diff --git a/.github/workflows/license-header-format.yml b/.github/workflows/license-header-format.yml
index 0ac41c7bc8..6be1171b32 100644
--- a/.github/workflows/license-header-format.yml
+++ b/.github/workflows/license-header-format.yml
@@ -35,7 +35,7 @@ jobs:
uses: actions/setup-java@v4
with:
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
- name: License header format
diff --git a/README.md b/README.md
index 6267cc3a7a..169a276db4 100644
--- a/README.md
+++ b/README.md
@@ -8,15 +8,13 @@
-💡 [Get started](https://thingsboard.io/docs/getting-started-guides/helloworld/) • 🌐 [Website](https://thingsboard.io/) • 📚 [Documentation](https://thingsboard.io/docs/) • 📔 [Blog](https://thingsboard.io/blog/) • ▶️ [Live demo](https://demo.thingsboard.io/signup) • 🔗 [LinkedIn](https://www.linkedin.com/company/thingsboard/posts/?feedView=all)
+💡 [Get started](https://thingsboard.io/docs/getting-started-guides/helloworld/) • 🌐 [Website](https://thingsboard.io/) • 📚 [Documentation](https://thingsboard.io/docs/) • 📔 [Blog](https://thingsboard.io/blog/) • 🔗 [LinkedIn](https://www.linkedin.com/company/thingsboard/posts/?feedView=all)
## 🚀 Installation options
-* Install ThingsBoard [On-premise](https://thingsboard.io/docs/user-guide/install/installation-options/?ceInstallType=onPremise)
-* Try [ThingsBoard Cloud](https://thingsboard.io/installations/)
-* or [Use our Live demo](https://demo.thingsboard.io/signup)
+Install ThingsBoard [on-premises](https://thingsboard.io/docs/user-guide/install/installation-options/?ceInstallType=onPremise) or use [ThingsBoard Cloud](https://thingsboard.io/installations/).
## 💡 Getting started with ThingsBoard
@@ -123,9 +121,9 @@ ThingsBoard is a scalable, user-friendly, and device-agnostic IoT platform that
[](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/)
-[](https://thingsboard.io/use-cases/fleet-tracking/)
+[](https://thingsboard.io/use-cases/site-fleet-tracking/)
[**Smart farming**](https://thingsboard.io/use-cases/smart-farming/)
diff --git a/application/pom.xml b/application/pom.xml
index f6ceba95a1..60186c3d3b 100644
--- a/application/pom.xml
+++ b/application/pom.xml
@@ -20,7 +20,7 @@
4.0.0
org.thingsboard
- 4.3.1.3-SNAPSHOT
+ 4.4.0-SNAPSHOT
thingsboard
application
diff --git a/application/src/main/data/json/edge/instructions/install/centos/instructions.md b/application/src/main/data/json/edge/instructions/install/centos/instructions.md
index 1822add6d8..964cdcb887 100644
--- a/application/src/main/data/json/edge/instructions/install/centos/instructions.md
+++ b/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
Before continuing to installation, execute the following commands to install the necessary tools:
```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}
```
-#### Step 1. Install Java 17 (OpenJDK)
-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
-sudo dnf install java-17-openjdk
+sudo dnf install -y java-25-openjdk
{: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
sudo update-alternatives --config java
@@ -33,7 +35,7 @@ java -version
The expected result is:
```text
-openjdk version "17.x.xx"
+openjdk version "25.x.xx"
OpenJDK Runtime Environment (...)
OpenJDK 64-Bit Server VM (build ...)
```
@@ -41,7 +43,7 @@ OpenJDK 64-Bit Server VM (build ...)
#### Step 2. Configure ThingsBoard Edge Database
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 ThingsBoard documentation site.
To install the PostgreSQL database, run these commands:
@@ -54,19 +56,19 @@ sudo dnf update
Install the repository RPM:
-* **For CentOS/RHEL 8:**
+* **For RHEL/CentOS 9:**
```bash
-# Install the repository RPM (For CentOS/RHEL 8):
-sudo sudo dnf -y install https://download.postgresql.org/pub/repos/yum/reporpms/EL-8-x86_64/pgdg-redhat-repo-latest.noarch.rpm
+# Install the repository RPM (for RHEL/CentOS 9):
+sudo dnf -y install https://download.postgresql.org/pub/repos/yum/reporpms/EL-9-x86_64/pgdg-redhat-repo-latest.noarch.rpm
{:copy-code}
```
-* **For CentOS/RHEL 9:**
+* **For RHEL/CentOS 10:**
```bash
-# Install the repository RPM (for CentOS 9):
-sudo dnf -y install https://download.postgresql.org/pub/repos/yum/reporpms/EL-9-x86_64/pgdg-redhat-repo-latest.noarch.rpm
+# Install the repository RPM (for RHEL/CentOS 10):
+sudo dnf -y install https://download.postgresql.org/pub/repos/yum/reporpms/EL-10-x86_64/pgdg-redhat-repo-latest.noarch.rpm
{:copy-code}
```
@@ -91,8 +93,8 @@ sudo -u postgres psql -c "\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
-applications can access the database, thus protecting your data. After configuring the password,
+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,
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:
@@ -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}
```
-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.
```bash
diff --git a/application/src/main/data/json/edge/instructions/install/ubuntu/instructions.md b/application/src/main/data/json/edge/instructions/install/ubuntu/instructions.md
index b4782dd46a..6f74c09c08 100644
--- a/application/src/main/data/json/edge/instructions/install/ubuntu/instructions.md
+++ b/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.
-#### Step 1. Install Java 17 (OpenJDK)
-ThingsBoard service is running on Java 17. To install OpenJDK 17, follow these instructions:
+**Note:** OpenJDK 25 requires Ubuntu 22.04 LTS or newer. Earlier versions (20.04 and below) are not supported.
+
+#### Step 1. Install Java 25 (OpenJDK)
+ThingsBoard service is running on Java 25. To install OpenJDK 25, follow these instructions:
```bash
-sudo apt update && sudo apt install openjdk-17-jdk
+sudo apt update && sudo apt install openjdk-25-jdk
{: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
sudo update-alternatives --config java
@@ -25,7 +27,7 @@ java -version
The expected result is:
```text
-openjdk version "17.x.xx"
+openjdk version "25.x.xx"
OpenJDK Runtime Environment (...)
OpenJDK 64-Bit Server VM (build ...)
```
diff --git a/application/src/main/data/json/system/widget_types/label_widget.json b/application/src/main/data/json/system/widget_types/label_widget.json
index b8bfc94f41..46d448ac1a 100644
--- a/application/src/main/data/json/system/widget_types/label_widget.json
+++ b/application/src/main/data/json/system/widget_types/label_widget.json
@@ -11,7 +11,7 @@
"resources": [],
"templateHtml": "",
"templateCss": "#container {\n overflow: auto;\n}\n\n.tbDatasource-container {\n margin: 5px;\n padding: 8px;\n}\n\n.tbDatasource-title {\n font-size: 1.200rem;\n font-weight: 500;\n padding-bottom: 10px;\n}\n\n.tbDatasource-table {\n width: 100%;\n box-shadow: 0 0 10px #ccc;\n border-collapse: collapse;\n white-space: nowrap;\n font-size: 1.000rem;\n color: #757575;\n}\n\n.tbDatasource-table td {\n position: relative;\n border-top: 1px solid rgba(0, 0, 0, 0.12);\n border-bottom: 1px solid rgba(0, 0, 0, 0.12);\n padding: 0px 18px;\n box-sizing: border-box;\n}",
- "controllerScript": "self.onInit = function() {\n self.ctx.varsRegex = /\\$\\{([^\\}]*)\\}/g;\n \n var imageUrl = self.ctx.settings.backgroundImageUrl ? self.ctx.settings.backgroundImageUrl :\n 'data:image/svg+xml;base64,PHN2ZyBpZD0ic3ZnMiIgeG1sbnM6cmRmPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5LzAyLzIyLXJkZi1zeW50YXgtbnMjIiB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIGhlaWdodD0iMTAwIiB3aWR0aD0iMTAwIiB2ZXJzaW9uPSIxLjEiIHhtbG5zOmNjPSJodHRwOi8vY3JlYXRpdmVjb21tb25zLm9yZy9ucyMiIHhtbG5zOmRjPSJodHRwOi8vcHVybC5vcmcvZGMvZWxlbWVudHMvMS4xLyIgdmlld0JveD0iMCAwIDEwMCAxMDAiPgogPGcgaWQ9ImxheWVyMSIgdHJhbnNmb3JtPSJ0cmFuc2xhdGUoMCAtOTUyLjM2KSI+CiAgPHJlY3QgaWQ9InJlY3Q0Njg0IiBzdHJva2UtbGluZWpvaW49InJvdW5kIiBoZWlnaHQ9Ijk5LjAxIiB3aWR0aD0iOTkuMDEiIHN0cm9rZT0iIzAwMCIgc3Ryb2tlLWxpbmVjYXA9InJvdW5kIiB5PSI5NTIuODYiIHg9Ii40OTUwNSIgc3Ryb2tlLXdpZHRoPSIuOTkwMTAiIGZpbGw9IiNlZWUiLz4KICA8dGV4dCBpZD0idGV4dDQ2ODYiIHN0eWxlPSJ3b3JkLXNwYWNpbmc6MHB4O2xldHRlci1zcGFjaW5nOjBweDt0ZXh0LWFuY2hvcjptaWRkbGU7dGV4dC1hbGlnbjpjZW50ZXIiIGZvbnQtd2VpZ2h0PSJib2xkIiB4bWw6c3BhY2U9InByZXNlcnZlIiBmb250LXNpemU9IjEwcHgiIGxpbmUtaGVpZ2h0PSIxMjUlIiB5PSI5NzAuNzI4MDkiIHg9IjQ5LjM5NjQ3NyIgZm9udC1mYW1pbHk9IlJvYm90byIgZmlsbD0iIzY2NjY2NiI+PHRzcGFuIGlkPSJ0c3BhbjQ2OTAiIHg9IjUwLjY0NjQ3NyIgeT0iOTcwLjcyODA5Ij5JbWFnZSBiYWNrZ3JvdW5kIDwvdHNwYW4+PHRzcGFuIGlkPSJ0c3BhbjQ2OTIiIHg9IjQ5LjM5NjQ3NyIgeT0iOTgzLjIyODA5Ij5pcyBub3QgY29uZmlndXJlZDwvdHNwYW4+PC90ZXh0PgogIDxyZWN0IGlkPSJyZWN0NDY5NCIgc3Ryb2tlLWxpbmVqb2luPSJyb3VuZCIgaGVpZ2h0PSIxOS4zNiIgd2lkdGg9IjY5LjM2IiBzdHJva2U9IiMwMDAiIHN0cm9rZS1saW5lY2FwPSJyb3VuZCIgeT0iOTkyLjY4IiB4PSIxNS4zMiIgc3Ryb2tlLXdpZHRoPSIuNjM5ODYiIGZpbGw9Im5vbmUiLz4KIDwvZz4KPC9zdmc+Cg==';\n\n function processLabelPattern(pattern, data) {\n var match = self.ctx.varsRegex.exec(pattern);\n var replaceInfo = {};\n replaceInfo.variables = [];\n while (match !== null) {\n var variableInfo = {};\n variableInfo.dataKeyIndex = -1;\n var variable = match[0];\n var label = match[1];\n var valDec = 2;\n var splitVals = label.split(':');\n if (splitVals.length > 1) {\n label = splitVals[0];\n valDec = parseFloat(splitVals[1]);\n }\n variableInfo.variable = variable;\n variableInfo.valDec = valDec;\n \n if (label.startsWith('#')) {\n var keyIndexStr = label.substring(1);\n var n = Math.floor(Number(keyIndexStr));\n if (String(n) === keyIndexStr && n >= 0) {\n variableInfo.dataKeyIndex = n;\n }\n }\n if (variableInfo.dataKeyIndex === -1) {\n for (var i = 0; i < data.length; i++) {\n var datasourceData = data[i];\n var dataKey = datasourceData.dataKey;\n if (dataKey.label === label) {\n variableInfo.dataKeyIndex = i;\n break;\n }\n }\n }\n replaceInfo.variables.push(variableInfo);\n match = self.ctx.varsRegex.exec(pattern);\n }\n return replaceInfo;\n }\n\n var configuredLabels = self.ctx.settings.labels;\n if (!configuredLabels) {\n configuredLabels = [];\n }\n \n self.ctx.labels = [];\n\n for (var l = 0; l < configuredLabels.length; l++) {\n var labelConfig = configuredLabels[l];\n var localConfig = {};\n localConfig.font = {};\n \n localConfig.pattern = labelConfig.pattern ? labelConfig.pattern : '${#0}';\n localConfig.x = labelConfig.x ? labelConfig.x : 0;\n localConfig.y = labelConfig.y ? labelConfig.y : 0;\n localConfig.backgroundColor = labelConfig.backgroundColor ? labelConfig.backgroundColor : 'rgba(0,0,0,0)';\n \n var settingsFont = labelConfig.font;\n if (!settingsFont) {\n settingsFont = {};\n }\n \n localConfig.font.family = settingsFont.family || 'Roboto';\n localConfig.font.size = settingsFont.size ? settingsFont.size : 6;\n localConfig.font.style = settingsFont.style ? settingsFont.style : 'normal';\n localConfig.font.weight = settingsFont.weight ? settingsFont.weight : '500';\n localConfig.font.color = settingsFont.color ? settingsFont.color : '#fff';\n \n localConfig.replaceInfo = processLabelPattern(localConfig.pattern, self.ctx.data);\n \n var label = {};\n var labelElement = $('');\n labelElement.css('position', 'absolute');\n labelElement.css('display', 'none');\n labelElement.css('top', '0');\n labelElement.css('left', '0');\n labelElement.css('backgroundColor', localConfig.backgroundColor);\n labelElement.css('color', localConfig.font.color);\n labelElement.css('fontFamily', localConfig.font.family);\n labelElement.css('fontStyle', localConfig.font.style);\n labelElement.css('fontWeight', localConfig.font.weight);\n \n labelElement.html(localConfig.pattern);\n self.ctx.$container.append(labelElement);\n label.element = labelElement;\n label.config = localConfig;\n label.htmlSet = false;\n label.visible = false;\n self.ctx.labels.push(label);\n }\n \n self.ctx.imagePipe.transform(imageUrl, {asString: true, ignoreLoadingImage: true}).subscribe(\n function (transformedUrl) {\n self.ctx.$container.css('background', 'url(\"'+transformedUrl+'\") no-repeat');\n self.ctx.$container.css('backgroundSize', 'contain');\n self.ctx.$container.css('backgroundPosition', '50% 50%');\n var bgImg = $('
');\n bgImg.hide();\n bgImg.bind('load', function()\n {\n self.ctx.bImageHeight = $(this).height();\n self.ctx.bImageWidth = $(this).width();\n self.onResize();\n });\n self.ctx.$container.append(bgImg);\n bgImg.attr('src', transformedUrl);\n }\n );\n\n self.onDataUpdated();\n}\n\nself.onDataUpdated = function() {\n updateLabels();\n}\n\nself.onResize = function() {\n if (self.ctx.bImageHeight && self.ctx.bImageWidth) {\n var backgroundRect = {};\n var imageRatio = self.ctx.bImageWidth / self.ctx.bImageHeight;\n var componentRatio = self.ctx.width / self.ctx.height;\n if (componentRatio >= imageRatio) {\n backgroundRect.top = 0;\n backgroundRect.bottom = 1.0;\n backgroundRect.xRatio = imageRatio / componentRatio;\n backgroundRect.yRatio = 1;\n var offset = (1 - backgroundRect.xRatio) / 2;\n backgroundRect.left = offset;\n backgroundRect.right = 1 - offset;\n } else {\n backgroundRect.left = 0;\n backgroundRect.right = 1.0;\n backgroundRect.xRatio = 1;\n backgroundRect.yRatio = componentRatio / imageRatio;\n var offset = (1 - backgroundRect.yRatio) / 2;\n backgroundRect.top = offset;\n backgroundRect.bottom = 1 - offset;\n }\n for (var l = 0; l < self.ctx.labels.length; l++) {\n var label = self.ctx.labels[l];\n var labelLeft = backgroundRect.left*100 + (label.config.x*backgroundRect.xRatio);\n var labelTop = backgroundRect.top*100 + (label.config.y*backgroundRect.yRatio);\n var fontSize = self.ctx.height * backgroundRect.yRatio * label.config.font.size / 100;\n label.element.css('top', labelTop + '%');\n label.element.css('left', labelLeft + '%');\n label.element.css('fontSize', fontSize + 'px');\n if (!label.visible) {\n label.element.css('display', 'block');\n label.visible = true;\n }\n }\n } \n}\n\n\nfunction isNumber(n) {\n return !isNaN(parseFloat(n)) && isFinite(n);\n}\n\nfunction padValue(val, dec, int) {\n var i = 0;\n var s, strVal, n;\n\n val = parseFloat(val);\n n = (val < 0);\n val = Math.abs(val);\n\n if (dec > 0) {\n strVal = val.toFixed(dec).toString().split('.');\n s = int - strVal[0].length;\n\n for (; i < s; ++i) {\n strVal[0] = '0' + strVal[0];\n }\n\n strVal = (n ? '-' : '') + strVal[0] + '.' + strVal[1];\n }\n\n else {\n strVal = Math.round(val).toString();\n s = int - strVal.length;\n\n for (; i < s; ++i) {\n strVal = '0' + strVal;\n }\n\n strVal = (n ? '-' : '') + strVal;\n }\n\n return strVal;\n}\n\nfunction updateLabels() {\n for (var l = 0; l < self.ctx.labels.length; l++) {\n var label = self.ctx.labels[l];\n var text = label.config.pattern;\n var replaceInfo = label.config.replaceInfo;\n var updated = false;\n for (var v = 0; v < replaceInfo.variables.length; v++) {\n var variableInfo = replaceInfo.variables[v];\n var txtVal = '';\n if (variableInfo.dataKeyIndex > -1) {\n var varData = self.ctx.data[variableInfo.dataKeyIndex].data;\n if (varData.length > 0) {\n var val = varData[varData.length-1][1];\n if (isNumber(val)) {\n txtVal = padValue(val, variableInfo.valDec, 0);\n updated = true;\n } else {\n txtVal = val;\n updated = true;\n }\n }\n }\n text = text.split(variableInfo.variable).join(txtVal);\n }\n if (updated || !label.htmlSet) {\n label.element.html(text);\n if (!label.htmlSet) {\n label.htmlSet = true;\n }\n }\n }\n}\n\nself.typeParameters = function() {\n return {\n maxDatasources: 1,\n singleEntity: true\n };\n};\n\nself.onDestroy = function() {\n}\n",
+ "controllerScript": "self.onInit = function() {\n self.ctx.varsRegex = /\\$\\{([^\\}]*)\\}/g;\n \n var imageUrl = self.ctx.settings.backgroundImageUrl ? self.ctx.settings.backgroundImageUrl :\n 'data:image/svg+xml;base64,PHN2ZyBpZD0ic3ZnMiIgeG1sbnM6cmRmPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5LzAyLzIyLXJkZi1zeW50YXgtbnMjIiB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIGhlaWdodD0iMTAwIiB3aWR0aD0iMTAwIiB2ZXJzaW9uPSIxLjEiIHhtbG5zOmNjPSJodHRwOi8vY3JlYXRpdmVjb21tb25zLm9yZy9ucyMiIHhtbG5zOmRjPSJodHRwOi8vcHVybC5vcmcvZGMvZWxlbWVudHMvMS4xLyIgdmlld0JveD0iMCAwIDEwMCAxMDAiPgogPGcgaWQ9ImxheWVyMSIgdHJhbnNmb3JtPSJ0cmFuc2xhdGUoMCAtOTUyLjM2KSI+CiAgPHJlY3QgaWQ9InJlY3Q0Njg0IiBzdHJva2UtbGluZWpvaW49InJvdW5kIiBoZWlnaHQ9Ijk5LjAxIiB3aWR0aD0iOTkuMDEiIHN0cm9rZT0iIzAwMCIgc3Ryb2tlLWxpbmVjYXA9InJvdW5kIiB5PSI5NTIuODYiIHg9Ii40OTUwNSIgc3Ryb2tlLXdpZHRoPSIuOTkwMTAiIGZpbGw9IiNlZWUiLz4KICA8dGV4dCBpZD0idGV4dDQ2ODYiIHN0eWxlPSJ3b3JkLXNwYWNpbmc6MHB4O2xldHRlci1zcGFjaW5nOjBweDt0ZXh0LWFuY2hvcjptaWRkbGU7dGV4dC1hbGlnbjpjZW50ZXIiIGZvbnQtd2VpZ2h0PSJib2xkIiB4bWw6c3BhY2U9InByZXNlcnZlIiBmb250LXNpemU9IjEwcHgiIGxpbmUtaGVpZ2h0PSIxMjUlIiB5PSI5NzAuNzI4MDkiIHg9IjQ5LjM5NjQ3NyIgZm9udC1mYW1pbHk9IlJvYm90byIgZmlsbD0iIzY2NjY2NiI+PHRzcGFuIGlkPSJ0c3BhbjQ2OTAiIHg9IjUwLjY0NjQ3NyIgeT0iOTcwLjcyODA5Ij5JbWFnZSBiYWNrZ3JvdW5kIDwvdHNwYW4+PHRzcGFuIGlkPSJ0c3BhbjQ2OTIiIHg9IjQ5LjM5NjQ3NyIgeT0iOTgzLjIyODA5Ij5pcyBub3QgY29uZmlndXJlZDwvdHNwYW4+PC90ZXh0PgogIDxyZWN0IGlkPSJyZWN0NDY5NCIgc3Ryb2tlLWxpbmVqb2luPSJyb3VuZCIgaGVpZ2h0PSIxOS4zNiIgd2lkdGg9IjY5LjM2IiBzdHJva2U9IiMwMDAiIHN0cm9rZS1saW5lY2FwPSJyb3VuZCIgeT0iOTkyLjY4IiB4PSIxNS4zMiIgc3Ryb2tlLXdpZHRoPSIuNjM5ODYiIGZpbGw9Im5vbmUiLz4KIDwvZz4KPC9zdmc+Cg==';\n\n function processLabelPattern(pattern, data) {\n var match = self.ctx.varsRegex.exec(pattern);\n var replaceInfo = {};\n replaceInfo.variables = [];\n while (match !== null) {\n var variableInfo = {};\n variableInfo.dataKeyIndex = -1;\n var variable = match[0];\n var label = match[1];\n var valDec = 2;\n var splitVals = label.split(':');\n if (splitVals.length > 1) {\n label = splitVals[0];\n valDec = parseFloat(splitVals[1]);\n }\n variableInfo.variable = variable;\n variableInfo.valDec = valDec;\n \n if (label.startsWith('#')) {\n var keyIndexStr = label.substring(1);\n var n = Math.floor(Number(keyIndexStr));\n if (String(n) === keyIndexStr && n >= 0) {\n variableInfo.dataKeyIndex = n;\n }\n }\n if (variableInfo.dataKeyIndex === -1) {\n for (var i = 0; i < data.length; i++) {\n var datasourceData = data[i];\n var dataKey = datasourceData.dataKey;\n if (dataKey.label === label) {\n variableInfo.dataKeyIndex = i;\n break;\n }\n }\n }\n replaceInfo.variables.push(variableInfo);\n match = self.ctx.varsRegex.exec(pattern);\n }\n return replaceInfo;\n }\n\n var configuredLabels = self.ctx.settings.labels;\n if (!configuredLabels) {\n configuredLabels = [];\n }\n \n self.ctx.labels = [];\n\n for (var l = 0; l < configuredLabels.length; l++) {\n var labelConfig = configuredLabels[l];\n var localConfig = {};\n localConfig.font = {};\n \n localConfig.pattern = labelConfig.pattern ? labelConfig.pattern : '${#0}';\n localConfig.x = labelConfig.x ? labelConfig.x : 0;\n localConfig.y = labelConfig.y ? labelConfig.y : 0;\n localConfig.backgroundColor = labelConfig.backgroundColor ? labelConfig.backgroundColor : 'rgba(0,0,0,0)';\n \n var settingsFont = labelConfig.font;\n if (!settingsFont) {\n settingsFont = {};\n }\n \n localConfig.font.family = settingsFont.family || 'Roboto';\n localConfig.font.size = settingsFont.size ? settingsFont.size : 6;\n localConfig.font.style = settingsFont.style ? settingsFont.style : 'normal';\n localConfig.font.weight = settingsFont.weight ? settingsFont.weight : '500';\n localConfig.font.color = settingsFont.color ? settingsFont.color : '#fff';\n \n localConfig.replaceInfo = processLabelPattern(localConfig.pattern, self.ctx.data);\n \n var label = {};\n var labelElement = $('');\n labelElement.css('position', 'absolute');\n labelElement.css('display', 'none');\n labelElement.css('top', '0');\n labelElement.css('left', '0');\n labelElement.css('backgroundColor', localConfig.backgroundColor);\n labelElement.css('color', localConfig.font.color);\n labelElement.css('fontFamily', localConfig.font.family);\n labelElement.css('fontStyle', localConfig.font.style);\n labelElement.css('fontWeight', localConfig.font.weight);\n \n labelElement.html(localConfig.pattern);\n self.ctx.$container.append(labelElement);\n label.element = labelElement;\n label.config = localConfig;\n label.htmlSet = false;\n label.visible = false;\n self.ctx.labels.push(label);\n }\n \n self.ctx.imagePipe.transform(imageUrl, {asString: true, ignoreLoadingImage: true}).subscribe(\n function (transformedUrl) {\n self.ctx.$container.css('background', 'url(\"'+transformedUrl+'\") no-repeat');\n self.ctx.$container.css('backgroundSize', 'contain');\n self.ctx.$container.css('backgroundPosition', '50% 50%');\n var bgImg = $('
');\n bgImg.hide();\n bgImg.bind('load', function()\n {\n self.ctx.bImageHeight = $(this).height();\n self.ctx.bImageWidth = $(this).width();\n self.onResize();\n });\n self.ctx.$container.append(bgImg);\n bgImg.attr('src', transformedUrl);\n }\n );\n\n self.onDataUpdated();\n}\n\nself.onDataUpdated = function() {\n updateLabels();\n}\n\nself.onResize = function() {\n if (self.ctx.bImageHeight && self.ctx.bImageWidth) {\n var backgroundRect = {};\n var imageRatio = self.ctx.bImageWidth / self.ctx.bImageHeight;\n var componentRatio = self.ctx.width / self.ctx.height;\n if (componentRatio >= imageRatio) {\n backgroundRect.top = 0;\n backgroundRect.bottom = 1.0;\n backgroundRect.xRatio = imageRatio / componentRatio;\n backgroundRect.yRatio = 1;\n var offset = (1 - backgroundRect.xRatio) / 2;\n backgroundRect.left = offset;\n backgroundRect.right = 1 - offset;\n } else {\n backgroundRect.left = 0;\n backgroundRect.right = 1.0;\n backgroundRect.xRatio = 1;\n backgroundRect.yRatio = componentRatio / imageRatio;\n var offset = (1 - backgroundRect.yRatio) / 2;\n backgroundRect.top = offset;\n backgroundRect.bottom = 1 - offset;\n }\n for (var l = 0; l < self.ctx.labels.length; l++) {\n var label = self.ctx.labels[l];\n var labelLeft = backgroundRect.left*100 + (label.config.x*backgroundRect.xRatio);\n var labelTop = backgroundRect.top*100 + (label.config.y*backgroundRect.yRatio);\n var fontSize = self.ctx.height * backgroundRect.yRatio * label.config.font.size / 100;\n label.element.css('top', labelTop + '%');\n label.element.css('left', labelLeft + '%');\n label.element.css('fontSize', fontSize + 'px');\n if (!label.visible) {\n label.element.css('display', 'block');\n label.visible = true;\n }\n }\n } \n}\n\n\nfunction isNumber(n) {\n return !isNaN(parseFloat(n)) && isFinite(n);\n}\n\nfunction padValue(val, dec, int) {\n var i = 0;\n var s, strVal, n;\n\n val = parseFloat(val);\n n = (val < 0);\n val = Math.abs(val);\n\n if (dec > 0) {\n strVal = val.toFixed(dec).toString().split('.');\n s = int - strVal[0].length;\n\n for (; i < s; ++i) {\n strVal[0] = '0' + strVal[0];\n }\n\n strVal = (n ? '-' : '') + strVal[0] + '.' + strVal[1];\n }\n\n else {\n strVal = Math.round(val).toString();\n s = int - strVal.length;\n\n for (; i < s; ++i) {\n strVal = '0' + strVal;\n }\n\n strVal = (n ? '-' : '') + strVal;\n }\n\n return strVal;\n}\n\nfunction updateLabels() {\n for (var l = 0; l < self.ctx.labels.length; l++) {\n var label = self.ctx.labels[l];\n var text = label.config.pattern;\n var replaceInfo = label.config.replaceInfo;\n var updated = false;\n for (var v = 0; v < replaceInfo.variables.length; v++) {\n var variableInfo = replaceInfo.variables[v];\n var txtVal = '';\n if (variableInfo.dataKeyIndex > -1) {\n var varData = self.ctx.data[variableInfo.dataKeyIndex].data;\n if (varData.length > 0) {\n var val = varData[varData.length-1][1];\n if (isNumber(val)) {\n txtVal = padValue(val, variableInfo.valDec, 0);\n updated = true;\n } else {\n txtVal = val;\n updated = true;\n }\n }\n }\n text = text.split(variableInfo.variable).join(txtVal);\n }\n text = self.ctx.utilsService.customTranslation(text);\n if (updated || !label.htmlSet) {\n label.element.html(text);\n if (!label.htmlSet) {\n label.htmlSet = true;\n }\n }\n }\n}\n\nself.typeParameters = function() {\n return {\n maxDatasources: 1,\n singleEntity: true\n };\n};\n\nself.onDestroy = function() {\n}\n",
"dataKeySettingsSchema": "{}\n",
"settingsDirective": "tb-label-widget-settings",
"defaultConfig": "{\"datasources\":[{\"type\":\"function\",\"name\":\"function\",\"dataKeys\":[{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"var\",\"color\":\"#2196f3\",\"settings\":{},\"_hash\":0.15479322438769105,\"funcBody\":\"var value = prevValue + Math.random() * 100 - 50;\\nvar multiplier = Math.pow(10, 2 || 0);\\nvar value = Math.round(value * multiplier) / multiplier;\\nif (value < -1000) {\\n\\tvalue = -1000;\\n} else if (value > 1000) {\\n\\tvalue = 1000;\\n}\\nreturn value;\"}]}],\"showTitle\":true,\"backgroundColor\":\"#fff\",\"color\":\"rgba(0, 0, 0, 0.87)\",\"padding\":\"8px\",\"settings\":{\"backgroundImageUrl\":\"tb-image;/api/images/system/here_map_system_widget_map_image.svg\",\"labels\":[{\"pattern\":\"Value: ${#0:2} units.\",\"x\":20,\"y\":47,\"font\":{\"color\":\"#515151\",\"family\":\"Roboto\",\"size\":6,\"style\":\"normal\",\"weight\":\"500\"}}]},\"title\":\"Label widget\",\"dropShadow\":true,\"enableFullscreen\":true,\"titleStyle\":{\"fontSize\":\"16px\",\"fontWeight\":400}}"
diff --git a/application/src/main/data/json/system/widget_types/unread_notifications.json b/application/src/main/data/json/system/widget_types/unread_notifications.json
index 7d699e7018..c4d2f899fb 100644
--- a/application/src/main/data/json/system/widget_types/unread_notifications.json
+++ b/application/src/main/data/json/system/widget_types/unread_notifications.json
@@ -3,6 +3,7 @@
"name": "Unread notifications",
"deprecated": false,
"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": {
"type": "static",
"sizeX": 5.5,
diff --git a/application/src/main/data/upgrade/basic/schema_update.sql b/application/src/main/data/upgrade/basic/schema_update.sql
index 66243461e2..2f9c0cd302 100644
--- a/application/src/main/data/upgrade/basic/schema_update.sql
+++ b/application/src/main/data/upgrade/basic/schema_update.sql
@@ -14,69 +14,14 @@
-- limitations under the License.
--
--- UPDATE TENANT PROFILE CONFIGURATION START
+-- CALCULATED FIELD ADDITIONAL INFO ADDITION START
-UPDATE tenant_profile
-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'
- ]
-);
+ALTER TABLE calculated_field ADD COLUMN IF NOT EXISTS additional_info varchar;
--- 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 calculated_field ADD CONSTRAINT calculated_field_unq_key UNIQUE (entity_id, type, name);
+ALTER TABLE rule_chain ADD COLUMN IF NOT EXISTS notes varchar(1000000);
--- CALCULATED FIELD UNIQUE CONSTRAINT UPDATE 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
+-- RULE CHAIN NOTES MIGRATION END
diff --git a/application/src/main/java/org/thingsboard/server/config/WebSocketConfiguration.java b/application/src/main/java/org/thingsboard/server/config/WebSocketConfiguration.java
index 38f37cf35d..4ce7268232 100644
--- a/application/src/main/java/org/thingsboard/server/config/WebSocketConfiguration.java
+++ b/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);
throw new RuntimeException("TbWebSocketHandler expected but " + wsHandler + " provided");
}
- registry.addHandler(wsHandler, WS_API_MAPPING).setAllowedOriginPatterns("*");
+ registry.addHandler(wsHandler, WS_API_MAPPING)
+ .setAllowedOriginPatterns("*");
}
}
diff --git a/application/src/main/java/org/thingsboard/server/controller/SystemInfoController.java b/application/src/main/java/org/thingsboard/server/controller/SystemInfoController.java
index 41e8949a71..e9c10261ad 100644
--- a/application/src/main/java/org/thingsboard/server/controller/SystemInfoController.java
+++ b/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}")
private int defaultDebugDurationMinutes;
+ @Value("${sql.query.key-filters-or-conditions.enabled:true}")
+ private boolean keyFiltersOrConditionsEnabled;
+
@Value("${sql.entity_data_query_nulls_order_strategy:default}")
private String nullsOrderStrategy;
@@ -189,6 +192,7 @@ public class SystemInfoController extends BaseController {
systemParams.setMobileQrEnabled(Optional.ofNullable(qrCodeSettingService.findQrCodeSettings(TenantId.SYS_TENANT_ID))
.map(QrCodeSettings::getQrCodeConfig).map(QRCodeConfig::isShowOnHomePage)
.orElse(false));
+ systemParams.setAllowKeyFiltersOrConditions(keyFiltersOrConditionsEnabled);
return systemParams;
}
diff --git a/application/src/main/java/org/thingsboard/server/controller/plugin/TbWebSocketHandler.java b/application/src/main/java/org/thingsboard/server/controller/plugin/TbWebSocketHandler.java
index 133584201c..cf56d051ca 100644
--- a/application/src/main/java/org/thingsboard/server/controller/plugin/TbWebSocketHandler.java
+++ b/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.queue.util.TbCoreComponent;
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.model.SecurityUser;
import org.thingsboard.server.service.security.model.UserPrincipal;
@@ -100,6 +101,8 @@ public class TbWebSocketHandler extends TextWebSocketHandler implements WebSocke
private RateLimitService rateLimitService;
@Autowired
private JwtAuthenticationProvider authenticationProvider;
+ @Autowired
+ private ApiKeyAuthenticationProvider apiKeyAuthenticationProvider;
@Value("${server.ws.send_timeout:5000}")
private long sendTimeout;
@@ -194,7 +197,11 @@ public class TbWebSocketHandler extends TextWebSocketHandler implements WebSocke
log.trace("{} Authenticating session", sessionRef);
SecurityUser securityCtx;
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) {
close(sessionRef, CloseStatus.BAD_DATA.withReason(e.getMessage()));
return;
@@ -328,9 +335,12 @@ public class TbWebSocketHandler extends TextWebSocketHandler implements WebSocke
}
SecurityUser securityCtx = null;
- String token = StringUtils.substringAfter(session.getUri().getQuery(), "token=");
- if (StringUtils.isNotEmpty(token)) {
- securityCtx = authenticationProvider.authenticate(token);
+ String query = session.getUri().getQuery();
+ if (query != null) {
+ String token = extractQueryParam(query, "token");
+ if (StringUtils.isNotEmpty(token)) {
+ securityCtx = authenticationProvider.authenticate(token);
+ }
}
return WebSocketSessionRef.builder()
.sessionId(UUID.randomUUID().toString())
@@ -341,6 +351,15 @@ public class TbWebSocketHandler extends TextWebSocketHandler implements WebSocke
.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) {
SessionMetaData sessionMd = internalSessionMap.get(internalSessionId);
if (sessionMd == null) {
@@ -482,6 +501,7 @@ public class TbWebSocketHandler extends TextWebSocketHandler implements WebSocke
}
}
}
+
}
@Override
diff --git a/application/src/main/java/org/thingsboard/server/install/ThingsboardInstallService.java b/application/src/main/java/org/thingsboard/server/install/ThingsboardInstallService.java
index 8ea989fc01..66695c396f 100644
--- a/application/src/main/java/org/thingsboard/server/install/ThingsboardInstallService.java
+++ b/application/src/main/java/org/thingsboard/server/install/ThingsboardInstallService.java
@@ -116,7 +116,6 @@ public class ThingsboardInstallService {
entityDatabaseSchemaService.createDatabaseIndexes();
// TODO: cleanup update code after each release
- systemDataLoaderService.updateDefaultNotificationConfigs(false);
// Runs upgrade scripts that are not possible in plain SQL.
dataUpdateService.updateData();
diff --git a/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/alarm/AlarmCalculatedFieldState.java b/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/alarm/AlarmCalculatedFieldState.java
index 8aa2c6d1c9..f9c06dbda7 100644
--- a/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/alarm/AlarmCalculatedFieldState.java
+++ b/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.expression.AlarmConditionExpression;
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.TbelAlarmConditionExpression;
import org.thingsboard.server.common.data.alarm.rule.condition.expression.predicate.BooleanFilterPredicate;
diff --git a/application/src/main/java/org/thingsboard/server/service/edge/EdgeContextComponent.java b/application/src/main/java/org/thingsboard/server/service/edge/EdgeContextComponent.java
index c99261b249..065c3f2cec 100644
--- a/application/src/main/java/org/thingsboard/server/service/edge/EdgeContextComponent.java
+++ b/application/src/main/java/org/thingsboard/server/service/edge/EdgeContextComponent.java
@@ -15,11 +15,14 @@
*/
package org.thingsboard.server.service.edge;
+import jakarta.annotation.PreDestroy;
import lombok.Data;
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.springframework.stereotype.Component;
+import org.thingsboard.common.util.ThingsBoardExecutors;
import org.thingsboard.server.cache.limits.RateLimitService;
import org.thingsboard.server.cluster.TbClusterService;
import org.thingsboard.server.common.data.edge.EdgeEventType;
@@ -86,6 +89,8 @@ import java.util.EnumMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
+import java.util.concurrent.Executors;
+import java.util.concurrent.ScheduledExecutorService;
@Lazy
@Data
@@ -96,14 +101,25 @@ public class EdgeContextComponent {
private final Map processorMap = new EnumMap<>(EdgeEventType.class);
+ private ScheduledExecutorService edgeEventProcessingExecutorService;
+
@Autowired
- public EdgeContextComponent(List processors) {
+ public EdgeContextComponent(List processors,
+ @Value("${edges.scheduler_pool_size}") int schedulerPoolSize) {
processors.forEach(processor -> {
EdgeEventType eventType = processor.getEdgeEventType();
if (eventType != null) {
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
diff --git a/application/src/main/java/org/thingsboard/server/service/edge/rpc/DownlinkMessageMapper.java b/application/src/main/java/org/thingsboard/server/service/edge/rpc/DownlinkMessageMapper.java
new file mode 100644
index 0000000000..a83e3036f4
--- /dev/null
+++ b/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 convertToDownlinkMsgsPack(EdgeSessionState state, List edgeEvents) {
+ List result = new ArrayList<>();
+ List 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());
+ }
+}
diff --git a/application/src/main/java/org/thingsboard/server/service/edge/rpc/EdgeGrpcService.java b/application/src/main/java/org/thingsboard/server/service/edge/rpc/EdgeGrpcService.java
deleted file mode 100644
index 072a4878d5..0000000000
--- a/application/src/main/java/org/thingsboard/server/service/edge/rpc/EdgeGrpcService.java
+++ /dev/null
@@ -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 sessions = new ConcurrentHashMap<>();
- private final ConcurrentMap sessionsById = new ConcurrentHashMap<>();
- private final ConcurrentMap sessionNewEventsLocks = new ConcurrentHashMap<>();
- private final Map sessionNewEvents = new HashMap<>();
- private final ConcurrentMap> sessionEdgeEventChecks = new ConcurrentHashMap<>();
- private final ConcurrentMap> localSyncEdgeRequests = new ConcurrentHashMap<>();
- private final ConcurrentMap edgeEventsMigrationProcessed = new ConcurrentHashMap<>();
- private final List 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 edgeIdServiceIdCache;
-
- @Autowired
- private TopicService topicService;
-
- @Autowired
- private TbCoreQueueFactory tbCoreQueueFactory;
-
- @Autowired
- private Optional 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.
- *
- * Delegates PEM parsing and key management to {@link PemSslCredentials} — the same
- * class used by MQTT, CoAP, and LwM2M transports — which supports:
- *
- * - Separate certificate and private key files (classic two-file setup)
- * - Combined PEM: certificate chain + private key in a single {@code cert} file
- * ({@code private_key} left empty)
- * - Encrypted private keys (password supplied via {@code key_password})
- *
- * 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> 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 handleMsgs(StreamObserver outputStream) {
- EdgeGrpcSession session = createEdgeGrpcSession(outputStream);
- return session.getInputStream();
- }
-
- private EdgeGrpcSession createEdgeGrpcSession(StreamObserver 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 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 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 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 getZombieSessions(Collection sessions) {
- List result = new ArrayList<>();
- for (EdgeGrpcSession session : sessions) {
- if (isKafkaSessionAndZombie(session)) {
- result.add(session);
- }
- }
- return result;
- }
-
- private void tryToDestroyZombieSessions(List sessionsToRemove, Function 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;
- }
-
-}
diff --git a/application/src/main/java/org/thingsboard/server/service/edge/rpc/EdgeGrpcSession.java b/application/src/main/java/org/thingsboard/server/service/edge/rpc/EdgeGrpcSession.java
deleted file mode 100644
index de4a92ae8c..0000000000
--- a/application/src/main/java/org/thingsboard/server/service/edge/rpc/EdgeGrpcSession.java
+++ /dev/null
@@ -1,1029 +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.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.Data;
-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.edge.EdgeEventType;
-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.BooleanDataEntry;
-import org.thingsboard.server.common.data.kv.LongDataEntry;
-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.data.page.TimePageLink;
-import org.thingsboard.server.common.msg.edge.EdgeEventUpdateMsg;
-import org.thingsboard.server.dao.edge.stats.EdgeStatsKey;
-import org.thingsboard.server.gen.edge.v1.AiModelUpdateMsg;
-import org.thingsboard.server.gen.edge.v1.ApiKeyUpdateMsg;
-import org.thingsboard.server.gen.edge.v1.AlarmCommentUpdateMsg;
-import org.thingsboard.server.gen.edge.v1.AlarmUpdateMsg;
-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.ConnectRequestMsg;
-import org.thingsboard.server.gen.edge.v1.ConnectResponseCode;
-import org.thingsboard.server.gen.edge.v1.ConnectResponseMsg;
-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.DownlinkMsg;
-import org.thingsboard.server.gen.edge.v1.DownlinkResponseMsg;
-import org.thingsboard.server.gen.edge.v1.EdgeConfiguration;
-import org.thingsboard.server.gen.edge.v1.EdgeUpdateMsg;
-import org.thingsboard.server.gen.edge.v1.EdgeVersion;
-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.RequestMsg;
-import org.thingsboard.server.gen.edge.v1.RequestMsgType;
-import org.thingsboard.server.gen.edge.v1.ResourceUpdateMsg;
-import org.thingsboard.server.gen.edge.v1.ResponseMsg;
-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.SyncCompletedMsg;
-import org.thingsboard.server.gen.edge.v1.UplinkMsg;
-import org.thingsboard.server.gen.edge.v1.UplinkResponseMsg;
-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.service.edge.EdgeContextComponent;
-import org.thingsboard.server.service.edge.EdgeMsgConstructorUtils;
-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.utils.EdgeVersionUtils;
-
-import java.io.Closeable;
-import java.util.ArrayList;
-import java.util.Arrays;
-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
-@Data
-public abstract class EdgeGrpcSession implements Closeable {
-
- private static final String QUEUE_START_TS_ATTR_KEY = "queueStartTs";
- private static final String QUEUE_START_SEQ_ID_ATTR_KEY = "queueStartSeqId";
-
- private static final int MAX_DOWNLINK_ATTEMPTS = 3;
- private static final String RATE_LIMIT_REACHED = "Rate limit reached";
-
- protected final ConcurrentLinkedQueue highPriorityQueue = new ConcurrentLinkedQueue<>();
-
- protected UUID sessionId;
- private BiConsumer sessionOpenListener;
- private BiConsumer sessionCloseListener;
-
- private final EdgeSessionState sessionState = new EdgeSessionState();
- private final ReentrantLock downlinkMsgLock = new ReentrantLock();
- private final Lock sequenceDependencyLock = new ReentrantLock();
-
- protected EdgeContextComponent ctx;
- protected Edge edge;
- protected TenantId tenantId;
-
- private Long newStartTs;
- private Long previousStartTs;
- private Long newStartSeqId;
- private Long previousStartSeqId;
-
- private StreamObserver inputStream;
- private StreamObserver outputStream;
-
- private volatile boolean connected;
- private volatile boolean syncInProgress;
-
- private EdgeVersion edgeVersion;
- private int maxInboundMessageSize;
- private int clientMaxInboundMessageSize;
- private int maxHighPriorityQueueSizePerSession;
-
- private ScheduledExecutorService sendDownlinkExecutorService;
-
- public EdgeGrpcSession(EdgeContextComponent ctx, StreamObserver outputStream,
- BiConsumer sessionOpenListener,
- BiConsumer sessionCloseListener,
- ScheduledExecutorService sendDownlinkExecutorService,
- int maxInboundMessageSize, int maxHighPriorityQueueSizePerSession) {
- this.sessionId = UUID.randomUUID();
- this.ctx = ctx;
- this.outputStream = outputStream;
- this.sessionOpenListener = sessionOpenListener;
- this.sessionCloseListener = sessionCloseListener;
- this.sendDownlinkExecutorService = sendDownlinkExecutorService;
- this.maxInboundMessageSize = maxInboundMessageSize;
- this.maxHighPriorityQueueSizePerSession = maxHighPriorityQueueSizePerSession;
- initInputStream();
- }
-
- protected abstract ListenableFuture migrateEdgeEvents() throws Exception;
-
- public void initInputStream() {
- inputStream = new StreamObserver<>() {
- @Override
- public void onNext(RequestMsg requestMsg) {
- if (!connected && 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: {}", tenantId, sessionId, requestMsg.getConnectRequestMsg().getMaxInboundMessageSize());
- clientMaxInboundMessageSize = requestMsg.getConnectRequestMsg().getMaxInboundMessageSize();
- }
- connected = true;
- }
- }
- if (connected) {
- 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 {
- updateSyncInProgress(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:", tenantId, sessionId, t);
- closeSession();
- }
-
- @Override
- public void onCompleted() {
- log.info("[{}][{}] Stream was closed and completed successfully!", tenantId, sessionId);
- closeSession();
- }
-
- private void closeSession() {
- connected = false;
- if (edge != null) {
- try {
- sessionCloseListener.accept(edge, sessionId);
- } catch (Exception ignored) {
- }
- }
- try {
- outputStream.onCompleted();
- } catch (Exception ignored) {
- }
- }
- };
- }
-
- public void onConfigurationUpdate(Edge edge) {
- log.debug("[{}] onConfigurationUpdate [{}]", sessionId, edge);
- this.tenantId = edge.getTenantId();
- this.edge = edge;
- if (!this.edge.getCustomerId().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();
- sendDownlinkMsg(edgeConfigMsg);
- }
-
- public void startSyncProcess(boolean fullSync) {
- if (!syncInProgress) {
- log.info("[{}][{}][{}] Staring edge sync process", tenantId, edge.getId(), sessionId);
- updateSyncInProgress(true);
- interruptGeneralProcessingOnSync();
- doSync(new EdgeSyncCursor(ctx, edge, fullSync));
- } else {
- log.info("[{}][{}][{}] Sync is already started, skipping starting it now", tenantId, edge.getId(), sessionId);
- }
- }
-
- private void doSync(EdgeSyncCursor cursor) {
- if (cursor.hasNext()) {
- EdgeEventFetcher next = cursor.getNext();
- log.debug("[{}][{}] starting sync process, cursor current idx = {}, class = {}",
- tenantId, edge.getId(), cursor.getCurrentIdx(), next.getClass().getSimpleName());
- ListenableFuture> future = startProcessingEdgeEvents(next);
- Futures.addCallback(future, new FutureCallback<>() {
- @Override
- public void onSuccess(@Nullable Pair result) {
- doSync(cursor);
- }
-
- @Override
- public void onFailure(Throwable t) {
- log.error("[{}][{}] Exception during sync process", tenantId, edge.getId(), t);
- }
- }, ctx.getGrpcCallbackExecutorService());
- } else {
- log.info("[{}][{}] sync process completed", tenantId, edge.getId());
- 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", tenantId, edge.getId(), t);
- markSyncCompletedSendEdgeEventUpdate();
- }
- }, ctx.getGrpcCallbackExecutorService());
- }
- }
-
- protected void processEdgeEvents(EdgeEventFetcher fetcher, PageLink pageLink, SettableFuture> result) {
- try {
- log.trace("[{}] Start processing edge events, fetcher = {}, pageLink = {}", edge.getId(), fetcher.getClass().getSimpleName(), pageLink);
- processHighPriorityEvents();
- PageData pageData = fetcher.fetchEdgeEvents(edge.getTenantId(), edge, pageLink);
- if (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 downlinkMsgsPack = convertToDownlinkMsgsPack(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(), sessionId);
- result.set(null);
- } else {
- if (isConnected() && pageData.hasNext()) {
- processEdgeEvents(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 ConnectResponseMsg processConnect(ConnectRequestMsg request) {
- log.trace("[{}] processConnect [{}]", sessionId, request);
- Optional optional = ctx.getEdgeService().findEdgeByRoutingKey(TenantId.SYS_TENANT_ID, request.getEdgeRoutingKey());
- if (optional.isPresent()) {
- edge = optional.get();
- tenantId = edge.getTenantId();
- try {
- if (edge.getSecret().equals(request.getEdgeSecret())) {
- sessionOpenListener.accept(edge.getId(), this);
- edgeVersion = 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(tenantId, edge.getId(), AttributeScope.SERVER_SCOPE, attributeKvEntry);
- }
-
- private void updateSyncInProgress(Boolean value) {
- this.syncInProgress = value;
-
- ctx.getTsSubService().saveAttributes(AttributesSaveRequest.builder()
- .tenantId(tenantId)
- .entityId(edge.getId())
- .scope(AttributeScope.SERVER_SCOPE)
- .entry(new BooleanDataEntry(DataConstants.EDGE_SYNC_IN_PROGRESS_ATTR_KEY, value))
- .callback(new AttributeSaveCallback(tenantId, edge.getId(), DataConstants.EDGE_SYNC_IN_PROGRESS_ATTR_KEY, value))
- .build());
- }
-
- private void interruptGeneralProcessingOnSync() {
- log.debug("[{}][{}][{}] Sync process started. General processing interrupted!", tenantId, edge.getId(), sessionId);
- stopCurrentSendDownlinkMsgsTask(true);
- }
-
- protected ListenableFuture sendDownlinkMsgsPack(List downlinkMsgsPack) {
- interruptPreviousSendDownlinkMsgsTask();
-
- sessionState.setSendDownlinkMsgsFuture(SettableFuture.create());
- sessionState.getPendingMsgsMap().clear();
-
- downlinkMsgsPack.forEach(msg -> sessionState.getPendingMsgsMap().put(msg.getDownlinkMsgId(), msg));
- scheduleDownlinkMsgsPackSend(1);
-
- return sessionState.getSendDownlinkMsgsFuture();
- }
-
- private void interruptPreviousSendDownlinkMsgsTask() {
- if (sessionState.getSendDownlinkMsgsFuture() != null && !sessionState.getSendDownlinkMsgsFuture().isDone()
- || sessionState.getScheduledSendDownlinkTask() != null && !sessionState.getScheduledSendDownlinkTask().isCancelled()) {
- log.debug("[{}][{}][{}] Previous send downlink future was not properly completed, stopping it now!", tenantId, edge.getId(), sessionId);
- stopCurrentSendDownlinkMsgsTask(true);
- } else {
- log.trace("[{}][{}][{}] Previous send downlink future is not active", tenantId, edge.getId(), sessionId);
- }
- }
-
- private void onUplinkMsg(UplinkMsg uplinkMsg) {
- if (isRateLimitViolated(uplinkMsg)) {
- return;
- }
- ListenableFuture> future = processUplinkMsg(uplinkMsg);
- Futures.addCallback(future, new FutureCallback<>() {
- @Override
- public void onSuccess(@Nullable List 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, tenantId) ||
- !ctx.getRateLimitService().checkRateLimit(LimitedApi.EDGE_UPLINK_MESSAGES_PER_EDGE, tenantId, edge.getId())) {
- String errorMsg = String.format("Failed to process uplink message. %s", RATE_LIMIT_REACHED);
- sendResponseMessage(uplinkMsg.getUplinkMsgId(), false, errorMsg);
- return true;
- }
- return false;
- }
-
- private void scheduleDownlinkMsgsPackSend(int attempt) {
- Runnable sendDownlinkMsgsTask = () -> {
- try {
- if (!isConnected()) {
- stopCurrentSendDownlinkMsgsTask(true);
- return;
- }
- if (!sessionState.getPendingMsgsMap().values().isEmpty()) {
- List copy = new ArrayList<>(sessionState.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(tenantId)
- .edgeId(edge.getId()).customerId(edge.getCustomerId()).edgeName(edge.getName()).failureMsg(failureMsg).error(error).build());
- }
- ctx.getStatsCounterService().ifPresent(statsCounterService ->
- statsCounterService.recordEvent(EdgeStatsKey.DOWNLINK_MSGS_TMP_FAILED, edge.getTenantId(), edge.getId(), 1));
- log.warn("[{}][{}] {} on attempt {}", tenantId, edge.getId(), failureMsg, attempt);
- log.debug("[{}][{}] entities in failed batch: {}", tenantId, edge.getId(), copy);
- }
- log.trace("[{}][{}][{}] downlink msg(s) are going to be send.", tenantId, edge.getId(), 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 {}", tenantId, edge.getId(), sessionId, message, downlinkMsg);
- ctx.getRuleProcessor().process(EdgeCommunicationFailureTrigger.builder().tenantId(tenantId)
- .edgeId(edge.getId()).customerId(edge.getCustomerId()).edgeName(edge.getName()).failureMsg(message).error(error).build());
- ctx.getStatsCounterService().ifPresent(statsCounterService ->
- statsCounterService.recordEvent(EdgeStatsKey.DOWNLINK_MSGS_PERMANENTLY_FAILED, edge.getTenantId(), edge.getId(), 1));
- sessionState.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 {}",
- tenantId, edge.getId(), MAX_DOWNLINK_ATTEMPTS, copy);
- ctx.getRuleProcessor().process(EdgeCommunicationFailureTrigger.builder().tenantId(tenantId).edgeId(edge.getId())
- .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(), edge.getId(), copy.size()));
- stopCurrentSendDownlinkMsgsTask(false);
- }
- } else {
- stopCurrentSendDownlinkMsgsTask(false);
- }
- } catch (Exception e) {
- log.warn("[{}][{}] Failed to send downlink msgs. Error msg {}", tenantId, edge.getId(), e.getMessage(), e);
- stopCurrentSendDownlinkMsgsTask(true);
- }
- };
-
- if (attempt == 1) {
- sendDownlinkExecutorService.submit(sendDownlinkMsgsTask);
- } else {
- sessionState.setScheduledSendDownlinkTask(
- sendDownlinkExecutorService.schedule(
- sendDownlinkMsgsTask,
- ctx.getEdgeEventStorageSettings().getSleepIntervalBetweenBatches(),
- TimeUnit.MILLISECONDS)
- );
- }
- }
-
- 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()) {
- sessionState.getPendingMsgsMap().remove(msg.getDownlinkMsgId());
- ctx.getStatsCounterService().ifPresent(statsCounterService ->
- statsCounterService.recordEvent(EdgeStatsKey.DOWNLINK_MSGS_PUSHED, edge.getTenantId(), edge.getId(), 1));
- log.debug("[{}][{}][{}] Msg has been processed successfully! Msg Id: [{}], Msg: {}", tenantId, edge.getId(), sessionId, msg.getDownlinkMsgId(), msg);
- } else {
- log.debug("[{}][{}][{}] Msg processing failed! Msg Id: [{}], Error msg: {}", tenantId, edge.getId(), sessionId, msg.getDownlinkMsgId(), msg.getErrorMsg());
- DownlinkMsg downlinkMsg = sessionState.getPendingMsgsMap().get(msg.getDownlinkMsgId());
- // if NOT timeseries or attributes failures - ack failed downlink
- if (downlinkMsg.getEntityDataCount() == 0) {
- sessionState.getPendingMsgsMap().remove(msg.getDownlinkMsgId());
- }
- }
- if (sessionState.getPendingMsgsMap().isEmpty()) {
- log.debug("[{}][{}][{}] Pending msgs map is empty. Stopping current iteration", tenantId, edge.getId(), sessionId);
- stopCurrentSendDownlinkMsgsTask(false);
- }
- } catch (Exception e) {
- log.error("[{}][{}] Can't process downlink response message [{}]", tenantId, edge.getId(), msg, e);
- }
- }
-
- public void processHighPriorityEvents() {
- try {
- if (isConnected() && !isSyncInProgress()) {
- if (highPriorityQueue.isEmpty()) {
- return;
- }
- List highPriorityEvents = new ArrayList<>();
- EdgeEvent event;
- while ((event = highPriorityQueue.poll()) != null) {
- highPriorityEvents.add(event);
- }
- log.trace("[{}][{}] Sending high priority events {}", tenantId, edge.getId(), highPriorityEvents.size());
- List downlinkMsgsPack = convertToDownlinkMsgsPack(highPriorityEvents);
- sendDownlinkMsgsPack(downlinkMsgsPack).get();
- }
- } catch (Exception e) {
- log.error("[{}] Failed to process high priority events", edge.getId(), e);
- }
- }
-
- public ListenableFuture processEdgeEvents() throws Exception {
- SettableFuture result = SettableFuture.create();
- if (isConnected() && !isSyncInProgress()) {
- Pair startTsAndSeqId = getQueueStartTsAndSeqId().get();
- previousStartTs = startTsAndSeqId.getFirst();
- previousStartSeqId = startTsAndSeqId.getSecond();
- GeneralEdgeEventFetcher fetcher = new GeneralEdgeEventFetcher(
- previousStartTs,
- previousStartSeqId,
- false,
- Integer.toUnsignedLong(ctx.getEdgeEventStorageSettings().getMaxReadRecordsCount()),
- ctx.getEdgeEventService(),
- ctx.getEdgeEventStorageSettings().getMisorderingCompensationMillis());
- log.trace("[{}][{}] starting processing edge events, previousStartTs = {}, previousStartSeqId = {}",
- tenantId, edge.getId(), previousStartTs, previousStartSeqId);
- Futures.addCallback(startProcessingEdgeEvents(fetcher), new FutureCallback<>() {
- @Override
- public void onSuccess(@Nullable Pair newStartTsAndSeqId) {
- if (newStartTsAndSeqId != null) {
- ListenableFuture updateFuture = updateQueueStartTsAndSeqId(newStartTsAndSeqId);
- Futures.addCallback(updateFuture, new FutureCallback<>() {
- @Override
- public void onSuccess(@Nullable AttributesSaveResult saveResult) {
- log.debug("[{}][{}] queue offset was updated [{}]", tenantId, edge.getId(), newStartTsAndSeqId);
- boolean newEventsAvailable;
- if (fetcher.isSeqIdNewCycleStarted()) {
- newEventsAvailable = isNewEdgeEventsAvailable();
- } else {
- newEventsAvailable = isSeqIdStartedNewCycle();
- if (!newEventsAvailable) {
- newEventsAvailable = isNewEdgeEventsAvailable();
- }
- }
- result.set(newEventsAvailable);
- }
-
- @Override
- public void onFailure(Throwable t) {
- log.error("[{}][{}] Failed to update queue offset [{}]", tenantId, edge.getId(), newStartTsAndSeqId, t);
- result.setException(t);
- }
- }, ctx.getGrpcCallbackExecutorService());
- } else {
- log.trace("[{}][{}] newStartTsAndSeqId is null. Skipping iteration without db update", tenantId, edge.getId());
- result.set(Boolean.FALSE);
- }
- }
-
- @Override
- public void onFailure(Throwable t) {
- log.error("[{}][{}] Failed to process events", tenantId, edge.getId(), t);
- result.setException(t);
- }
- }, ctx.getGrpcCallbackExecutorService());
- } else {
- if (isSyncInProgress()) {
- log.trace("[{}][{}] edge sync is not completed yet. Skipping iteration", tenantId, edge.getId());
- result.set(Boolean.TRUE);
- } else {
- log.trace("[{}][{}] edge is not connected. Skipping iteration", tenantId, edge.getId());
- result.set(null);
- }
- }
- return result;
- }
-
- protected List convertToDownlinkMsgsPack(List edgeEvents) {
- List result = new ArrayList<>();
- List filtered = EdgeMsgConstructorUtils.mergeAndFilterDownlinkDuplicates(edgeEvents);
- for (EdgeEvent edgeEvent : filtered) {
- log.trace("[{}][{}] converting edge event to downlink msg [{}]", tenantId, edge.getId(), 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(edgeEvent);
- if (downlinkMsg != null && downlinkMsg.getWidgetTypeUpdateMsgCount() > 0) {
- log.trace("[{}][{}] widgetTypeUpdateMsg message processed, downlinkMsgId = {}", tenantId, edge.getId(), downlinkMsg.getDownlinkMsgId());
- } else {
- log.trace("[{}][{}] entity message processed [{}]", tenantId, edge.getId(), downlinkMsg);
- }
- }
- case ATTRIBUTES_UPDATED, POST_ATTRIBUTES, ATTRIBUTES_DELETED, TIMESERIES_UPDATED ->
- downlinkMsg = ctx.getTelemetryProcessor().convertTelemetryEventToDownlink(edge, edgeEvent);
- default -> log.warn("[{}][{}] Unsupported action type [{}]", tenantId, edge.getId(), edgeEvent.getAction());
- }
- } catch (Exception e) {
- log.trace("[{}][{}] Exception during converting edge event to downlink msg", tenantId, edge.getId(), e);
- }
- if (downlinkMsg != null) {
- result.add(downlinkMsg);
- }
- }
- return result;
- }
-
- private ListenableFuture> getQueueStartTsAndSeqId() {
- ListenableFuture> future =
- ctx.getAttributesService().find(edge.getTenantId(), edge.getId(), 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();
- }
- return Pair.of(startTs, startSeqId);
- }, ctx.getGrpcCallbackExecutorService());
- }
-
- private boolean isSeqIdStartedNewCycle() {
- try {
- log.trace("[{}][{}][{}] Checking if seq id started new cycle", tenantId, edge.getId(), sessionId);
- TimePageLink pageLink = new TimePageLink(ctx.getEdgeEventStorageSettings().getMaxReadRecordsCount(), 0, null, null, newStartTs, System.currentTimeMillis());
- PageData edgeEvents = ctx.getEdgeEventService().findEdgeEvents(edge.getTenantId(), edge.getId(), 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, edge.getId(), sessionId, result);
- return result;
- } catch (Exception e) {
- log.error("[{}][{}][{}] Failed to execute isSeqIdStartedNewCycle", tenantId, edge.getId(), sessionId, e);
- }
- return false;
- }
-
- private boolean isNewEdgeEventsAvailable() {
- try {
- log.trace("[{}][{}][{}] Checking if new edge events available", tenantId, edge.getId(), sessionId);
- TimePageLink pageLink = new TimePageLink(ctx.getEdgeEventStorageSettings().getMaxReadRecordsCount(), 0, null, null, newStartTs, System.currentTimeMillis());
- PageData edgeEvents = ctx.getEdgeEventService().findEdgeEvents(edge.getTenantId(), edge.getId(), newStartSeqId, null, pageLink);
- boolean result = !edgeEvents.getData().isEmpty() || !highPriorityQueue.isEmpty();
- log.trace("[{}][{}][{}] Result of check if new edge events available, result = {}", tenantId, edge.getId(), sessionId, result);
- return result;
- } catch (Exception e) {
- log.error("[{}][{}][{}] Failed to execute isNewEdgeEventsAvailable", tenantId, edge.getId(), sessionId, e);
- }
- return false;
- }
-
- private long findStartSeqIdFromOldestEventIfAny() {
- long startSeqId = 0L;
- try {
- TimePageLink pageLink = new TimePageLink(1, 0, null, null, null, null);
- PageData edgeEvents = ctx.getEdgeEventService().findEdgeEvents(edge.getTenantId(), edge.getId(), null, null, pageLink);
- if (!edgeEvents.getData().isEmpty()) {
- startSeqId = edgeEvents.getData().get(0).getSeqId() - 1;
- }
- } catch (Exception e) {
- log.error("[{}][{}][{}] Failed to execute findStartSeqIdFromOldestEventIfAny", tenantId, edge.getId(), sessionId, e);
- }
- return startSeqId;
- }
-
- private ListenableFuture updateQueueStartTsAndSeqId(Pair pair) {
- newStartTs = pair.getFirst();
- newStartSeqId = pair.getSecond();
- log.trace("[{}] updateQueueStartTsAndSeqId [{}][{}][{}]", sessionId, edge.getId(), newStartTs, newStartSeqId);
- List 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(edge.getTenantId(), edge.getId(), AttributeScope.SERVER_SCOPE, attributes);
- }
-
- protected ListenableFuture> startProcessingEdgeEvents(EdgeEventFetcher fetcher) {
- SettableFuture> result = SettableFuture.create();
- PageLink pageLink = fetcher.getPageLink(ctx.getEdgeEventStorageSettings().getMaxReadRecordsCount());
- processEdgeEvents(fetcher, pageLink, result);
- return result;
- }
-
- private void markSyncCompletedSendEdgeEventUpdate() {
- updateSyncInProgress(false);
- ctx.getClusterService().onEdgeEventUpdate(new EdgeEventUpdateMsg(edge.getTenantId(), edge.getId()));
- }
-
- private void stopCurrentSendDownlinkMsgsTask(Boolean isInterrupted) {
- if (sessionState.getSendDownlinkMsgsFuture() != null && !sessionState.getSendDownlinkMsgsFuture().isDone()) {
- sessionState.getSendDownlinkMsgsFuture().set(isInterrupted);
- }
- if (sessionState.getScheduledSendDownlinkTask() != null) {
- sessionState.getScheduledSendDownlinkTask().cancel(true);
- }
- }
-
- private void sendDownlinkMsg(ResponseMsg responseMsg) {
- if (isConnected()) {
- String responseMsgStr = StringUtils.truncate(responseMsg.toString(), 10000);
- log.trace("[{}][{}] Sending downlink msg [{}]", tenantId, edge.getId(), 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 [{}]", tenantId, edge.getId(), downlinkMsgStr, e);
- connected = false;
- sessionCloseListener.accept(edge, sessionId);
- } finally {
- downlinkMsgLock.unlock();
- }
- log.trace("[{}][{}] downlink msg successfully sent [{}]", tenantId, edge.getId(), downlinkMsgStr);
- }
- }
-
- protected DownlinkMsg convertEntityEventToDownlink(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(edgeVersion, EdgeVersion.V_3_8_0))) {
- return null;
- }
-
- return ctx.getProcessor(edgeEvent.getType()).convertEdgeEventToDownlink(edgeEvent, edgeVersion);
- }
-
- public void addEventToHighPriorityQueue(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 {}",
- tenantId, edge.getId(), sessionId, oldestHighPriority);
- }
- }
- highPriorityQueue.add(edgeEvent);
- ctx.getStatsCounterService().ifPresent(statsCounterService ->
- statsCounterService.recordEvent(EdgeStatsKey.DOWNLINK_MSGS_ADDED, edge.getTenantId(), edgeEvent.getEdgeId(), 1));
- }
-
- protected ListenableFuture> processUplinkMsg(UplinkMsg uplinkMsg) {
- List> 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()) {
- sequenceDependencyLock.lock();
- try {
- result.add(ctx.getUserProcessor().processUserMsgFromEdge(edge.getTenantId(), edge, userUpdateMsg));
- } finally {
- sequenceDependencyLock.unlock();
- }
- }
- }
- if (uplinkMsg.getUserCredentialsUpdateMsgCount() > 0) {
- for (UserCredentialsUpdateMsg userCredentialsUpdateMsg : uplinkMsg.getUserCredentialsUpdateMsgList()) {
- sequenceDependencyLock.lock();
- try {
- result.add(ctx.getUserProcessor().processUserCredentialsMsgFromEdge(edge.getTenantId(), edge, userCredentialsUpdateMsg));
- } finally {
- sequenceDependencyLock.unlock();
- }
- }
- }
- if (uplinkMsg.getApiKeyUpdateMsgCount() > 0) {
- for (ApiKeyUpdateMsg apiKeyUpdateMsg : uplinkMsg.getApiKeyUpdateMsgList()) {
- sequenceDependencyLock.lock();
- try {
- result.add(ctx.getApiKeyProcessor().processApiKeyMsgFromEdge(edge.getTenantId(), edge, apiKeyUpdateMsg));
- } finally {
- sequenceDependencyLock.unlock();
- }
- }
- }
- } catch (Exception e) {
- String failureMsg = String.format("Can't process uplink msg [%s] from edge", uplinkMsg);
- log.trace("[{}][{}] Can't process uplink msg [{}]", tenantId, edge.getId(), uplinkMsg, e);
- ctx.getRuleProcessor().process(EdgeCommunicationFailureTrigger.builder().tenantId(tenantId).edgeId(edge.getId())
- .customerId(edge.getCustomerId()).edgeName(edge.getName()).failureMsg(failureMsg).error(e.getMessage()).build());
- return Futures.immediateFailedFuture(e);
- }
- return Futures.allAsList(result);
- }
-
- protected boolean destroy() {
- return true;
- }
-
- protected void cleanUp() {}
-
- @Override
- public void close() {
- log.debug("[{}][{}] Closing session", tenantId, sessionId);
- connected = false;
- try {
- outputStream.onCompleted();
- } catch (Exception e) {
- log.debug("[{}][{}] Failed to close output stream: {}", tenantId, sessionId, e.getMessage());
- }
- }
-
-}
diff --git a/application/src/main/java/org/thingsboard/server/service/edge/rpc/EdgeRpcService.java b/application/src/main/java/org/thingsboard/server/service/edge/rpc/EdgeRpcService.java
index 7e06c01bd3..af51247720 100644
--- a/application/src/main/java/org/thingsboard/server/service/edge/rpc/EdgeRpcService.java
+++ b/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.msg.edge.EdgeSessionMsg;
import org.thingsboard.server.common.msg.edge.FromEdgeSyncResponse;
-import org.thingsboard.server.common.msg.edge.ToEdgeSyncRequest;
import java.util.function.Consumer;
diff --git a/application/src/main/java/org/thingsboard/server/service/edge/rpc/EdgeSessionState.java b/application/src/main/java/org/thingsboard/server/service/edge/rpc/EdgeSessionState.java
index be79b51e19..3cb3920bfb 100644
--- a/application/src/main/java/org/thingsboard/server/service/edge/rpc/EdgeSessionState.java
+++ b/application/src/main/java/org/thingsboard/server/service/edge/rpc/EdgeSessionState.java
@@ -16,19 +16,70 @@
package org.thingsboard.server.service.edge.rpc;
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.EdgeVersion;
import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.Map;
+import java.util.UUID;
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 {
- private final Map pendingMsgsMap = Collections.synchronizedMap(new LinkedHashMap<>());
+ @Setter
private SettableFuture sendDownlinkMsgsFuture;
+ @Setter
private ScheduledFuture> scheduledSendDownlinkTask;
+ @Setter
+ private volatile boolean connected;
+ @Setter
+ private EdgeVersion edgeVersion;
+ private final UUID sessionId = UUID.randomUUID();
+ private final Map 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();
+ }
}
diff --git a/application/src/main/java/org/thingsboard/server/service/edge/rpc/EdgeUplinkMessageDispatcher.java b/application/src/main/java/org/thingsboard/server/service/edge/rpc/EdgeUplinkMessageDispatcher.java
new file mode 100644
index 0000000000..e5dd0738ac
--- /dev/null
+++ b/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> processUplinkMsg(EdgeSessionState state, UplinkMsg uplinkMsg) {
+ Edge edge = state.getEdge();
+ List> 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);
+ }
+}
diff --git a/application/src/main/java/org/thingsboard/server/service/edge/rpc/GrpcServer.java b/application/src/main/java/org/thingsboard/server/service/edge/rpc/GrpcServer.java
new file mode 100644
index 0000000000..0b19f10aa4
--- /dev/null
+++ b/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.
+ *
+ * Delegates PEM parsing and key management to {@link PemSslCredentials} — the same
+ * class used by MQTT, CoAP, and LwM2M transports — which supports:
+ *
+ * - Separate certificate and private key files (classic two-file setup)
+ * - Combined PEM: certificate chain + private key in a single {@code cert} file
+ * ({@code private_key} left empty)
+ * - Encrypted private keys (password supplied via {@code key_password})
+ *
+ * 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();
+ }
+ }
+}
diff --git a/application/src/main/java/org/thingsboard/server/service/edge/rpc/KafkaEdgeGrpcSession.java b/application/src/main/java/org/thingsboard/server/service/edge/rpc/KafkaEdgeGrpcSession.java
deleted file mode 100644
index 67c0a19e66..0000000000
--- a/application/src/main/java/org/thingsboard/server/service/edge/rpc/KafkaEdgeGrpcSession.java
+++ /dev/null
@@ -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> consumer;
-
- private ExecutorService consumerExecutor;
-
- public KafkaEdgeGrpcSession(EdgeContextComponent ctx, TopicService topicService, TbCoreQueueFactory tbCoreQueueFactory,
- KafkaAdmin kafkaAdmin, StreamObserver outputStream,
- BiConsumer sessionOpenListener, BiConsumer 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> msgs, TbQueueConsumer> 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 edgeEvents = new ArrayList<>();
- for (TbProtoQueueMsg msg : msgs) {
- EdgeEvent edgeEvent = ProtoUtils.fromProto(msg.getValue().getEdgeEventMsg());
- edgeEvents.add(edgeEvent);
- }
- List 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 migrateEdgeEvents() throws Exception {
- return super.processEdgeEvents();
- }
-
- @Override
- public ListenableFuture 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.>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);
- }
-
-}
diff --git a/application/src/main/java/org/thingsboard/server/service/edge/rpc/PostgresEdgeGrpcSession.java b/application/src/main/java/org/thingsboard/server/service/edge/rpc/PostgresEdgeGrpcSession.java
deleted file mode 100644
index bfeb3f09aa..0000000000
--- a/application/src/main/java/org/thingsboard/server/service/edge/rpc/PostgresEdgeGrpcSession.java
+++ /dev/null
@@ -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 outputStream,
- BiConsumer sessionOpenListener,
- BiConsumer sessionCloseListener, ScheduledExecutorService sendDownlinkExecutorService,
- int maxInboundMessageSize, int maxHighPriorityQueueSizePerSession) {
- super(ctx, outputStream, sessionOpenListener, sessionCloseListener, sendDownlinkExecutorService, maxInboundMessageSize, maxHighPriorityQueueSizePerSession);
- }
-
- @Override
- public ListenableFuture migrateEdgeEvents() {
- return Futures.immediateFuture(Boolean.FALSE);
- }
-
-}
diff --git a/application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/EdgeEventsDispatcher.java b/application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/EdgeEventsDispatcher.java
new file mode 100644
index 0000000000..f218148d30
--- /dev/null
+++ b/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 processNewEvents() throws Exception;
+}
diff --git a/application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/PostgresGeneralEdgeEventsDispatcher.java b/application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/PostgresGeneralEdgeEventsDispatcher.java
new file mode 100644
index 0000000000..d483706485
--- /dev/null
+++ b/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 processNewEvents() throws Exception {
+ EdgeSessionState state = session.getState();
+ TenantId tenantId = state.getTenantId();
+ EdgeId edgeId = state.getEdgeId();
+ UUID sessionId = state.getSessionId();
+
+ SettableFuture 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 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 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 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 updateQueueStartTsAndSeqId(TenantId tenantId, EdgeId edgeId, Pair pair) {
+ newStartTs = pair.getFirst();
+ newStartSeqId = pair.getSecond();
+ log.trace("[{}] updateQueueStartTsAndSeqId [{}][{}][{}]", session.getState().getSessionId(), edgeId, newStartTs, newStartSeqId);
+ List 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 startTsAndSeqId = getQueueStartTsAndSeqId(tenantId, edgeId, sessionId).get();
+ previousStartTs = startTsAndSeqId.getFirst();
+ previousStartSeqId = startTsAndSeqId.getSecond();
+ }
+
+ private ListenableFuture> getQueueStartTsAndSeqId(TenantId tenantId, EdgeId edgeId, UUID sessionId) {
+ ListenableFuture> 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 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;
+ }
+}
diff --git a/application/src/main/java/org/thingsboard/server/service/edge/rpc/service/EdgeGrpcService.java b/application/src/main/java/org/thingsboard/server/service/edge/rpc/service/EdgeGrpcService.java
new file mode 100644
index 0000000000..b50d643634
--- /dev/null
+++ b/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 edgeIdServiceIdCache;
+
+ private final ConcurrentMap> 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 handleMsgs(StreamObserver 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 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 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 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();
+ }
+ }
+}
diff --git a/application/src/main/java/org/thingsboard/server/service/edge/rpc/session/DefaultZombieSessionCleanupService.java b/application/src/main/java/org/thingsboard/server/service/edge/rpc/session/DefaultZombieSessionCleanupService.java
new file mode 100644
index 0000000000..780dcbe196
--- /dev/null
+++ b/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 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 getZombieSessions(Collection sessions) {
+ List 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 sessionsToRemove, Function 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);
+ }
+ }
+ }
+}
diff --git a/application/src/main/java/org/thingsboard/server/service/edge/rpc/AttributeSaveCallback.java b/application/src/main/java/org/thingsboard/server/service/edge/rpc/session/EdgeAttributeSaveCallback.java
similarity index 79%
rename from application/src/main/java/org/thingsboard/server/service/edge/rpc/AttributeSaveCallback.java
rename to application/src/main/java/org/thingsboard/server/service/edge/rpc/session/EdgeAttributeSaveCallback.java
index 7d0f14c24e..a5e063de2c 100644
--- a/application/src/main/java/org/thingsboard/server/service/edge/rpc/AttributeSaveCallback.java
+++ b/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
* 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 jakarta.annotation.Nullable;
-import lombok.AllArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.thingsboard.server.common.data.id.EdgeId;
import org.thingsboard.server.common.data.id.TenantId;
@Slf4j
-@AllArgsConstructor
-public class AttributeSaveCallback implements FutureCallback {
+public class EdgeAttributeSaveCallback implements FutureCallback {
private final TenantId tenantId;
private final EdgeId edgeId;
private final String key;
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
public void onSuccess(@Nullable Void result) {
log.trace("[{}][{}] Successfully updated attribute [{}] with value [{}]", tenantId, edgeId, key, value);
@@ -40,4 +45,5 @@ public class AttributeSaveCallback implements FutureCallback {
public void onFailure(Throwable t) {
log.warn("[{}][{}] Failed to update attribute [{}] with value [{}]", tenantId, edgeId, key, value, t);
}
-}
+
+}
\ No newline at end of file
diff --git a/application/src/main/java/org/thingsboard/server/service/edge/rpc/session/EdgeGrpcSession.java b/application/src/main/java/org/thingsboard/server/service/edge/rpc/session/EdgeGrpcSession.java
new file mode 100644
index 0000000000..73a3b53e4c
--- /dev/null
+++ b/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 outputStream;
+ private final DownlinkMessageMapper downlinkMessageMapper;
+ private final EdgeUplinkMessageDispatcher uplinkMessageDispatcher;
+ private final BiConsumer sessionOpenListener;
+ private final BiConsumer 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 highPriorityQueue = new ConcurrentLinkedQueue<>();
+
+ private int clientMaxInboundMessageSize;
+
+ @Override
+ public EdgeSessionState getState() {
+ return state;
+ }
+
+ @Override
+ public StreamObserver 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 highPriorityEvents = new ArrayList<>();
+ EdgeEvent event;
+ while ((event = highPriorityQueue.poll()) != null) {
+ highPriorityEvents.add(event);
+ }
+ log.trace("[{}][{}] Sending high priority events {}", getTenantId(), getEdgeId(), highPriorityEvents.size());
+ List 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> fetchAndSendEdgeEvents(EdgeEventFetcher fetcher) {
+ SettableFuture> result = SettableFuture.create();
+ PageLink pageLink = fetcher.getPageLink(ctx.getEdgeEventStorageSettings().getMaxReadRecordsCount());
+ fetchAndSendEdgeEvents(fetcher, pageLink, result);
+ return result;
+ }
+
+ @Override
+ public ListenableFuture sendDownlinkMsgsPack(List 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 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> future = fetchAndSendEdgeEvents(next);
+ Futures.addCallback(future, new FutureCallback<>() {
+ @Override
+ public void onSuccess(@Nullable Pair 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> 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 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 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 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 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();
+ }
+}
diff --git a/application/src/main/java/org/thingsboard/server/service/edge/rpc/session/EdgeGrpcSessionDelegate.java b/application/src/main/java/org/thingsboard/server/service/edge/rpc/session/EdgeGrpcSessionDelegate.java
new file mode 100644
index 0000000000..9c8c5eddb9
--- /dev/null
+++ b/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);
+ }
+}
diff --git a/application/src/main/java/org/thingsboard/server/service/edge/rpc/session/EdgeSession.java b/application/src/main/java/org/thingsboard/server/service/edge/rpc/session/EdgeSession.java
new file mode 100644
index 0000000000..0b7158211d
--- /dev/null
+++ b/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 initInputStream();
+ EdgeSessionState getState();
+ void startSyncProcess(boolean fullSync);
+ void sendDownlinkMsg(ResponseMsg responseMsg);
+ void addHighPriorityEvent(EdgeEvent edgeEvent);
+ void processHighPriorityEvents();
+ boolean hasHighPriorityEvents();
+ ListenableFuture> fetchAndSendEdgeEvents(EdgeEventFetcher fetcher);
+ ListenableFuture sendDownlinkMsgsPack(List downlinkMsgsPack);
+
+}
diff --git a/application/src/main/java/org/thingsboard/server/service/edge/rpc/session/EdgeSessionsHolder.java b/application/src/main/java/org/thingsboard/server/service/edge/rpc/session/EdgeSessionsHolder.java
new file mode 100644
index 0000000000..2d7c3dd8e3
--- /dev/null
+++ b/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 sessions = new ConcurrentHashMap<>();
+ private final ConcurrentMap sessionsById = new ConcurrentHashMap<>();
+
+ public void forEach(Consumer consumer) {
+ Set 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());
+ }
+}
diff --git a/application/src/main/java/org/thingsboard/server/service/edge/rpc/session/ZombieSessionCleanupService.java b/application/src/main/java/org/thingsboard/server/service/edge/rpc/session/ZombieSessionCleanupService.java
new file mode 100644
index 0000000000..4f65ae38a0
--- /dev/null
+++ b/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);
+}
diff --git a/application/src/main/java/org/thingsboard/server/service/edge/rpc/session/manager/AbstractEdgeGrpcSessionManager.java b/application/src/main/java/org/thingsboard/server/service/edge/rpc/session/manager/AbstractEdgeGrpcSessionManager.java
new file mode 100644
index 0000000000..0df1af1ada
--- /dev/null
+++ b/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 initInputStream(StreamObserver outputStream,
+ BiConsumer sessionOpenListener,
+ BiConsumer 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);
+ }
+ }
+ }
+}
diff --git a/application/src/main/java/org/thingsboard/server/service/edge/rpc/session/manager/EdgeGrpcSessionManager.java b/application/src/main/java/org/thingsboard/server/service/edge/rpc/session/manager/EdgeGrpcSessionManager.java
new file mode 100644
index 0000000000..9bf51107e4
--- /dev/null
+++ b/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 initInputStream(StreamObserver outputStream,
+ BiConsumer sessionOpenListener,
+ BiConsumer 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();
+
+}
diff --git a/application/src/main/java/org/thingsboard/server/service/edge/rpc/session/manager/KafkaBasedEdgeGrpcSessionManager.java b/application/src/main/java/org/thingsboard/server/service/edge/rpc/session/manager/KafkaBasedEdgeGrpcSessionManager.java
new file mode 100644
index 0000000000..f42f48421a
--- /dev/null
+++ b/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> initMigrationAndProcessingFutureRef = new AtomicReference<>();
+ private final AtomicReference> highPriorityProcessingFutureRef = new AtomicReference<>();
+ private final Lock initLock = new ReentrantLock();
+ private volatile boolean migrationProcessed;
+ private volatile boolean isHighPriorityProcessing;
+ @Getter
+ private QueueConsumerManager> 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.>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> msgs, TbQueueConsumer> 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 edgeEvents = new ArrayList<>();
+ for (TbProtoQueueMsg msg : msgs) {
+ EdgeEvent edgeEvent = ProtoUtils.fromProto(msg.getValue().getEdgeEventMsg());
+ edgeEvents.add(edgeEvent);
+ }
+ List 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();
+ }
+ }
+
+}
diff --git a/application/src/main/java/org/thingsboard/server/service/edge/rpc/session/manager/PostgresBasedEdgeGrpcSessionManager.java b/application/src/main/java/org/thingsboard/server/service/edge/rpc/session/manager/PostgresBasedEdgeGrpcSessionManager.java
new file mode 100644
index 0000000000..0190cd2784
--- /dev/null
+++ b/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> 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 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();
+ }
+ }
+
+}
diff --git a/application/src/main/java/org/thingsboard/server/service/install/DefaultDatabaseSchemaSettingsService.java b/application/src/main/java/org/thingsboard/server/service/install/DefaultDatabaseSchemaSettingsService.java
index ce86cbef07..c03dc22b2d 100644
--- a/application/src/main/java/org/thingsboard/server/service/install/DefaultDatabaseSchemaSettingsService.java
+++ b/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
// key - supported version prefix, value - display name
private static final Map SUPPORTED_VERSIONS_FOR_UPGRADE = Map.of(
- "4.2.1", "4.2.1.x",
- "4.2.2", "4.2.2.x"
+ "4.3.0", "4.3.0.x",
+ "4.3.1", "4.3.1.x"
);
private final ProjectInfo projectInfo;
diff --git a/application/src/main/java/org/thingsboard/server/service/install/DefaultSystemDataLoaderService.java b/application/src/main/java/org/thingsboard/server/service/install/DefaultSystemDataLoaderService.java
index 965d7105a9..d528f10899 100644
--- a/application/src/main/java/org/thingsboard/server/service/install/DefaultSystemDataLoaderService.java
+++ b/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.SimpleAlarmCondition;
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.predicate.BooleanFilterPredicate;
import org.thingsboard.server.common.data.alarm.rule.condition.expression.predicate.NumericFilterPredicate;
diff --git a/application/src/main/java/org/thingsboard/server/service/install/update/DefaultDataUpdateService.java b/application/src/main/java/org/thingsboard/server/service/install/update/DefaultDataUpdateService.java
index 464714fde7..086d76a25c 100644
--- a/application/src/main/java/org/thingsboard/server/service/install/update/DefaultDataUpdateService.java
+++ b/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.DbUpgradeExecutorService;
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 java.util.ArrayList;
@@ -51,11 +52,18 @@ public class DefaultDataUpdateService implements DataUpdateService {
private final DbUpgradeExecutorService executorService;
private final DatabaseSchemaSettingsService schemaSettingsService;
private final LtsMigrationService ltsMigrationService;
+ private final V4_3_1_3Migration v4313Migration;
@Override
public void updateData() throws Exception {
log.info("Updating data ...");
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.");
}
diff --git a/application/src/main/java/org/thingsboard/server/service/iot_hub/DefaultIotHubService.java b/application/src/main/java/org/thingsboard/server/service/iot_hub/DefaultIotHubService.java
index 5da54eb8b5..eb082c92c8 100644
--- a/application/src/main/java/org/thingsboard/server/service/iot_hub/DefaultIotHubService.java
+++ b/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.profile.TbDeviceProfileService;
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.security.model.SecurityUser;
@@ -117,6 +118,7 @@ public class DefaultIotHubService implements IotHubService {
private final DeviceService deviceService;
private final TbDeviceService tbDeviceService;
private final SolutionService solutionService;
+ private final ProjectInfo projectInfo;
// 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.
@@ -203,10 +205,11 @@ public class DefaultIotHubService implements IotHubService {
}
try {
- iotHubRestClient.reportVersionInstalled(versionId);
+ InstallReport report = buildInstallReport(tenantId.getId(), user.getId().getId(),
+ projectInfo.getProjectVersion(), projectInfo.getProductType());
+ iotHubRestClient.reportVersionInstalled(versionId, report);
} catch (Exception e) {
- // Counter ping is best-effort — do not fail the install if it errors.
- log.warn("[{}] Failed to report install counter for version {}: {}", tenantId, versionId, e.getMessage());
+ log.warn("[{}] Failed to report install for version {}: {}", tenantId, versionId, e.getMessage());
}
log.info("[{}] Successfully installed IoT Hub item version: {} (type: {})", tenantId, itemName, itemType);
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
public InstallItemVersionResult registerDeviceInstall(SecurityUser user, String versionId, DeviceInstalledItemDescriptor descriptor) {
TenantId tenantId = user.getTenantId();
@@ -704,10 +714,11 @@ public class DefaultIotHubService implements IotHubService {
}
try {
- iotHubRestClient.reportVersionInstalled(versionId);
+ InstallReport report = buildInstallReport(tenantId.getId(), user.getId().getId(),
+ projectInfo.getProjectVersion(), projectInfo.getProductType());
+ iotHubRestClient.reportVersionInstalled(versionId, report);
} catch (Exception e) {
- // Counter ping is best-effort — do not fail the install if it errors.
- log.warn("[{}] Failed to report install counter for version {}: {}", tenantId, versionId, e.getMessage());
+ log.warn("[{}] Failed to report install for version {}: {}", tenantId, versionId, e.getMessage());
}
log.info("[{}] Registered device package install: {} (version {})", tenantId, itemName, version);
diff --git a/application/src/main/java/org/thingsboard/server/service/iot_hub/InstallReport.java b/application/src/main/java/org/thingsboard/server/service/iot_hub/InstallReport.java
new file mode 100644
index 0000000000..3b253e426c
--- /dev/null
+++ b/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) {
+}
diff --git a/application/src/main/java/org/thingsboard/server/service/iot_hub/IotHubRestClient.java b/application/src/main/java/org/thingsboard/server/service/iot_hub/IotHubRestClient.java
index c67fef55a8..ae3cf5b07e 100644
--- a/application/src/main/java/org/thingsboard/server/service/iot_hub/IotHubRestClient.java
+++ b/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";
log.debug("Reporting IoT Hub version installed: {}", url);
- restTemplate.postForObject(url, null, Void.class);
+ restTemplate.postForObject(url, report, Void.class);
}
}
diff --git a/application/src/main/java/org/thingsboard/server/service/query/DefaultEntityQueryService.java b/application/src/main/java/org/thingsboard/server/service/query/DefaultEntityQueryService.java
index 50f0f0e75a..92d76693f9 100644
--- a/application/src/main/java/org/thingsboard/server/service/query/DefaultEntityQueryService.java
+++ b/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) {
EntityDataPageLink edpl = new EntityDataPageLink(maxEntitiesPerAlarmSubscription, 0, null,
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) {
@@ -233,7 +233,7 @@ public class DefaultEntityQueryService implements EntityQueryService {
entitiesSortOrder = sortOrder;
}
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
diff --git a/application/src/main/java/org/thingsboard/server/service/queue/DefaultTbCalculatedFieldConsumerService.java b/application/src/main/java/org/thingsboard/server/service/queue/DefaultTbCalculatedFieldConsumerService.java
index df5683711a..8a3f05f0b1 100644
--- a/application/src/main/java/org/thingsboard/server/service/queue/DefaultTbCalculatedFieldConsumerService.java
+++ b/application/src/main/java/org/thingsboard/server/service/queue/DefaultTbCalculatedFieldConsumerService.java
@@ -145,15 +145,15 @@ public class DefaultTbCalculatedFieldConsumerService extends AbstractPartitionBa
private void processMsgs(List> msgs, TbQueueConsumer> consumer, Object consumerKey, QueueConfig config) throws Exception {
List> orderedMsgList = msgs.stream().map(msg -> new IdMsgPair<>(UUID.randomUUID(), msg)).toList();
ConcurrentMap> pendingMap = orderedMsgList.stream().collect(
- Collectors.toConcurrentMap(IdMsgPair::getUuid, IdMsgPair::getMsg));
+ Collectors.toConcurrentMap(IdMsgPair::uuid, IdMsgPair::msg));
CountDownLatch processingTimeoutLatch = new CountDownLatch(1);
TbPackProcessingContext> ctx = new TbPackProcessingContext<>(
processingTimeoutLatch, pendingMap, new ConcurrentHashMap<>());
PendingMsgHolder pendingMsgHolder = new PendingMsgHolder<>();
Future> packSubmitFuture = consumersExecutor.submit(() -> {
orderedMsgList.forEach((element) -> {
- UUID id = element.getUuid();
- TbProtoQueueMsg msg = element.getMsg();
+ UUID id = element.uuid();
+ TbProtoQueueMsg msg = element.msg();
log.trace("[{}] Creating main callback for message: {}", id, msg.getValue());
TbCallback callback = new TbPackCallback<>(id, ctx);
try {
diff --git a/application/src/main/java/org/thingsboard/server/service/queue/DefaultTbCoreConsumerService.java b/application/src/main/java/org/thingsboard/server/service/queue/DefaultTbCoreConsumerService.java
index ffb6af8319..2489c3a091 100644
--- a/application/src/main/java/org/thingsboard/server/service/queue/DefaultTbCoreConsumerService.java
+++ b/application/src/main/java/org/thingsboard/server/service/queue/DefaultTbCoreConsumerService.java
@@ -258,15 +258,15 @@ public class DefaultTbCoreConsumerService extends AbstractConsumerService> msgs, TbQueueConsumer> consumer, Object consumerKey, QueueConfig config) throws Exception {
List> orderedMsgList = msgs.stream().map(msg -> new IdMsgPair<>(UUID.randomUUID(), msg)).toList();
ConcurrentMap> pendingMap = orderedMsgList.stream().collect(
- Collectors.toConcurrentMap(IdMsgPair::getUuid, IdMsgPair::getMsg));
+ Collectors.toConcurrentMap(IdMsgPair::uuid, IdMsgPair::msg));
CountDownLatch processingTimeoutLatch = new CountDownLatch(1);
TbPackProcessingContext> ctx = new TbPackProcessingContext<>(
processingTimeoutLatch, pendingMap, new ConcurrentHashMap<>());
PendingMsgHolder pendingMsgHolder = new PendingMsgHolder<>();
Future> packSubmitFuture = consumersExecutor.submit(() -> {
orderedMsgList.forEach((element) -> {
- UUID id = element.getUuid();
- TbProtoQueueMsg msg = element.getMsg();
+ UUID id = element.uuid();
+ TbProtoQueueMsg msg = element.msg();
log.trace("[{}] Creating main callback for message: {}", id, msg.getValue());
TbCallback callback = new TbPackCallback<>(id, ctx);
try {
diff --git a/application/src/main/java/org/thingsboard/server/service/queue/DefaultTbEdgeConsumerService.java b/application/src/main/java/org/thingsboard/server/service/queue/DefaultTbEdgeConsumerService.java
index 21361a1678..8498c3e205 100644
--- a/application/src/main/java/org/thingsboard/server/service/queue/DefaultTbEdgeConsumerService.java
+++ b/application/src/main/java/org/thingsboard/server/service/queue/DefaultTbEdgeConsumerService.java
@@ -128,15 +128,15 @@ public class DefaultTbEdgeConsumerService extends AbstractConsumerService> msgs, TbQueueConsumer> consumer, Object consumerKey, QueueConfig edgeQueueConfig) throws InterruptedException {
List> orderedMsgList = msgs.stream().map(msg -> new IdMsgPair<>(UUID.randomUUID(), msg)).toList();
ConcurrentMap> pendingMap = orderedMsgList.stream().collect(
- Collectors.toConcurrentMap(IdMsgPair::getUuid, IdMsgPair::getMsg));
+ Collectors.toConcurrentMap(IdMsgPair::uuid, IdMsgPair::msg));
CountDownLatch processingTimeoutLatch = new CountDownLatch(1);
TbPackProcessingContext> ctx = new TbPackProcessingContext<>(
processingTimeoutLatch, pendingMap, new ConcurrentHashMap<>());
PendingMsgHolder pendingMsgHolder = new PendingMsgHolder<>();
Future> submitFuture = consumersExecutor.submit(() -> {
orderedMsgList.forEach((element) -> {
- UUID id = element.getUuid();
- TbProtoQueueMsg msg = element.getMsg();
+ UUID id = element.uuid();
+ TbProtoQueueMsg msg = element.msg();
TbCallback callback = new TbPackCallback<>(id, ctx);
try {
ToEdgeMsg toEdgeMsg = msg.getValue();
diff --git a/application/src/main/java/org/thingsboard/server/service/queue/DefaultTbRuleEngineConsumerService.java b/application/src/main/java/org/thingsboard/server/service/queue/DefaultTbRuleEngineConsumerService.java
index bb70a5f136..444cb904f5 100644
--- a/application/src/main/java/org/thingsboard/server/service/queue/DefaultTbRuleEngineConsumerService.java
+++ b/application/src/main/java/org/thingsboard/server/service/queue/DefaultTbRuleEngineConsumerService.java
@@ -69,6 +69,7 @@ public class DefaultTbRuleEngineConsumerService extends AbstractPartitionBasedCo
private final TbRuleEngineConsumerContext ctx;
private final QueueService queueService;
private final TbRuleEngineDeviceRpcService tbDeviceRpcService;
+ private final TbMsgPackProcessingContextFactory packProcessingContextFactory;
private final ConcurrentMap consumers = new ConcurrentHashMap<>();
@@ -83,11 +84,13 @@ public class DefaultTbRuleEngineConsumerService extends AbstractPartitionBasedCo
TbApiUsageStateService apiUsageStateService,
PartitionService partitionService,
ApplicationEventPublisher eventPublisher,
- JwtSettingsService jwtSettingsService) {
+ JwtSettingsService jwtSettingsService,
+ TbMsgPackProcessingContextFactory packProcessingContextFactory) {
super(actorContext, tenantProfileCache, deviceProfileCache, assetProfileCache, tbResourceDataCache, apiUsageStateService, partitionService, eventPublisher, jwtSettingsService);
this.ctx = ctx;
this.tbDeviceRpcService = tbDeviceRpcService;
this.queueService = queueService;
+ this.packProcessingContextFactory = packProcessingContextFactory;
}
@Override
@@ -253,6 +256,7 @@ public class DefaultTbRuleEngineConsumerService extends AbstractPartitionBasedCo
.consumerExecutor(consumersExecutor)
.scheduler(scheduler)
.taskExecutor(mgmtExecutor)
+ .packProcessingContextFactory(packProcessingContextFactory)
.build();
consumers.put(queueKey, consumer);
consumer.init(queue);
diff --git a/application/src/main/java/org/thingsboard/server/service/queue/TbMsgPackProcessingContextFactory.java b/application/src/main/java/org/thingsboard/server/service/queue/TbMsgPackProcessingContextFactory.java
new file mode 100644
index 0000000000..08307a68ca
--- /dev/null
+++ b/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);
+ }
+
+ }
+
+}
diff --git a/application/src/main/java/org/thingsboard/server/service/queue/processing/AbstractConsumerService.java b/application/src/main/java/org/thingsboard/server/service/queue/processing/AbstractConsumerService.java
index 9f25ca209c..4243d71ce5 100644
--- a/application/src/main/java/org/thingsboard/server/service/queue/processing/AbstractConsumerService.java
+++ b/application/src/main/java/org/thingsboard/server/service/queue/processing/AbstractConsumerService.java
@@ -131,13 +131,13 @@ public abstract class AbstractConsumerService> msgs, TbQueueConsumer> consumer) throws Exception {
List> orderedMsgList = msgs.stream().map(msg -> new IdMsgPair<>(UUID.randomUUID(), msg)).toList();
ConcurrentMap> pendingMap = orderedMsgList.stream().collect(
- Collectors.toConcurrentMap(IdMsgPair::getUuid, IdMsgPair::getMsg));
+ Collectors.toConcurrentMap(IdMsgPair::uuid, IdMsgPair::msg));
CountDownLatch processingTimeoutLatch = new CountDownLatch(1);
TbPackProcessingContext> ctx = new TbPackProcessingContext<>(
processingTimeoutLatch, pendingMap, new ConcurrentHashMap<>());
orderedMsgList.forEach(element -> {
- UUID id = element.getUuid();
- TbProtoQueueMsg msg = element.getMsg();
+ UUID id = element.uuid();
+ TbProtoQueueMsg msg = element.msg();
log.trace("[{}] Creating notification callback for message: {}", id, msg.getValue());
TbCallback callback = new TbPackCallback<>(id, ctx);
try {
diff --git a/application/src/main/java/org/thingsboard/server/service/queue/processing/AbstractTbRuleEngineSubmitStrategy.java b/application/src/main/java/org/thingsboard/server/service/queue/processing/AbstractTbRuleEngineSubmitStrategy.java
index 9f3344ca3e..291d9fc9b5 100644
--- a/application/src/main/java/org/thingsboard/server/service/queue/processing/AbstractTbRuleEngineSubmitStrategy.java
+++ b/application/src/main/java/org/thingsboard/server/service/queue/processing/AbstractTbRuleEngineSubmitStrategy.java
@@ -44,21 +44,21 @@ public abstract class AbstractTbRuleEngineSubmitStrategy implements TbRuleEngine
@Override
public ConcurrentMap> 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
public void update(ConcurrentMap> reprocessMap) {
List> newOrderedMsgList = new ArrayList<>(reprocessMap.size());
for (IdMsgPair pair : orderedMsgList) {
- if (reprocessMap.containsKey(pair.uuid)) {
- if (StringUtils.isNotEmpty(pair.getMsg().getValue().getFailureMessage())) {
- var toRuleEngineMsg = TransportProtos.ToRuleEngineMsg.newBuilder(pair.getMsg().getValue())
+ if (reprocessMap.containsKey(pair.uuid())) {
+ if (StringUtils.isNotEmpty(pair.msg().getValue().getFailureMessage())) {
+ var toRuleEngineMsg = TransportProtos.ToRuleEngineMsg.newBuilder(pair.msg().getValue())
.clearFailureMessage()
.clearRelationTypes()
.build();
- var newMsg = new TbProtoQueueMsg<>(pair.getMsg().getKey(), toRuleEngineMsg, pair.getMsg().getHeaders());
- newOrderedMsgList.add(new IdMsgPair<>(pair.getUuid(), newMsg));
+ var newMsg = new TbProtoQueueMsg<>(pair.msg().getKey(), toRuleEngineMsg, pair.msg().getHeaders());
+ newOrderedMsgList.add(new IdMsgPair<>(pair.uuid(), newMsg));
} else {
newOrderedMsgList.add(pair);
}
diff --git a/application/src/main/java/org/thingsboard/server/service/queue/processing/BatchTbRuleEngineSubmitStrategy.java b/application/src/main/java/org/thingsboard/server/service/queue/processing/BatchTbRuleEngineSubmitStrategy.java
index 32fcd65ca4..605c5fe977 100644
--- a/application/src/main/java/org/thingsboard/server/service/queue/processing/BatchTbRuleEngineSubmitStrategy.java
+++ b/application/src/main/java/org/thingsboard/server/service/queue/processing/BatchTbRuleEngineSubmitStrategy.java
@@ -73,7 +73,7 @@ public class BatchTbRuleEngineSubmitStrategy extends AbstractTbRuleEngineSubmitS
pendingPack.clear();
for (int i = startIdx; i < endIdx; i++) {
IdMsgPair pair = orderedMsgList.get(i);
- pendingPack.put(pair.uuid, pair.msg);
+ pendingPack.put(pair.uuid(), pair.msg());
}
tmpPack = new LinkedHashMap<>(pendingPack);
}
diff --git a/application/src/main/java/org/thingsboard/server/service/queue/processing/BurstTbRuleEngineSubmitStrategy.java b/application/src/main/java/org/thingsboard/server/service/queue/processing/BurstTbRuleEngineSubmitStrategy.java
index 9f48c7bb70..5da8b85dc4 100644
--- a/application/src/main/java/org/thingsboard/server/service/queue/processing/BurstTbRuleEngineSubmitStrategy.java
+++ b/application/src/main/java/org/thingsboard/server/service/queue/processing/BurstTbRuleEngineSubmitStrategy.java
@@ -34,7 +34,7 @@ public class BurstTbRuleEngineSubmitStrategy extends AbstractTbRuleEngineSubmitS
if (log.isDebugEnabled()) {
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
diff --git a/application/src/main/java/org/thingsboard/server/service/queue/processing/IdMsgPair.java b/application/src/main/java/org/thingsboard/server/service/queue/processing/IdMsgPair.java
index 81d2854dac..a0e440f7cd 100644
--- a/application/src/main/java/org/thingsboard/server/service/queue/processing/IdMsgPair.java
+++ b/application/src/main/java/org/thingsboard/server/service/queue/processing/IdMsgPair.java
@@ -15,19 +15,9 @@
*/
package org.thingsboard.server.service.queue.processing;
-import lombok.Getter;
+import com.google.protobuf.GeneratedMessageV3;
import org.thingsboard.server.queue.common.TbProtoQueueMsg;
import java.util.UUID;
-public class IdMsgPair {
- @Getter
- final UUID uuid;
- @Getter
- final TbProtoQueueMsg msg;
-
- public IdMsgPair(UUID uuid, TbProtoQueueMsg msg) {
- this.uuid = uuid;
- this.msg = msg;
- }
-}
+public record IdMsgPair(UUID uuid, TbProtoQueueMsg msg) {}
diff --git a/application/src/main/java/org/thingsboard/server/service/queue/processing/SequentialByEntityIdTbRuleEngineSubmitStrategy.java b/application/src/main/java/org/thingsboard/server/service/queue/processing/SequentialByEntityIdTbRuleEngineSubmitStrategy.java
index cfc3bb4a38..5acb629824 100644
--- a/application/src/main/java/org/thingsboard/server/service/queue/processing/SequentialByEntityIdTbRuleEngineSubmitStrategy.java
+++ b/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) -> {
IdMsgPair msg = queue.peek();
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 next = null;
synchronized (queue) {
IdMsgPair expected = queue.peek();
- if (expected != null && expected.uuid.equals(id)) {
+ if (expected != null && expected.uuid().equals(id)) {
queue.poll();
next = queue.peek();
}
}
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();
entityIdToListMap.clear();
for (IdMsgPair pair : orderedMsgList) {
- EntityId entityId = getEntityId(pair.msg.getValue());
+ EntityId entityId = getEntityId(pair.msg().getValue());
if (entityId != null) {
- msgToEntityIdMap.put(pair.uuid, entityId);
+ msgToEntityIdMap.put(pair.uuid(), entityId);
entityIdToListMap.computeIfAbsent(entityId, id -> new LinkedList<>()).add(pair);
}
}
diff --git a/application/src/main/java/org/thingsboard/server/service/queue/processing/SequentialTbRuleEngineSubmitStrategy.java b/application/src/main/java/org/thingsboard/server/service/queue/processing/SequentialTbRuleEngineSubmitStrategy.java
index 64be9bd378..be32f155dd 100644
--- a/application/src/main/java/org/thingsboard/server/service/queue/processing/SequentialTbRuleEngineSubmitStrategy.java
+++ b/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();
if (idx < listSize) {
IdMsgPair pair = orderedMsgList.get(idx);
- expectedMsgId = pair.uuid;
+ expectedMsgId = pair.uuid();
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());
}
}
diff --git a/application/src/main/java/org/thingsboard/server/service/queue/ruleengine/TbRuleEngineQueueConsumerManager.java b/application/src/main/java/org/thingsboard/server/service/queue/ruleengine/TbRuleEngineQueueConsumerManager.java
index 8ba98b817a..3733b49f1f 100644
--- a/application/src/main/java/org/thingsboard/server/service/queue/ruleengine/TbRuleEngineQueueConsumerManager.java
+++ b/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.service.queue.TbMsgPackCallback;
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.processing.TbRuleEngineProcessingDecision;
import org.thingsboard.server.service.queue.processing.TbRuleEngineProcessingResult;
@@ -67,13 +68,15 @@ public class TbRuleEngineQueueConsumerManager extends MainQueueConsumerManager {
Integer partitionId = tpi != null ? tpi.getPartition().orElse(-1) : null;
@@ -82,6 +85,7 @@ public class TbRuleEngineQueueConsumerManager extends MainQueueConsumerManager submitMessage(packCtx, id, msg));
final boolean timeout = !packCtx.await(queue.getPackProcessingTimeout(), TimeUnit.MILLISECONDS);
diff --git a/application/src/main/java/org/thingsboard/server/service/security/auth/pat/ApiKeyAuthenticationProvider.java b/application/src/main/java/org/thingsboard/server/service/security/auth/pat/ApiKeyAuthenticationProvider.java
index 162a780bbb..46af0120da 100644
--- a/application/src/main/java/org/thingsboard/server/service/security/auth/pat/ApiKeyAuthenticationProvider.java
+++ b/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);
}
- private SecurityUser authenticate(String key) {
+ public SecurityUser authenticate(String key) {
if (StringUtils.isEmpty(key)) {
throw new BadCredentialsException("Empty API key");
}
diff --git a/application/src/main/java/org/thingsboard/server/service/subscription/TbAlarmCountSubCtx.java b/application/src/main/java/org/thingsboard/server/service/subscription/TbAlarmCountSubCtx.java
index c2fe743bb4..5b4287acd1 100644
--- a/application/src/main/java/org/thingsboard/server/service/subscription/TbAlarmCountSubCtx.java
+++ b/application/src/main/java/org/thingsboard/server/service/subscription/TbAlarmCountSubCtx.java
@@ -130,7 +130,7 @@ public class TbAlarmCountSubCtx extends TbAbstractEntityQuerySubCtx {
entitiesSortOrder = sortOrder;
}
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());
}
}
diff --git a/application/src/main/java/org/thingsboard/server/service/transport/DefaultTransportApiService.java b/application/src/main/java/org/thingsboard/server/service/transport/DefaultTransportApiService.java
index 48bd497c6e..5c21096862 100644
--- a/application/src/main/java/org/thingsboard/server/service/transport/DefaultTransportApiService.java
+++ b/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.MoreExecutors;
import com.google.protobuf.ByteString;
+import org.thingsboard.server.common.data.relation.RelationTypeGroup;
import org.thingsboard.server.exception.EntitiesLimitExceededException;
import jakarta.annotation.PostConstruct;
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.VALID;
+import static org.thingsboard.server.transport.mqtt.util.sparkplug.SparkplugTopicService.DEVICE_NAME_SPLIT_SEPARATOR;
/**
* Created by ashvayka on 05.10.18.
@@ -139,6 +141,7 @@ public class DefaultTransportApiService implements TransportApiService {
private final OtaPackageService otaPackageService;
private final OtaPackageDataCache otaPackageDataCache;
private final QueueService queueService;
+ public static final String GATEWAY_CREATED_RELATION = "Created";
private final ConcurrentMap deviceCreationLocks = new ConcurrentReferenceHashMap<>(16, ConcurrentReferenceHashMap.ReferenceType.WEAK);
@@ -330,84 +333,188 @@ public class DefaultTransportApiService implements TransportApiService {
}
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);
- Lock deviceCreationLock = deviceCreationLocks.computeIfAbsent(requestMsg.getDeviceName(), id -> new ReentrantLock());
- deviceCreationLock.lock();
+ String deviceName = requestMsg.getDeviceName();
+ Lock lock = deviceCreationLocks.computeIfAbsent(deviceName, k -> new ReentrantLock());
+ lock.lock();
try {
- Device device = deviceService.findDeviceByTenantIdAndName(gateway.getTenantId(), requestMsg.getDeviceName());
- if (device == null) {
- TenantId tenantId = gateway.getTenantId();
- 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();
+ Device device = findOrCreateDevice(requestMsg, gateway, gatewayId);
+ updateLastConnectedGateway(device, gatewayId);
+ return buildResponse(device);
} 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);
} catch (EntitiesLimitExceededException e) {
- log.warn("[{}][{}] API limit exception: [{}]", e.getTenantId(), gatewayId, e.getMessage());
- return TransportApiResponseMsg.newBuilder()
- .setGetOrCreateDeviceResponseMsg(
- GetOrCreateDeviceFromGatewayResponseMsg.newBuilder()
- .setError(TransportProtos.TransportApiRequestErrorCode.ENTITY_LIMIT))
- .build();
+ return buildLimitErrorResponse(e, gatewayId);
} 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) {
diff --git a/application/src/main/java/org/thingsboard/server/service/ws/AuthCmd.java b/application/src/main/java/org/thingsboard/server/service/ws/AuthCmd.java
index e139498d04..a2dc87e4af 100644
--- a/application/src/main/java/org/thingsboard/server/service/ws/AuthCmd.java
+++ b/application/src/main/java/org/thingsboard/server/service/ws/AuthCmd.java
@@ -23,11 +23,14 @@ import lombok.NoArgsConstructor;
@NoArgsConstructor
@AllArgsConstructor
public class AuthCmd implements WsCmd {
+
private int cmdId;
private String token;
+ private String apiKey;
@Override
public WsCmdType getType() {
return WsCmdType.AUTH;
}
+
}
diff --git a/application/src/main/resources/thingsboard.yml b/application/src/main/resources/thingsboard.yml
index 64d5a66a58..bde1873627 100644
--- a/application/src/main/resources/thingsboard.yml
+++ b/application/src/main/resources/thingsboard.yml
@@ -15,6 +15,7 @@
#
# Server common parameters
+# Configures HTTP/HTTPS bind address, port, SSL, WebSocket, and REST API settings.
server:
# Server bind-address
address: "${HTTP_BIND_ADDRESS:0.0.0.0}"
@@ -117,23 +118,25 @@ server:
response_timeout: "${DEFAULT_RULE_ENGINE_RESPONSE_TIMEOUT:10000}"
# Application info parameters
+# Exposes application metadata such as version string injected at build time.
app:
# Application version
version: "@project.version@"
-# Zookeeper connection parameters
+# ZooKeeper connection parameters
+# Controls ZooKeeper-based service discovery and cluster coordination for microservice deployments.
zk:
- # Enable/disable zookeeper discovery service.
+ # Enable/disable ZooKeeper discovery service.
enabled: "${ZOOKEEPER_ENABLED:false}"
- # Zookeeper connect string
+ # ZooKeeper connect string
url: "${ZOOKEEPER_URL:localhost:2181}"
- # Zookeeper retry interval in milliseconds
+ # ZooKeeper retry interval in milliseconds
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}"
- # Zookeeper session timeout in milliseconds
+ # ZooKeeper session timeout in milliseconds
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}"
# 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.
@@ -141,6 +144,7 @@ zk:
recalculate_delay: "${ZOOKEEPER_RECALCULATE_DELAY_MS:0}"
# Cluster parameters
+# Controls cluster statistics — tracks the number of messages exchanged between cluster nodes.
cluster:
stats:
# 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}"
# Plugins configuration parameters
+# Defines classpath scan packages used to discover and register ThingsBoard extension plugins.
plugins:
# Comma-separated package list used during classpath scanning for plugins
scan_packages: "${PLUGINS_SCAN_PACKAGES:org.thingsboard.server.extensions,org.thingsboard.rule.engine}"
# Security parameters
+# Configures JWT tokens, user login, device claiming, OAuth2, and CA certificate trust store.
security:
# 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
@@ -246,6 +252,7 @@ security:
report-only: "${SECURITY_HEADERS_CONTENT_SECURITY_POLICY_REPORT_ONLY:false}"
# Mail settings parameters
+# Configures mail service OAuth2 token refresh and per-tenant sending rate limits.
mail:
oauth2:
# 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:}"
# Usage statistics parameters
+# Controls collection and reporting intervals for API usage stats at system, tenant, and customer levels.
usage:
stats:
report:
@@ -277,6 +285,7 @@ usage:
report_interval: "${DEVICES_STATS_REPORT_INTERVAL:60}"
# UI settings parameters
+# Configures dashboard data limits and base URL for UI help assets.
ui:
# Dashboard parameters
dashboard:
@@ -285,9 +294,10 @@ ui:
# Help parameters
help:
# Base URL for UI help assets
- base-url: "${UI_HELP_BASE_URL:https://raw.githubusercontent.com/thingsboard/thingsboard-ui-help/release-4.3}"
+ base-url: "${UI_HELP_BASE_URL:https://raw.githubusercontent.com/thingsboard/thingsboard-ui-help/release-4.4}"
# Database telemetry parameters
+# Selects the storage backend (SQL, Cassandra, or TimescaleDB) for time-series and latest telemetry data.
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:
@@ -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)
# Cassandra driver configuration parameters
+# Configures cluster name, keyspace, SSL, credentials, socket, and query settings for the Cassandra driver.
cassandra:
# Thingsboard cluster name
cluster_name: "${CASSANDRA_CLUSTER_NAME:Thingsboard Cluster}"
@@ -414,6 +425,7 @@ cassandra:
print_tenant_names: "${CASSANDRA_QUERY_TENANT_RATE_LIMITS_PRINT_TENANT_NAMES:false}"
# SQL configuration parameters
+# Tunes batch sizes, delays, thread counts, TTL, and partitioning for SQL-backed persistence of telemetry, events, and audit logs.
sql:
# Specify batch size for persisting attribute updates
attributes:
@@ -466,6 +478,10 @@ sql:
# Interval in milliseconds for printing the latest statistic information about the tenant
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.
+ 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:
# Specify partitioning size for timestamp key-value storage. Example: DAYS, MONTHS, YEARS, INDEFINITE.
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
# Actor system parameters
+# Configures thread pools, timeouts, and behavior for the internal actor system processing devices, rules, and RPCs.
actors:
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
@@ -634,6 +651,8 @@ actors:
# Time in seconds to receive calculation result.
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:
settings:
# 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}"
# Cache settings parameters
+# Configures cache type (Caffeine or Redis), pool size, and per-entity TTL and max-size limits for all caches.
cache:
# caffeine or redis(7.2 - latest compatible version)
type: "${CACHE_TYPE:caffeine}"
@@ -800,6 +820,7 @@ cache:
spring.data.redis.repositories.enabled: false # Disable this because it is not required.
# Redis/Valkey configuration parameters
+# Configures standalone, cluster, or sentinel Redis connection, SSL, and connection pool settings used for caching.
redis:
# standalone or cluster or sentinel
connection:
@@ -836,9 +857,9 @@ redis:
password: "${REDIS_SENTINEL_PASSWORD:}"
# If set false will be used pool config build from values of the pool config section
useDefaultPoolConfig: "${REDIS_USE_DEFAULT_POOL_CONFIG:true}"
- # db index
+ # Redis logical database index to select after connecting.
db: "${REDIS_DB:0}"
- # db password
+ # Password for Redis authentication (leave empty if not required).
password: "${REDIS_PASSWORD:}"
# Redis username for ACL authentication (Redis 6.0+). Leave empty for legacy password-only auth
username: "${REDIS_USERNAME:}"
@@ -883,6 +904,7 @@ redis:
# Update version parameters
+# Controls whether the platform periodically checks for new ThingsBoard releases.
updates:
# Enable/disable checks for the new version
enabled: "${UPDATES_ENABLED:true}"
@@ -911,6 +933,7 @@ spring.mvc.cors:
allow-credentials: "${TB_CORS_ALLOW_CREDENTIALS:true}"
# 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.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
@@ -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)
# SQL DAO Configuration parameters
+# Configures Spring JPA, Hibernate DDL, and HikariCP datasource connections for the primary and events databases.
spring:
data:
jpa:
@@ -979,6 +1003,7 @@ spring:
registerMbeans: "${SPRING_EVENTS_DATASOURCE_HIKARI_REGISTER_MBEANS:false}"
# Audit log parameters
+# Configures audit logging levels per entity type and optional forwarding to an external sink such as Elasticsearch.
audit-log:
# Enable/disable audit log functionality.
enabled: "${AUDIT_LOG_ENABLED:true}"
@@ -1021,6 +1046,7 @@ audit-log:
password: "${AUDIT_LOG_SINK_PASSWORD:}" # Password used to access external sink system
# Device state parameters
+# Configures device inactivity detection, state persistence strategy, and rate limits for state rule nodes.
state:
# 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).
@@ -1051,7 +1077,8 @@ state:
# 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}"
-# Tbel parameters
+# TBEL parameters
+# Configures the ThingsBoard Expression Language (TBEL) engine: limits, timeouts, thread pool, and blacklisting.
tbel:
# Enable/Disable TBEL feature.
enabled: "${TBEL_ENABLED:true}"
@@ -1080,6 +1107,7 @@ tbel:
print_interval_ms: "${TB_TBEL_STATS_PRINT_INTERVAL_MS:10000}"
# JS parameters
+# Configures the JavaScript execution engine (local Nashorn or remote Node.js): limits, sandboxing, and thread pools.
js:
# local (Nashorn Engine, deprecated) OR remote JS-Executors (NodeJS)
evaluator: "${JS_EVALUATOR:local}"
@@ -1127,6 +1155,7 @@ js:
print_interval_ms: "${TB_JS_REMOTE_STATS_PRINT_INTERVAL_MS:10000}"
# Transport configuration parameters
+# Configures session management, rate limits, and protocol-specific settings for HTTP, MQTT, CoAP, LwM2M, and SNMP transports.
transport:
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.
@@ -1448,6 +1477,7 @@ transport:
check_interval_seconds: "${TB_TRANSPORT_SSL_CERTIFICATE_RELOAD_CHECK_INTERVAL_SECONDS:60}"
# CoAP server parameters
+# Configures the standalone CoAP server bind address, port, DTLS encryption, and credential settings.
coap:
server:
# Enable/disable coap server.
@@ -1532,6 +1562,7 @@ coap:
dtls_session_report_timeout: "${TB_COAP_X509_DTLS_SESSION_REPORT_TIMEOUT:1800000}"
# Device connectivity parameters
+# Specifies the hosts, ports, and credentials exposed to the UI for generating device connection check commands.
device:
connectivity:
http:
@@ -1585,6 +1616,7 @@ device:
image_version: "${DEVICE_CONNECTIVITY_GATEWAY_IMAGE_VERSION:3.8-stable}"
# Edges parameters
+# Controls Edge instance gRPC communication, event storage, state persistence, and statistics reporting.
edges:
# Enable/disable Edge instance
enabled: "${EDGES_ENABLED:true}"
@@ -1653,6 +1685,7 @@ edges:
report-interval-millis: "${EDGES_STATS_REPORT_INTERVAL_MS:600000}"
# Spring doc common parameters
+# Enables or disables the OpenAPI/Swagger documentation endpoint and sets the default media type.
springdoc:
# If false swagger API docs will be unavailable
api-docs.enabled: "${SWAGGER_ENABLED:true}"
@@ -1660,6 +1693,7 @@ springdoc:
default-produces-media-type: "${SWAGGER_DEFAULT_PRODUCES_MEDIA_TYPE:application/json}"
# Swagger common parameters
+# Configures Swagger UI metadata: title, description, contact, license, version, and API path patterns.
swagger:
# General swagger match pattern of swagger UI links
api_path: "${SWAGGER_API_PATH:/api/**}"
@@ -1691,6 +1725,7 @@ swagger:
doc_expansion: "${SWAGGER_DOC_EXPANSION:list}"
# Queue configuration parameters
+# Configures the message queue backend (in-memory or Kafka) and all topic/partition/consumer settings for each service.
queue:
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).
@@ -1749,11 +1784,23 @@ queue:
# 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}"
# 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}"
# 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\";}"
# 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}"
+ # 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:///.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.
# Check TB_QUEUE_CORE_OTA_TOPIC and TB_QUEUE_RE_SQ_TOPIC params
consumer-properties-per-topic:
@@ -2030,6 +2077,9 @@ queue:
print-interval-ms: "${TB_QUEUE_RULE_ENGINE_STATS_PRINT_INTERVAL_MS:60000}"
# Max length of the error message that is printed by statistics
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
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
@@ -2101,12 +2151,14 @@ queue:
processing_interval: "${TB_QUEUE_TASKS_STATS_PROCESSING_INTERVAL_MS:1000}"
# Event configuration parameters
+# Controls limits on debug event content size stored for rule chain and rule node execution.
event:
debug:
# Maximum number of symbols per debug event. The event content will be truncated if needed
max-symbols: "${TB_MAX_DEBUG_EVENT_SYMBOLS:4096}"
# General service parameters
+# Sets the deployment type (monolith or microservice) and assigns tenant profiles to specific Rule Engine instances.
service:
type: "${TB_SERVICE_TYPE:monolith}" # monolith or tb-core or tb-rule-engine
# 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}"
# Metrics parameters
+# Enables actuator metrics endpoint and configures system info (CPU, memory) persistence frequency and TTL.
metrics:
# Enable/disable actuator metrics.
enabled: "${METRICS_ENABLED:false}"
@@ -2133,6 +2186,7 @@ metrics:
ttl: "${METRICS_SYSTEM_INFO_TTL_DAYS:7}"
# Version control parameters
+# Configures thread pools and Git repository folder for entity version control (export/import) operations.
vc:
# Pool size for handling export tasks
thread_pool_size: "${TB_VC_POOL_SIZE:6}"
@@ -2143,6 +2197,7 @@ vc:
repositories-folder: "${TB_VC_GIT_REPOSITORIES_FOLDER:${java.io.tmpdir}/repositories}"
# Notification system parameters
+# Configures thread pool size and deduplication intervals for the notification rule processing engine.
notification_system:
# 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}"
@@ -2151,6 +2206,7 @@ notification_system:
deduplication_durations: "${TB_NOTIFICATION_RULES_DEDUPLICATION_DURATIONS:NEW_PLATFORM_VERSION:0;RATE_LIMITS:14400000;}"
# General management parameters
+# Configures Spring Boot Actuator endpoint exposure and health indicator settings.
management:
endpoints:
web:
@@ -2163,6 +2219,7 @@ management:
enabled: "false"
# 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:
# Server domain name for Thingsboard Live mobile application
domain: "${TB_MOBILE_APP_DOMAIN:demo.thingsboard.io}"
@@ -2171,6 +2228,8 @@ mobileApp:
# Link to App Store for Thingsboard Live mobile application
appStoreLink: "${TB_MOBILE_APP_APP_STORE_LINK:https://apps.apple.com/us/app/thingsboard-live/id1594355695}"
+# MQTT client parameters
+# Configures MQTT client retransmission behavior including max attempts, initial delay, and jitter factor.
mqtt:
# MQTT client configuration parameters
client:
diff --git a/application/src/test/java/org/thingsboard/server/cf/AlarmRulesTest.java b/application/src/test/java/org/thingsboard/server/cf/AlarmRulesTest.java
index e38a23d905..9a001e0575 100644
--- a/application/src/test/java/org/thingsboard/server/cf/AlarmRulesTest.java
+++ b/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.expression.AlarmConditionExpression;
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.TbelAlarmConditionExpression;
import org.thingsboard.server.common.data.alarm.rule.condition.expression.predicate.NoDataFilterPredicate;
diff --git a/application/src/test/java/org/thingsboard/server/controller/AbstractControllerTest.java b/application/src/test/java/org/thingsboard/server/controller/AbstractControllerTest.java
index c25dafa5e6..996efb17be 100644
--- a/application/src/test/java/org/thingsboard/server/controller/AbstractControllerTest.java
+++ b/application/src/test/java/org/thingsboard/server/controller/AbstractControllerTest.java
@@ -86,12 +86,12 @@ public abstract class AbstractControllerTest extends AbstractNotifyEntityTest {
}
@Before
- public void beforeWsTest() throws Exception {
+ public void beforeWsTest() {
// placeholder
}
@After
- public void afterWsTest() throws Exception {
+ public void afterWsTest() {
if (wsClient != null) {
wsClient.close();
}
@@ -113,4 +113,11 @@ public abstract class AbstractControllerTest extends AbstractNotifyEntityTest {
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;
+ }
+
}
diff --git a/application/src/test/java/org/thingsboard/server/controller/ApiKeyWebSocketApiTest.java b/application/src/test/java/org/thingsboard/server/controller/ApiKeyWebSocketApiTest.java
new file mode 100644
index 0000000000..405eecec9b
--- /dev/null
+++ b/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();
+ }
+ }
+
+}
diff --git a/application/src/test/java/org/thingsboard/server/controller/EntityQueryControllerTest.java b/application/src/test/java/org/thingsboard/server/controller/EntityQueryControllerTest.java
index 835f00985b..5de4c9ffcf 100644
--- a/application/src/test/java/org/thingsboard/server/controller/EntityQueryControllerTest.java
+++ b/application/src/test/java/org/thingsboard/server/controller/EntityQueryControllerTest.java
@@ -28,6 +28,7 @@ import org.junit.Before;
import org.junit.Test;
import org.springframework.beans.factory.annotation.Autowired;
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.web.servlet.ResultActions;
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.AvailableEntityKeysV2;
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.DynamicValue;
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.tenant.profile.DefaultTenantProfileConfiguration;
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.service.DaoSqlTest;
import org.thingsboard.server.edqs.util.EdqsRocksDb;
@@ -123,6 +127,8 @@ public class EntityQueryControllerTest extends AbstractControllerTest {
@Autowired
private QueueStatsService queueStatsService;
+ @Autowired
+ private BaseEntityService baseEntityService;
@MockitoBean
private EdqsRocksDb edqsRocksDb;
@@ -1343,7 +1349,7 @@ public class EntityQueryControllerTest extends AbstractControllerTest {
//assign dashboard
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
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);
}
+ // --- 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 nameEntityField() {
+ return Collections.singletonList(new EntityKey(EntityKeyType.ENTITY_FIELD, "name"));
+ }
+
+ private static List extractNames(PageData 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 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 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 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 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 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 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 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 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 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 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 alarmResult = findAlarmsByQueryAndCheck(orAlarmQuery, 2);
+ List 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 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 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 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 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 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 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 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 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 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 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 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 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 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);
+ }
+
}
diff --git a/application/src/test/java/org/thingsboard/server/controller/RuleChainControllerTest.java b/application/src/test/java/org/thingsboard/server/controller/RuleChainControllerTest.java
index bcffd9099e..7f613168b0 100644
--- a/application/src/test/java/org/thingsboard/server/controller/RuleChainControllerTest.java
+++ b/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.rule.RuleChain;
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.RuleNode;
import org.thingsboard.server.common.data.security.Authority;
@@ -442,6 +443,201 @@ public class RuleChainControllerTest extends AbstractControllerTest {
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 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 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) {
RuleChain ruleChain = new RuleChain();
ruleChain.setName(name);
diff --git a/application/src/test/java/org/thingsboard/server/controller/TbTestWebSocketClient.java b/application/src/test/java/org/thingsboard/server/controller/TbTestWebSocketClient.java
index 26705ddacb..671c936585 100644
--- a/application/src/test/java/org/thingsboard/server/controller/TbTestWebSocketClient.java
+++ b/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.LatestValueCmd;
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.nio.channels.NotYetConnectedException;
@@ -53,6 +52,8 @@ public class TbTestWebSocketClient extends WebSocketClient {
private static final long TIMEOUT = TimeUnit.SECONDS.toMillis(30);
+ private final CountDownLatch closeLatch = new CountDownLatch(1);
+
@Getter
private volatile String lastMsg;
private volatile CountDownLatch reply;
@@ -62,6 +63,7 @@ public class TbTestWebSocketClient extends WebSocketClient {
super(serverUri);
}
+
@Override
public void onOpen(ServerHandshake serverHandshake) {
@@ -69,7 +71,13 @@ public class TbTestWebSocketClient extends WebSocketClient {
public void authenticate(String token) {
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));
}
@@ -88,6 +96,16 @@ public class TbTestWebSocketClient extends WebSocketClient {
@Override
public void onClose(int i, String s, boolean b) {
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
@@ -275,7 +293,7 @@ public class TbTestWebSocketClient extends WebSocketClient {
public JsonNode sendTimeseriesCmd(EntityId entityId, String 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.setEntityType(entityId.getEntityType().toString());
cmd.setCmdId(1);
diff --git a/application/src/test/java/org/thingsboard/server/controller/WebsocketApiTest.java b/application/src/test/java/org/thingsboard/server/controller/WebSocketApiTest.java
similarity index 85%
rename from application/src/test/java/org/thingsboard/server/controller/WebsocketApiTest.java
rename to application/src/test/java/org/thingsboard/server/controller/WebSocketApiTest.java
index 6ec0f3ad64..2174b7425f 100644
--- a/application/src/test/java/org/thingsboard/server/controller/WebsocketApiTest.java
+++ b/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.EntityCountUpdate;
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.Arrays;
import java.util.Collections;
import java.util.List;
-import java.util.Map;
import java.util.concurrent.CountDownLatch;
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.dynamic_page_link.refresh_interval=15"
})
-public class WebsocketApiTest extends AbstractControllerTest {
+public class WebSocketApiTest extends AbstractControllerTest {
+
@Autowired
private TelemetrySubscriptionService tsService;
@@ -136,8 +135,8 @@ public class WebsocketApiTest extends AbstractControllerTest {
PageData pageData = update.getData();
Assert.assertNotNull(pageData);
Assert.assertEquals(1, pageData.getData().size());
- Assert.assertEquals(device.getId(), pageData.getData().get(0).getEntityId());
- Assert.assertEquals(0, pageData.getData().get(0).getTimeseries().get("temperature").length);
+ Assert.assertEquals(device.getId(), pageData.getData().getFirst().getEntityId());
+ Assert.assertEquals(0, pageData.getData().getFirst().getTimeseries().get("temperature").length);
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));
@@ -152,8 +151,8 @@ public class WebsocketApiTest extends AbstractControllerTest {
List dataList = update.getUpdate();
Assert.assertNotNull(dataList);
Assert.assertEquals(1, dataList.size());
- Assert.assertEquals(device.getId(), dataList.get(0).getEntityId());
- TsValue[] tsArray = dataList.get(0).getTimeseries().get("temperature");
+ Assert.assertEquals(device.getId(), dataList.getFirst().getEntityId());
+ TsValue[] tsArray = dataList.getFirst().getTimeseries().get("temperature");
Assert.assertEquals(3, tsArray.length);
Assert.assertEquals(new TsValue(dataPoint1.getTs(), dataPoint1.getValueAsString()), tsArray[0]);
Assert.assertEquals(new TsValue(dataPoint2.getTs(), dataPoint2.getValueAsString()), tsArray[1]);
@@ -170,7 +169,7 @@ public class WebsocketApiTest extends AbstractControllerTest {
PageData