diff --git a/.github/workflows/check-configuration-files.yml b/.github/workflows/check-configuration-files.yml
index f280e326c2..561b7d0019 100644
--- a/.github/workflows/check-configuration-files.yml
+++ b/.github/workflows/check-configuration-files.yml
@@ -32,14 +32,14 @@ on:
jobs:
build:
name: Check thingsboard.yml file
- runs-on: ubuntu-20.04
+ runs-on: ubuntu-22.04
steps:
- name: Checkout code
uses: actions/checkout@v2
- - name: Set up Python 3.10
+ - name: Set up Python 3.13
uses: actions/setup-python@v3
with:
- python-version: "3.10.2"
+ python-version: "3.13.2"
architecture: "x64"
env:
AGENT_TOOLSDIRECTORY: /opt/hostedtoolcache
diff --git a/application/pom.xml b/application/pom.xml
index a9356cc14a..6ce77646f8 100644
--- a/application/pom.xml
+++ b/application/pom.xml
@@ -124,6 +124,10 @@
org.thingsboard.common
edge-api
+
+ org.thingsboard.common
+ edqs
+
org.thingsboard
dao
@@ -369,6 +373,10 @@
com.google.firebase
firebase-admin
+
+ org.rocksdb
+ rocksdbjni
+
diff --git a/application/src/main/data/json/edge/rule_chains/edge_root_rule_chain.json b/application/src/main/data/json/edge/rule_chains/edge_root_rule_chain.json
index 6701b59e0e..81f9e6a14d 100644
--- a/application/src/main/data/json/edge/rule_chains/edge_root_rule_chain.json
+++ b/application/src/main/data/json/edge/rule_chains/edge_root_rule_chain.json
@@ -50,8 +50,11 @@
},
"type": "org.thingsboard.rule.engine.telemetry.TbMsgAttributesNode",
"name": "Save Client Attributes",
- "configurationVersion": 2,
+ "configurationVersion": 3,
"configuration": {
+ "processingSettings": {
+ "type": "ON_EVERY_MESSAGE"
+ },
"scope": "CLIENT_SCOPE",
"notifyDevice": false,
"sendAttributesUpdatedNotification": false,
@@ -119,7 +122,7 @@
"type": "org.thingsboard.rule.engine.edge.TbMsgPushToCloudNode",
"name": "Push to cloud",
"configuration": {
- "scope": "SERVER_SCOPE"
+ "scope": "CLIENT_SCOPE"
},
"externalId": null
},
diff --git a/application/src/main/data/json/tenant/device_profile/rule_chain_template.json b/application/src/main/data/json/tenant/device_profile/rule_chain_template.json
index 0f2473cde6..305dc04961 100644
--- a/application/src/main/data/json/tenant/device_profile/rule_chain_template.json
+++ b/application/src/main/data/json/tenant/device_profile/rule_chain_template.json
@@ -35,8 +35,11 @@
},
"type": "org.thingsboard.rule.engine.telemetry.TbMsgAttributesNode",
"name": "Save Client Attributes",
- "configurationVersion": 2,
+ "configurationVersion": 3,
"configuration": {
+ "processingSettings": {
+ "type": "ON_EVERY_MESSAGE"
+ },
"scope": "CLIENT_SCOPE",
"notifyDevice": false,
"sendAttributesUpdatedNotification": false,
diff --git a/application/src/main/data/json/tenant/rule_chains/root_rule_chain.json b/application/src/main/data/json/tenant/rule_chains/root_rule_chain.json
index 8efda98c5b..a988c9d5eb 100644
--- a/application/src/main/data/json/tenant/rule_chains/root_rule_chain.json
+++ b/application/src/main/data/json/tenant/rule_chains/root_rule_chain.json
@@ -34,8 +34,11 @@
},
"type": "org.thingsboard.rule.engine.telemetry.TbMsgAttributesNode",
"name": "Save Client Attributes",
- "configurationVersion": 2,
+ "configurationVersion": 3,
"configuration": {
+ "processingSettings": {
+ "type": "ON_EVERY_MESSAGE"
+ },
"scope": "CLIENT_SCOPE",
"notifyDevice": false,
"sendAttributesUpdatedNotification": false,
diff --git a/application/src/main/data/upgrade/basic/schema_update.sql b/application/src/main/data/upgrade/basic/schema_update.sql
index 2929635949..29c7a084f4 100644
--- a/application/src/main/data/upgrade/basic/schema_update.sql
+++ b/application/src/main/data/upgrade/basic/schema_update.sql
@@ -16,50 +16,70 @@
-- UPDATE SAVE TIME SERIES NODES START
-DO $$
- BEGIN
- -- Check if the rule_node table exists
- IF EXISTS (
- SELECT 1
- FROM information_schema.tables
- WHERE table_name = 'rule_node'
- ) THEN
+UPDATE rule_node
+SET configuration = (
+ (configuration::jsonb - 'skipLatestPersistence')
+ || jsonb_build_object(
+ 'processingSettings', jsonb_build_object(
+ 'type', 'ADVANCED',
+ 'timeseries', jsonb_build_object('type', 'ON_EVERY_MESSAGE'),
+ 'latest', jsonb_build_object('type', 'SKIP'),
+ 'webSockets', jsonb_build_object('type', 'ON_EVERY_MESSAGE'),
+ 'calculatedFields', jsonb_build_object('type', 'ON_EVERY_MESSAGE')
+ )
+ )
+ )::text,
+ configuration_version = 1
+WHERE type = 'org.thingsboard.rule.engine.telemetry.TbMsgTimeseriesNode'
+ AND configuration_version = 0
+ AND configuration::jsonb ->> 'skipLatestPersistence' = 'true';
- UPDATE rule_node
- SET configuration = (
- (configuration::jsonb - 'skipLatestPersistence')
- || jsonb_build_object(
- 'processingSettings', jsonb_build_object(
- 'type', 'ADVANCED',
- 'timeseries', jsonb_build_object('type', 'ON_EVERY_MESSAGE'),
- 'latest', jsonb_build_object('type', 'SKIP'),
- 'webSockets', jsonb_build_object('type', 'ON_EVERY_MESSAGE')
- )
- )
- )::text,
- configuration_version = 1
- WHERE type = 'org.thingsboard.rule.engine.telemetry.TbMsgTimeseriesNode'
- AND configuration_version = 0
- AND configuration::jsonb ->> 'skipLatestPersistence' = 'true';
+UPDATE rule_node
+SET configuration = (
+ (configuration::jsonb - 'skipLatestPersistence')
+ || jsonb_build_object(
+ 'processingSettings', jsonb_build_object(
+ 'type', 'ON_EVERY_MESSAGE'
+ )
+ )
+ )::text,
+ configuration_version = 1
+WHERE type = 'org.thingsboard.rule.engine.telemetry.TbMsgTimeseriesNode'
+ AND configuration_version = 0
+ AND (configuration::jsonb ->> 'skipLatestPersistence' != 'true' OR configuration::jsonb ->> 'skipLatestPersistence' IS NULL);
- UPDATE rule_node
- SET configuration = (
- (configuration::jsonb - 'skipLatestPersistence')
- || jsonb_build_object(
- 'processingSettings', jsonb_build_object(
- 'type', 'ON_EVERY_MESSAGE'
- )
- )
- )::text,
- configuration_version = 1
- WHERE type = 'org.thingsboard.rule.engine.telemetry.TbMsgTimeseriesNode'
- AND configuration_version = 0
- AND (configuration::jsonb ->> 'skipLatestPersistence' != 'true' OR configuration::jsonb ->> 'skipLatestPersistence' IS NULL);
+-- UPDATE SAVE TIME SERIES NODES END
- END IF;
- END;
-$$;
+-- UPDATE SAVE ATTRIBUTES NODES START
--- UPDATE SAVE TIME SERIES NODES END
+UPDATE rule_node
+SET configuration = (
+ configuration::jsonb
+ || jsonb_build_object(
+ 'processingSettings', jsonb_build_object('type', 'ON_EVERY_MESSAGE')
+ )
+ )::text,
+ configuration_version = 3
+WHERE type = 'org.thingsboard.rule.engine.telemetry.TbMsgAttributesNode'
+ AND configuration_version = 2;
+
+-- UPDATE SAVE ATTRIBUTES NODES END
+
+ALTER TABLE api_usage_state ADD COLUMN IF NOT EXISTS version BIGINT DEFAULT 1;
+
+-- UPDATE TENANT PROFILE CALCULATED FIELD LIMITS START
+
+UPDATE tenant_profile
+SET profile_data = profile_data
+ || jsonb_build_object(
+ 'configuration', profile_data->'configuration' || jsonb_build_object(
+ 'maxCalculatedFieldsPerEntity', COALESCE(profile_data->'configuration'->>'maxCalculatedFieldsPerEntity', '5')::bigint,
+ 'maxArgumentsPerCF', COALESCE(profile_data->'configuration'->>'maxArgumentsPerCF', '10')::bigint,
+ 'maxDataPointsPerRollingArg', COALESCE(profile_data->'configuration'->>'maxDataPointsPerRollingArg', '1000')::bigint,
+ 'maxStateSizeInKBytes', COALESCE(profile_data->'configuration'->>'maxStateSizeInKBytes', '32')::bigint,
+ 'maxSingleValueArgumentSizeInKBytes', COALESCE(profile_data->'configuration'->>'maxSingleValueArgumentSizeInKBytes', '2')::bigint
+ )
+ )
+WHERE profile_data->'configuration'->>'maxCalculatedFieldsPerEntity' IS NULL;
-ALTER TABLE api_usage_state ADD COLUMN IF NOT EXISTS version BIGINT DEFAULT 1;
\ No newline at end of file
+-- UPDATE TENANT PROFILE CALCULATED FIELD LIMITS END
diff --git a/application/src/main/java/org/thingsboard/server/actors/ActorSystemContext.java b/application/src/main/java/org/thingsboard/server/actors/ActorSystemContext.java
index eb76473386..2c22941c2f 100644
--- a/application/src/main/java/org/thingsboard/server/actors/ActorSystemContext.java
+++ b/application/src/main/java/org/thingsboard/server/actors/ActorSystemContext.java
@@ -33,7 +33,7 @@ import org.springframework.stereotype.Component;
import org.thingsboard.common.util.JacksonUtil;
import org.thingsboard.rule.engine.api.MailService;
import org.thingsboard.rule.engine.api.NotificationCenter;
-import org.thingsboard.rule.engine.api.RuleEngineDeviceStateManager;
+import org.thingsboard.rule.engine.api.DeviceStateManager;
import org.thingsboard.rule.engine.api.SmsService;
import org.thingsboard.rule.engine.api.notification.SlackService;
import org.thingsboard.rule.engine.api.sms.SmsSenderFactory;
@@ -41,13 +41,18 @@ import org.thingsboard.script.api.js.JsInvokeService;
import org.thingsboard.script.api.tbel.TbelInvokeService;
import org.thingsboard.server.actors.service.ActorService;
import org.thingsboard.server.actors.tenant.DebugTbRateLimits;
+import org.thingsboard.server.cache.limits.RateLimitService;
import org.thingsboard.server.cluster.TbClusterService;
+import org.thingsboard.server.common.data.event.CalculatedFieldDebugEvent;
import org.thingsboard.server.common.data.event.ErrorEvent;
import org.thingsboard.server.common.data.event.LifecycleEvent;
import org.thingsboard.server.common.data.event.RuleChainDebugEvent;
import org.thingsboard.server.common.data.event.RuleNodeDebugEvent;
+import org.thingsboard.server.common.data.id.CalculatedFieldId;
import org.thingsboard.server.common.data.id.EntityId;
import org.thingsboard.server.common.data.id.TenantId;
+import org.thingsboard.server.common.data.limit.LimitedApi;
+import org.thingsboard.server.common.data.msg.TbMsgType;
import org.thingsboard.server.common.data.plugin.ComponentLifecycleEvent;
import org.thingsboard.server.common.msg.TbActorMsg;
import org.thingsboard.server.common.msg.TbMsg;
@@ -62,6 +67,7 @@ import org.thingsboard.server.dao.asset.AssetService;
import org.thingsboard.server.dao.attributes.AttributesService;
import org.thingsboard.server.dao.audit.AuditLogService;
import org.thingsboard.server.dao.cassandra.CassandraCluster;
+import org.thingsboard.server.dao.cf.CalculatedFieldService;
import org.thingsboard.server.dao.customer.CustomerService;
import org.thingsboard.server.dao.dashboard.DashboardService;
import org.thingsboard.server.dao.device.ClaimDevicesService;
@@ -94,6 +100,7 @@ import org.thingsboard.server.dao.tenant.TbTenantProfileCache;
import org.thingsboard.server.dao.tenant.TenantProfileService;
import org.thingsboard.server.dao.tenant.TenantService;
import org.thingsboard.server.dao.timeseries.TimeseriesService;
+import org.thingsboard.server.dao.usagerecord.ApiLimitService;
import org.thingsboard.server.dao.user.UserService;
import org.thingsboard.server.dao.widget.WidgetTypeService;
import org.thingsboard.server.dao.widget.WidgetsBundleService;
@@ -101,6 +108,11 @@ import org.thingsboard.server.queue.discovery.DiscoveryService;
import org.thingsboard.server.queue.discovery.PartitionService;
import org.thingsboard.server.queue.discovery.TbServiceInfoProvider;
import org.thingsboard.server.service.apiusage.TbApiUsageStateService;
+import org.thingsboard.server.service.cf.CalculatedFieldProcessingService;
+import org.thingsboard.server.service.cf.CalculatedFieldQueueService;
+import org.thingsboard.server.service.cf.CalculatedFieldStateService;
+import org.thingsboard.server.service.cf.cache.CalculatedFieldEntityProfileCache;
+import org.thingsboard.server.service.cf.ctx.state.ArgumentEntry;
import org.thingsboard.server.service.component.ComponentDiscoveryService;
import org.thingsboard.server.service.edge.rpc.EdgeRpcService;
import org.thingsboard.server.service.entitiy.entityview.TbEntityViewService;
@@ -121,13 +133,17 @@ import org.thingsboard.server.service.state.DeviceStateService;
import org.thingsboard.server.service.telemetry.AlarmSubscriptionService;
import org.thingsboard.server.service.telemetry.TelemetrySubscriptionService;
import org.thingsboard.server.service.transport.TbCoreToTransportService;
+import org.thingsboard.server.utils.DebugModeRateLimitsConfig;
import java.io.PrintWriter;
import java.io.StringWriter;
+import java.util.Map;
+import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ConcurrentMap;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit;
+import java.util.stream.Collectors;
@Slf4j
@Component
@@ -156,6 +172,18 @@ public class ActorSystemContext {
}
};
+ private static final FutureCallback CALCULATED_FIELD_DEBUG_EVENT_ERROR_CALLBACK = new FutureCallback<>() {
+ @Override
+ public void onSuccess(@Nullable Void event) {
+
+ }
+
+ @Override
+ public void onFailure(Throwable th) {
+ log.error("Could not save debug Event for Calculated Field", th);
+ }
+ };
+
private final ConcurrentMap debugPerTenantLimits = new ConcurrentHashMap<>();
public ConcurrentMap getDebugPerTenantLimits() {
@@ -206,7 +234,7 @@ public class ActorSystemContext {
@Autowired(required = false)
@Getter
- private RuleEngineDeviceStateManager deviceStateManager;
+ private DeviceStateManager deviceStateManager;
@Autowired
@Getter
@@ -289,6 +317,7 @@ public class ActorSystemContext {
@Getter
private TbEntityViewService tbEntityViewService;
+ @Lazy
@Autowired
@Getter
private TelemetrySubscriptionService tsSubService;
@@ -394,6 +423,10 @@ public class ActorSystemContext {
@Getter
private SlackService slackService;
+ @Autowired
+ @Getter
+ private CalculatedFieldService calculatedFieldService;
+
@Lazy
@Autowired(required = false)
@Getter
@@ -416,6 +449,21 @@ public class ActorSystemContext {
@Getter
private TbCoreToTransportService tbCoreToTransportService;
+ @Lazy
+ @Autowired(required = false)
+ @Getter
+ private ApiLimitService apiLimitService;
+
+ @Lazy
+ @Autowired(required = false)
+ @Getter
+ private RateLimitService rateLimitService;
+
+ @Lazy
+ @Autowired(required = false)
+ @Getter
+ private DebugModeRateLimitsConfig debugModeRateLimitsConfig;
+
/**
* The following Service will be null if we operate in tb-core mode
*/
@@ -487,6 +535,26 @@ public class ActorSystemContext {
@Getter
private EntityService entityService;
+ @Lazy
+ @Autowired(required = false)
+ @Getter
+ private CalculatedFieldProcessingService calculatedFieldProcessingService;
+
+ @Lazy
+ @Autowired(required = false)
+ @Getter
+ private CalculatedFieldStateService calculatedFieldStateService;
+
+ @Lazy
+ @Autowired(required = false)
+ @Getter
+ private CalculatedFieldQueueService calculatedFieldQueueService;
+
+ @Lazy
+ @Autowired(required = false)
+ @Getter
+ private CalculatedFieldEntityProfileCache calculatedFieldEntityProfileCache;
+
@Value("${actors.session.max_concurrent_sessions_per_device:1}")
@Getter
private long maxConcurrentSessionsPerDevice;
@@ -558,14 +626,6 @@ public class ActorSystemContext {
@Getter
private long sessionReportTimeout;
- @Value("${actors.rule.chain.debug_mode_rate_limits_per_tenant.enabled:true}")
- @Getter
- private boolean debugPerTenantEnabled;
-
- @Value("${actors.rule.chain.debug_mode_rate_limits_per_tenant.configuration:50000:3600}")
- @Getter
- private String debugPerTenantLimitsConfiguration;
-
@Value("${actors.rpc.submit_strategy:BURST}")
@Getter
private String rpcSubmitStrategy;
@@ -590,6 +650,10 @@ public class ActorSystemContext {
@Getter
private String deviceStateNodeRateLimitConfig;
+ @Value("${actors.calculated_fields.calculation_timeout:5}")
+ @Getter
+ private long cfCalculationResultTimeout;
+
@Getter
@Setter
private TbActorSystem actorSystem;
@@ -719,9 +783,9 @@ public class ActorSystemContext {
}
private boolean checkLimits(TenantId tenantId, TbMsg tbMsg, Throwable error) {
- if (debugPerTenantEnabled) {
+ if (debugModeRateLimitsConfig.isRuleChainDebugPerTenantLimitsEnabled()) {
DebugTbRateLimits debugTbRateLimits = debugPerTenantLimits.computeIfAbsent(tenantId, id ->
- new DebugTbRateLimits(new TbRateLimits(debugPerTenantLimitsConfiguration), false));
+ new DebugTbRateLimits(new TbRateLimits(debugModeRateLimitsConfig.getRuleChainDebugPerTenantLimitsConfiguration()), false));
if (!debugTbRateLimits.getTbRateLimits().tryConsume()) {
if (!debugTbRateLimits.isRuleChainEventSaved()) {
@@ -751,6 +815,51 @@ public class ActorSystemContext {
Futures.addCallback(future, RULE_CHAIN_DEBUG_EVENT_ERROR_CALLBACK, MoreExecutors.directExecutor());
}
+ public void persistCalculatedFieldDebugEvent(TenantId tenantId, CalculatedFieldId calculatedFieldId, EntityId entityId, Map arguments, UUID tbMsgId, TbMsgType tbMsgType, String result, String errorMessage) {
+ if (checkLimits(tenantId)) {
+ try {
+ CalculatedFieldDebugEvent.CalculatedFieldDebugEventBuilder eventBuilder = CalculatedFieldDebugEvent.builder()
+ .tenantId(tenantId)
+ .entityId(calculatedFieldId.getId())
+ .serviceId(getServiceId())
+ .calculatedFieldId(calculatedFieldId)
+ .eventEntity(entityId);
+ if (tbMsgId != null) {
+ eventBuilder.msgId(tbMsgId);
+ }
+ if (tbMsgType != null) {
+ eventBuilder.msgType(tbMsgType.name());
+ }
+ if (arguments != null) {
+ eventBuilder.arguments(JacksonUtil.toString(
+ arguments.entrySet().stream()
+ .collect(Collectors.toMap(Map.Entry::getKey, e -> e.getValue().toTbelCfArg()))
+ ));
+ }
+ if (result != null) {
+ eventBuilder.result(result);
+ }
+ if (errorMessage != null) {
+ eventBuilder.error(errorMessage);
+ }
+
+ ListenableFuture future = eventService.saveAsync(eventBuilder.build());
+ Futures.addCallback(future, CALCULATED_FIELD_DEBUG_EVENT_ERROR_CALLBACK, MoreExecutors.directExecutor());
+ } catch (IllegalArgumentException ex) {
+ log.warn("Failed to persist calculated field debug message", ex);
+ }
+ }
+ }
+
+ private boolean checkLimits(TenantId tenantId) {
+ if (debugModeRateLimitsConfig.isCalculatedFieldDebugPerTenantLimitsEnabled() &&
+ !rateLimitService.checkRateLimit(LimitedApi.CALCULATED_FIELD_DEBUG_EVENTS, (Object) tenantId, debugModeRateLimitsConfig.getCalculatedFieldDebugPerTenantLimitsConfiguration())) {
+ log.trace("[{}] Calculated field debug event limits exceeded!", tenantId);
+ return false;
+ }
+ return true;
+ }
+
public static Exception toException(Throwable error) {
return Exception.class.isInstance(error) ? (Exception) error : new Exception(error);
}
diff --git a/application/src/main/java/org/thingsboard/server/actors/app/AppActor.java b/application/src/main/java/org/thingsboard/server/actors/app/AppActor.java
index dc6a3bcf5e..50fa7e2f8d 100644
--- a/application/src/main/java/org/thingsboard/server/actors/app/AppActor.java
+++ b/application/src/main/java/org/thingsboard/server/actors/app/AppActor.java
@@ -36,6 +36,7 @@ import org.thingsboard.server.common.data.page.PageDataIterable;
import org.thingsboard.server.common.data.plugin.ComponentLifecycleEvent;
import org.thingsboard.server.common.msg.MsgType;
import org.thingsboard.server.common.msg.TbActorMsg;
+import org.thingsboard.server.common.msg.ToCalculatedFieldSystemMsg;
import org.thingsboard.server.common.msg.aware.TenantAwareMsg;
import org.thingsboard.server.common.msg.plugin.ComponentLifecycleMsg;
import org.thingsboard.server.common.msg.queue.QueueToRuleEngineMsg;
@@ -87,6 +88,7 @@ public class AppActor extends ContextAwareActor {
case APP_INIT_MSG:
break;
case PARTITION_CHANGE_MSG:
+ case CF_PARTITIONS_CHANGE_MSG:
ctx.broadcastToChildren(msg, true);
break;
case COMPONENT_LIFE_CYCLE_MSG:
@@ -111,6 +113,18 @@ public class AppActor extends ContextAwareActor {
case SESSION_TIMEOUT_MSG:
ctx.broadcastToChildrenByType(msg, EntityType.TENANT);
break;
+ case CF_INIT_MSG:
+ case CF_LINK_INIT_MSG:
+ case CF_STATE_RESTORE_MSG:
+ case CF_ENTITY_LIFECYCLE_MSG:
+ //TODO: use priority from the message body. For example, messages about CF lifecycle are important and Device lifecycle are not.
+ // same for the Linked telemetry.
+ onToCalculatedFieldSystemActorMsg((ToCalculatedFieldSystemMsg) msg, true);
+ break;
+ case CF_TELEMETRY_MSG:
+ case CF_LINKED_TELEMETRY_MSG:
+ onToCalculatedFieldSystemActorMsg((ToCalculatedFieldSystemMsg) msg, false);
+ break;
default:
return false;
}
@@ -175,6 +189,19 @@ public class AppActor extends ContextAwareActor {
}
}
+ private void onToCalculatedFieldSystemActorMsg(ToCalculatedFieldSystemMsg msg, boolean priority) {
+ getOrCreateTenantActor(msg.getTenantId()).ifPresentOrElse(tenantActor -> {
+ if (priority) {
+ tenantActor.tellWithHighPriority(msg);
+ } else {
+ tenantActor.tell(msg);
+ }
+ }, () -> {
+ msg.getCallback().onSuccess();
+ });
+ }
+
+
private void onToDeviceActorMsg(TenantAwareMsg msg, boolean priority) {
getOrCreateTenantActor(msg.getTenantId()).ifPresentOrElse(tenantActor -> {
if (priority) {
diff --git a/application/src/main/java/org/thingsboard/server/actors/calculatedField/AbstractCalculatedFieldActor.java b/application/src/main/java/org/thingsboard/server/actors/calculatedField/AbstractCalculatedFieldActor.java
new file mode 100644
index 0000000000..6cf34599de
--- /dev/null
+++ b/application/src/main/java/org/thingsboard/server/actors/calculatedField/AbstractCalculatedFieldActor.java
@@ -0,0 +1,70 @@
+/**
+ * Copyright © 2016-2025 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.actors.calculatedField;
+
+import lombok.extern.slf4j.Slf4j;
+import org.thingsboard.common.util.DebugModeUtil;
+import org.thingsboard.server.actors.ActorSystemContext;
+import org.thingsboard.server.actors.service.ContextAwareActor;
+import org.thingsboard.server.common.data.id.TenantId;
+import org.thingsboard.server.common.msg.TbActorMsg;
+import org.thingsboard.server.common.msg.ToCalculatedFieldSystemMsg;
+
+@Slf4j
+public abstract class AbstractCalculatedFieldActor extends ContextAwareActor {
+
+ protected final TenantId tenantId;
+
+ public AbstractCalculatedFieldActor(ActorSystemContext systemContext, TenantId tenantId) {
+ super(systemContext);
+ this.tenantId = tenantId;
+ }
+
+ @Override
+ protected boolean doProcess(TbActorMsg msg) {
+ if (msg instanceof ToCalculatedFieldSystemMsg cfm) {
+ Exception cause;
+ try {
+ return doProcessCfMsg(cfm);
+ } catch (CalculatedFieldException cfe) {
+ if (DebugModeUtil.isDebugFailuresAvailable(cfe.getCtx().getCalculatedField())) {
+ String message;
+ if (cfe.getErrorMessage() != null) {
+ message = cfe.getErrorMessage();
+ } else if (cfe.getCause() != null) {
+ message = cfe.getCause().getMessage();
+ } else {
+ message = "N/A";
+ }
+ systemContext.persistCalculatedFieldDebugEvent(tenantId, cfe.getCtx().getCfId(), cfe.getEventEntity(), cfe.getArguments(), cfe.getMsgId(), cfe.getMsgType(), null, message);
+ }
+ cause = cfe.getCause();
+ } catch (Exception e) {
+ logProcessingException(e);
+ cause = e;
+ }
+ cfm.getCallback().onFailure(cause);
+ return true;
+ } else {
+ return false;
+ }
+ }
+
+ abstract void logProcessingException(Exception e);
+
+ abstract boolean doProcessCfMsg(ToCalculatedFieldSystemMsg msg) throws CalculatedFieldException;
+
+}
diff --git a/application/src/main/java/org/thingsboard/server/actors/calculatedField/CalculatedFieldEntityActor.java b/application/src/main/java/org/thingsboard/server/actors/calculatedField/CalculatedFieldEntityActor.java
new file mode 100644
index 0000000000..350a5776cf
--- /dev/null
+++ b/application/src/main/java/org/thingsboard/server/actors/calculatedField/CalculatedFieldEntityActor.java
@@ -0,0 +1,81 @@
+/**
+ * Copyright © 2016-2025 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.actors.calculatedField;
+
+import lombok.extern.slf4j.Slf4j;
+import org.thingsboard.server.actors.ActorSystemContext;
+import org.thingsboard.server.actors.TbActorCtx;
+import org.thingsboard.server.actors.TbActorException;
+import org.thingsboard.server.common.data.id.EntityId;
+import org.thingsboard.server.common.data.id.TenantId;
+import org.thingsboard.server.common.msg.ToCalculatedFieldSystemMsg;
+import org.thingsboard.server.common.msg.cf.CalculatedFieldPartitionChangeMsg;
+
+@Slf4j
+public class CalculatedFieldEntityActor extends AbstractCalculatedFieldActor {
+
+ private final CalculatedFieldEntityMessageProcessor processor;
+
+ CalculatedFieldEntityActor(ActorSystemContext systemContext, TenantId tenantId, EntityId entityId) {
+ super(systemContext, tenantId);
+ this.processor = new CalculatedFieldEntityMessageProcessor(systemContext, tenantId, entityId);
+ }
+
+ @Override
+ public void init(TbActorCtx ctx) throws TbActorException {
+ super.init(ctx);
+ log.debug("[{}][{}] Starting CF entity actor.", processor.tenantId, processor.entityId);
+ try {
+ processor.init(ctx);
+ log.debug("[{}][{}] CF entity actor started.", processor.tenantId, processor.entityId);
+ } catch (Exception e) {
+ log.warn("[{}][{}] Unknown failure", processor.tenantId, processor.entityId, e);
+ throw new TbActorException("Failed to initialize CF entity actor", e);
+ }
+ }
+
+ @Override
+ protected boolean doProcessCfMsg(ToCalculatedFieldSystemMsg msg) throws CalculatedFieldException {
+ switch (msg.getMsgType()) {
+ case CF_PARTITIONS_CHANGE_MSG:
+ processor.process((CalculatedFieldPartitionChangeMsg) msg);
+ break;
+ case CF_STATE_RESTORE_MSG:
+ processor.process((CalculatedFieldStateRestoreMsg) msg);
+ break;
+ case CF_ENTITY_INIT_CF_MSG:
+ processor.process((EntityInitCalculatedFieldMsg) msg);
+ break;
+ case CF_ENTITY_DELETE_MSG:
+ processor.process((CalculatedFieldEntityDeleteMsg) msg);
+ break;
+ case CF_ENTITY_TELEMETRY_MSG:
+ processor.process((EntityCalculatedFieldTelemetryMsg) msg);
+ break;
+ case CF_LINKED_TELEMETRY_MSG:
+ processor.process((EntityCalculatedFieldLinkedTelemetryMsg) msg);
+ break;
+ default:
+ return false;
+ }
+ return true;
+ }
+
+ @Override
+ void logProcessingException(Exception e) {
+ log.warn("[{}][{}] Processing failure", tenantId, processor.entityId, e);
+ }
+}
diff --git a/application/src/main/java/org/thingsboard/server/actors/calculatedField/CalculatedFieldEntityActorCreator.java b/application/src/main/java/org/thingsboard/server/actors/calculatedField/CalculatedFieldEntityActorCreator.java
new file mode 100644
index 0000000000..6dc2f26050
--- /dev/null
+++ b/application/src/main/java/org/thingsboard/server/actors/calculatedField/CalculatedFieldEntityActorCreator.java
@@ -0,0 +1,50 @@
+/**
+ * Copyright © 2016-2025 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.actors.calculatedField;
+
+import org.thingsboard.server.actors.ActorSystemContext;
+import org.thingsboard.server.actors.TbActor;
+import org.thingsboard.server.actors.TbActorId;
+import org.thingsboard.server.actors.TbCalculatedFieldEntityActorId;
+import org.thingsboard.server.actors.TbEntityActorId;
+import org.thingsboard.server.actors.device.DeviceActor;
+import org.thingsboard.server.actors.service.ContextBasedCreator;
+import org.thingsboard.server.common.data.id.DeviceId;
+import org.thingsboard.server.common.data.id.EntityId;
+import org.thingsboard.server.common.data.id.TenantId;
+
+public class CalculatedFieldEntityActorCreator extends ContextBasedCreator {
+
+ private final TenantId tenantId;
+ private final EntityId entityId;
+
+ public CalculatedFieldEntityActorCreator(ActorSystemContext context, TenantId tenantId, EntityId entityId) {
+ super(context);
+ this.tenantId = tenantId;
+ this.entityId = entityId;
+ }
+
+ @Override
+ public TbActorId createActorId() {
+ return new TbCalculatedFieldEntityActorId(entityId);
+ }
+
+ @Override
+ public TbActor createActor() {
+ return new CalculatedFieldEntityActor(context, tenantId, entityId);
+ }
+
+}
diff --git a/application/src/main/java/org/thingsboard/server/actors/calculatedField/CalculatedFieldEntityDeleteMsg.java b/application/src/main/java/org/thingsboard/server/actors/calculatedField/CalculatedFieldEntityDeleteMsg.java
new file mode 100644
index 0000000000..3ca6e8596a
--- /dev/null
+++ b/application/src/main/java/org/thingsboard/server/actors/calculatedField/CalculatedFieldEntityDeleteMsg.java
@@ -0,0 +1,44 @@
+/**
+ * Copyright © 2016-2025 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.actors.calculatedField;
+
+import lombok.Data;
+import org.thingsboard.server.common.data.id.EntityId;
+import org.thingsboard.server.common.data.id.TenantId;
+import org.thingsboard.server.common.msg.MsgType;
+import org.thingsboard.server.common.msg.ToCalculatedFieldSystemMsg;
+import org.thingsboard.server.common.msg.queue.TbCallback;
+
+@Data
+public class CalculatedFieldEntityDeleteMsg implements ToCalculatedFieldSystemMsg {
+
+ private final TenantId tenantId;
+ private final EntityId entityId;
+ private final TbCallback callback;
+
+ public CalculatedFieldEntityDeleteMsg(TenantId tenantId,
+ EntityId entityId,
+ TbCallback callback) {
+ this.tenantId = tenantId;
+ this.entityId = entityId;
+ this.callback = callback;
+ }
+
+ @Override
+ public MsgType getMsgType() {
+ return MsgType.CF_ENTITY_DELETE_MSG;
+ }
+}
diff --git a/application/src/main/java/org/thingsboard/server/actors/calculatedField/CalculatedFieldEntityMessageProcessor.java b/application/src/main/java/org/thingsboard/server/actors/calculatedField/CalculatedFieldEntityMessageProcessor.java
new file mode 100644
index 0000000000..9ab019097b
--- /dev/null
+++ b/application/src/main/java/org/thingsboard/server/actors/calculatedField/CalculatedFieldEntityMessageProcessor.java
@@ -0,0 +1,444 @@
+/**
+ * Copyright © 2016-2025 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.actors.calculatedField;
+
+import com.google.common.util.concurrent.ListenableFuture;
+import lombok.SneakyThrows;
+import lombok.extern.slf4j.Slf4j;
+import org.thingsboard.common.util.DebugModeUtil;
+import org.thingsboard.common.util.JacksonUtil;
+import org.thingsboard.server.actors.ActorSystemContext;
+import org.thingsboard.server.actors.TbActorCtx;
+import org.thingsboard.server.actors.shared.AbstractContextAwareMsgProcessor;
+import org.thingsboard.server.common.data.AttributeScope;
+import org.thingsboard.server.common.data.StringUtils;
+import org.thingsboard.server.common.data.cf.configuration.Argument;
+import org.thingsboard.server.common.data.cf.configuration.ArgumentType;
+import org.thingsboard.server.common.data.cf.configuration.ReferencedEntityKey;
+import org.thingsboard.server.common.data.id.CalculatedFieldId;
+import org.thingsboard.server.common.data.id.EntityId;
+import org.thingsboard.server.common.data.id.TenantId;
+import org.thingsboard.server.common.data.kv.StringDataEntry;
+import org.thingsboard.server.common.data.msg.TbMsgType;
+import org.thingsboard.server.common.msg.cf.CalculatedFieldPartitionChangeMsg;
+import org.thingsboard.server.common.msg.queue.TbCallback;
+import org.thingsboard.server.gen.transport.TransportProtos.AttributeScopeProto;
+import org.thingsboard.server.gen.transport.TransportProtos.AttributeValueProto;
+import org.thingsboard.server.gen.transport.TransportProtos.CalculatedFieldTelemetryMsgProto;
+import org.thingsboard.server.gen.transport.TransportProtos.TsKvProto;
+import org.thingsboard.server.service.cf.CalculatedFieldProcessingService;
+import org.thingsboard.server.service.cf.CalculatedFieldResult;
+import org.thingsboard.server.service.cf.CalculatedFieldStateService;
+import org.thingsboard.server.service.cf.ctx.CalculatedFieldEntityCtxId;
+import org.thingsboard.server.service.cf.ctx.state.ArgumentEntry;
+import org.thingsboard.server.service.cf.ctx.state.CalculatedFieldCtx;
+import org.thingsboard.server.service.cf.ctx.state.CalculatedFieldState;
+import org.thingsboard.server.service.cf.ctx.state.SingleValueArgumentEntry;
+
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.LinkedList;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.UUID;
+import java.util.concurrent.TimeUnit;
+import java.util.stream.Collectors;
+
+
+/**
+ * @author Andrew Shvayka
+ */
+@Slf4j
+public class CalculatedFieldEntityMessageProcessor extends AbstractContextAwareMsgProcessor {
+ // (1 for result persistence + 1 for the state persistence )
+ public static final int CALLBACKS_PER_CF = 2;
+
+ final TenantId tenantId;
+ final EntityId entityId;
+ final CalculatedFieldProcessingService cfService;
+ final CalculatedFieldStateService cfStateService;
+ final int partition;
+
+ TbActorCtx ctx;
+ Map states = new HashMap<>();
+
+ CalculatedFieldEntityMessageProcessor(ActorSystemContext systemContext, TenantId tenantId, EntityId entityId) {
+ super(systemContext);
+ this.tenantId = tenantId;
+ this.entityId = entityId;
+ this.cfService = systemContext.getCalculatedFieldProcessingService();
+ this.cfStateService = systemContext.getCalculatedFieldStateService();
+ this.partition = systemContext.getCalculatedFieldEntityProfileCache().getEntityIdPartition(tenantId, entityId);
+ }
+
+ void init(TbActorCtx ctx) {
+ this.ctx = ctx;
+ }
+
+ public void process(CalculatedFieldPartitionChangeMsg msg) {
+ if (!msg.getPartitions()[partition]) {
+ log.info("[{}][{}] Stopping entity actor due to change partition event.", partition, entityId);
+ ctx.stop(ctx.getSelf());
+ }
+ }
+
+ public void process(CalculatedFieldStateRestoreMsg msg) {
+ CalculatedFieldId cfId = msg.getId().cfId();
+ log.info("[{}] [{}] Processing CF state restore msg.", msg.getId().entityId(), cfId);
+ if (msg.getState() != null) {
+ states.put(cfId, msg.getState());
+ } else {
+ states.remove(cfId);
+ }
+ }
+
+ public void process(EntityInitCalculatedFieldMsg msg) throws CalculatedFieldException {
+ log.info("[{}] Processing entity init CF msg.", msg.getCtx().getCfId());
+ var ctx = msg.getCtx();
+ if (msg.isForceReinit()) {
+ log.info("Force reinitialization of CF: [{}].", ctx.getCfId());
+ states.remove(ctx.getCfId());
+ }
+ try {
+ var state = getOrInitState(ctx);
+ if (state.isSizeOk()) {
+ processStateIfReady(ctx, Collections.singletonList(ctx.getCfId()), state, null, null, msg.getCallback());
+ } else {
+ throw new RuntimeException(ctx.getSizeExceedsLimitMessage());
+ }
+ } catch (Exception e) {
+ if (e instanceof CalculatedFieldException cfe) {
+ throw cfe;
+ }
+ throw CalculatedFieldException.builder().ctx(ctx).eventEntity(entityId).cause(e).build();
+ }
+ }
+
+ public void process(CalculatedFieldEntityDeleteMsg msg) {
+ log.info("[{}] Processing CF entity delete msg.", msg.getEntityId());
+ if (this.entityId.equals(msg.getEntityId())) {
+ if (states.isEmpty()) {
+ msg.getCallback().onSuccess();
+ } else {
+ MultipleTbCallback multipleTbCallback = new MultipleTbCallback(states.size(), msg.getCallback());
+ states.forEach((cfId, state) -> cfStateService.removeState(new CalculatedFieldEntityCtxId(tenantId, cfId, entityId), multipleTbCallback));
+ ctx.stop(ctx.getSelf());
+ }
+ } else {
+ var cfId = new CalculatedFieldId(msg.getEntityId().getId());
+ var state = states.remove(cfId);
+ if (state != null) {
+ cfStateService.removeState(new CalculatedFieldEntityCtxId(tenantId, cfId, entityId), msg.getCallback());
+ } else {
+ msg.getCallback().onSuccess();
+ }
+ }
+ }
+
+ public void process(EntityCalculatedFieldTelemetryMsg msg) throws CalculatedFieldException {
+ log.info("[{}] Processing CF telemetry msg.", msg.getEntityId());
+ var proto = msg.getProto();
+ var numberOfCallbacks = CALLBACKS_PER_CF * (msg.getEntityIdFields().size() + msg.getProfileIdFields().size());
+ MultipleTbCallback callback = new MultipleTbCallback(numberOfCallbacks, msg.getCallback());
+ List cfIdList = getCalculatedFieldIds(proto);
+ Set cfIdSet = new HashSet<>(cfIdList);
+ for (var ctx : msg.getEntityIdFields()) {
+ process(ctx, proto, cfIdSet, cfIdList, callback);
+ }
+ for (var ctx : msg.getProfileIdFields()) {
+ process(ctx, proto, cfIdSet, cfIdList, callback);
+ }
+ }
+
+ public void process(EntityCalculatedFieldLinkedTelemetryMsg msg) throws CalculatedFieldException {
+ log.info("[{}] Processing CF link telemetry msg.", msg.getEntityId());
+ var proto = msg.getProto();
+ var ctx = msg.getCtx();
+ var callback = new MultipleTbCallback(CALLBACKS_PER_CF, msg.getCallback());
+ try {
+ List cfIds = getCalculatedFieldIds(proto);
+ if (cfIds.contains(ctx.getCfId())) {
+ callback.onSuccess(CALLBACKS_PER_CF);
+ } else {
+ if (proto.getTsDataCount() > 0) {
+ processArgumentValuesUpdate(ctx, cfIds, callback, mapToArguments(ctx, msg.getEntityId(), proto.getTsDataList()), toTbMsgId(proto), toTbMsgType(proto));
+ } else if (proto.getAttrDataCount() > 0) {
+ processArgumentValuesUpdate(ctx, cfIds, callback, mapToArguments(ctx, msg.getEntityId(), proto.getScope(), proto.getAttrDataList()), toTbMsgId(proto), toTbMsgType(proto));
+ } else if (proto.getRemovedTsKeysCount() > 0) {
+ processArgumentValuesUpdate(ctx, cfIds, callback, mapToArgumentsWithFetchedValue(ctx, proto.getRemovedTsKeysList()), toTbMsgId(proto), toTbMsgType(proto));
+ } else if (proto.getRemovedAttrKeysCount() > 0) {
+ processArgumentValuesUpdate(ctx, cfIds, callback, mapToArgumentsWithDefaultValue(ctx, msg.getEntityId(), proto.getScope(), proto.getRemovedAttrKeysList()), toTbMsgId(proto), toTbMsgType(proto));
+ } else {
+ callback.onSuccess(CALLBACKS_PER_CF);
+ }
+ }
+ } catch (Exception e) {
+ throw CalculatedFieldException.builder().ctx(ctx).eventEntity(entityId).cause(e).build();
+ }
+ }
+
+ private void process(CalculatedFieldCtx ctx, CalculatedFieldTelemetryMsgProto proto, Collection cfIds, List cfIdList, MultipleTbCallback callback) throws CalculatedFieldException {
+ try {
+ if (cfIds.contains(ctx.getCfId())) {
+ callback.onSuccess(CALLBACKS_PER_CF);
+ } else {
+ if (proto.getTsDataCount() > 0) {
+ processTelemetry(ctx, proto, cfIdList, callback);
+ } else if (proto.getAttrDataCount() > 0) {
+ processAttributes(ctx, proto, cfIdList, callback);
+ } else if (proto.getRemovedTsKeysCount() > 0) {
+ processRemovedTelemetry(ctx, proto, cfIdList, callback);
+ } else if (proto.getRemovedAttrKeysCount() > 0) {
+ processRemovedAttributes(ctx, proto, cfIdList, callback);
+ } else {
+ callback.onSuccess(CALLBACKS_PER_CF);
+ }
+ }
+ } catch (Exception e) {
+ if (e instanceof CalculatedFieldException cfe) {
+ throw cfe;
+ }
+ throw CalculatedFieldException.builder().ctx(ctx).eventEntity(entityId).cause(e).build();
+ }
+ }
+
+ private void processTelemetry(CalculatedFieldCtx ctx, CalculatedFieldTelemetryMsgProto proto, List cfIdList, MultipleTbCallback callback) throws CalculatedFieldException {
+ processArgumentValuesUpdate(ctx, cfIdList, callback, mapToArguments(ctx, proto.getTsDataList()), toTbMsgId(proto), toTbMsgType(proto));
+ }
+
+ private void processAttributes(CalculatedFieldCtx ctx, CalculatedFieldTelemetryMsgProto proto, List cfIdList, MultipleTbCallback callback) throws CalculatedFieldException {
+ processArgumentValuesUpdate(ctx, cfIdList, callback, mapToArguments(ctx, proto.getScope(), proto.getAttrDataList()), toTbMsgId(proto), toTbMsgType(proto));
+ }
+
+ private void processRemovedTelemetry(CalculatedFieldCtx ctx, CalculatedFieldTelemetryMsgProto proto, List cfIdList, MultipleTbCallback callback) throws CalculatedFieldException {
+ processArgumentValuesUpdate(ctx, cfIdList, callback, mapToArgumentsWithFetchedValue(ctx, proto.getRemovedTsKeysList()), toTbMsgId(proto), toTbMsgType(proto));
+ }
+
+ private void processRemovedAttributes(CalculatedFieldCtx ctx, CalculatedFieldTelemetryMsgProto proto, List cfIdList, MultipleTbCallback callback) throws CalculatedFieldException {
+ processArgumentValuesUpdate(ctx, cfIdList, callback, mapToArgumentsWithDefaultValue(ctx, proto.getScope(), proto.getRemovedAttrKeysList()), toTbMsgId(proto), toTbMsgType(proto));
+ }
+
+ private void processArgumentValuesUpdate(CalculatedFieldCtx ctx, List cfIdList, MultipleTbCallback callback,
+ Map newArgValues, UUID tbMsgId, TbMsgType tbMsgType) throws CalculatedFieldException {
+ if (newArgValues.isEmpty()) {
+ log.info("[{}] No new argument values to process for CF.", ctx.getCfId());
+ callback.onSuccess(CALLBACKS_PER_CF);
+ }
+ CalculatedFieldState state = states.get(ctx.getCfId());
+ boolean justRestored = false;
+ if (state == null) {
+ state = getOrInitState(ctx);
+ justRestored = true;
+ }
+ if (state.isSizeOk()) {
+ if (state.updateState(ctx, newArgValues) || justRestored) {
+ cfIdList = new ArrayList<>(cfIdList);
+ cfIdList.add(ctx.getCfId());
+ processStateIfReady(ctx, cfIdList, state, tbMsgId, tbMsgType, callback);
+ } else {
+ callback.onSuccess(CALLBACKS_PER_CF);
+ }
+ } else {
+ throw CalculatedFieldException.builder().ctx(ctx).eventEntity(entityId).errorMessage(ctx.getSizeExceedsLimitMessage()).build();
+ }
+ }
+
+ @SneakyThrows
+ private CalculatedFieldState getOrInitState(CalculatedFieldCtx ctx) {
+ CalculatedFieldState state = states.get(ctx.getCfId());
+ if (state != null) {
+ return state;
+ } else {
+ ListenableFuture stateFuture = systemContext.getCalculatedFieldProcessingService().fetchStateFromDb(ctx, entityId);
+ // Ugly but necessary. We do not expect to often fetch data from DB. Only once per pair lifetime.
+ // This call happens while processing the CF pack from the queue consumer. So the timeout should be relatively low.
+ // Alternatively, we can fetch the state outside the actor system and push separate command to create this actor,
+ // but this will significantly complicate the code.
+ state = stateFuture.get(1, TimeUnit.MINUTES);
+ state.checkStateSize(new CalculatedFieldEntityCtxId(tenantId, ctx.getCfId(), entityId), ctx.getMaxStateSize());
+ states.put(ctx.getCfId(), state);
+ }
+ return state;
+ }
+
+ private void processStateIfReady(CalculatedFieldCtx ctx, List cfIdList, CalculatedFieldState state, UUID tbMsgId, TbMsgType tbMsgType, TbCallback callback) throws CalculatedFieldException {
+ CalculatedFieldEntityCtxId ctxId = new CalculatedFieldEntityCtxId(tenantId, ctx.getCfId(), entityId);
+ boolean stateSizeChecked = false;
+ try {
+ if (ctx.isInitialized() && state.isReady()) {
+ CalculatedFieldResult calculationResult = state.performCalculation(ctx).get(systemContext.getCfCalculationResultTimeout(), TimeUnit.SECONDS);
+ state.checkStateSize(ctxId, ctx.getMaxStateSize());
+ stateSizeChecked = true;
+ if (state.isSizeOk()) {
+ cfService.pushMsgToRuleEngine(tenantId, entityId, calculationResult, cfIdList, callback);
+ if (DebugModeUtil.isDebugAllAvailable(ctx.getCalculatedField())) {
+ systemContext.persistCalculatedFieldDebugEvent(tenantId, ctx.getCfId(), entityId, state.getArguments(), tbMsgId, tbMsgType, JacksonUtil.writeValueAsString(calculationResult.getResult()), null);
+ }
+ }
+ }
+ } catch (Exception e) {
+ throw CalculatedFieldException.builder().ctx(ctx).eventEntity(entityId).msgId(tbMsgId).msgType(tbMsgType).arguments(state.getArguments()).cause(e).build();
+ } finally {
+ if (!stateSizeChecked) {
+ state.checkStateSize(ctxId, ctx.getMaxStateSize());
+ }
+ if (state.isSizeOk()) {
+ cfStateService.persistState(ctxId, state, callback);
+ } else {
+ removeStateAndRaiseSizeException(ctxId, CalculatedFieldException.builder().ctx(ctx).eventEntity(entityId).errorMessage(ctx.getSizeExceedsLimitMessage()).build(), callback);
+ }
+ }
+ }
+
+ private void removeStateAndRaiseSizeException(CalculatedFieldEntityCtxId ctxId, CalculatedFieldException ex, TbCallback callback) throws CalculatedFieldException {
+ // We remove the state, but remember that it is over-sized in a local map.
+ cfStateService.removeState(ctxId, new TbCallback() {
+ @Override
+ public void onSuccess() {
+ callback.onFailure(ex);
+ }
+
+ @Override
+ public void onFailure(Throwable t) {
+ callback.onFailure(ex);
+ }
+ });
+ throw ex;
+ }
+
+ private Map mapToArguments(CalculatedFieldCtx ctx, List data) {
+ return mapToArguments(ctx.getMainEntityArguments(), data);
+ }
+
+ private Map mapToArguments(CalculatedFieldCtx ctx, EntityId entityId, List data) {
+ var argNames = ctx.getLinkedEntityArguments().get(entityId);
+ if (argNames.isEmpty()) {
+ return Collections.emptyMap();
+ }
+ return mapToArguments(argNames, data);
+ }
+
+ private Map mapToArguments(Map argNames, List data) {
+ if (argNames.isEmpty()) {
+ return Collections.emptyMap();
+ }
+ Map arguments = new HashMap<>();
+ for (TsKvProto item : data) {
+ ReferencedEntityKey key = new ReferencedEntityKey(item.getKv().getKey(), ArgumentType.TS_LATEST, null);
+ String argName = argNames.get(key);
+ if (argName != null) {
+ arguments.put(argName, new SingleValueArgumentEntry(item));
+ }
+ key = new ReferencedEntityKey(item.getKv().getKey(), ArgumentType.TS_ROLLING, null);
+ argName = argNames.get(key);
+ if (argName != null) {
+ arguments.put(argName, new SingleValueArgumentEntry(item));
+ }
+ }
+ return arguments;
+ }
+
+ private Map mapToArguments(CalculatedFieldCtx ctx, AttributeScopeProto scope, List attrDataList) {
+ return mapToArguments(ctx.getMainEntityArguments(), scope, attrDataList);
+ }
+
+ private Map mapToArguments(CalculatedFieldCtx ctx, EntityId entityId, AttributeScopeProto scope, List attrDataList) {
+ var argNames = ctx.getLinkedEntityArguments().get(entityId);
+ if (argNames.isEmpty()) {
+ return Collections.emptyMap();
+ }
+ return mapToArguments(argNames, scope, attrDataList);
+ }
+
+ private Map mapToArguments(Map argNames, AttributeScopeProto scope, List attrDataList) {
+ Map arguments = new HashMap<>();
+ for (AttributeValueProto item : attrDataList) {
+ ReferencedEntityKey key = new ReferencedEntityKey(item.getKey(), ArgumentType.ATTRIBUTE, AttributeScope.valueOf(scope.name()));
+ String argName = argNames.get(key);
+ if (argName != null) {
+ arguments.put(argName, new SingleValueArgumentEntry(item));
+ }
+ }
+ return arguments;
+ }
+
+ private Map mapToArgumentsWithDefaultValue(CalculatedFieldCtx ctx, EntityId entityId, AttributeScopeProto scope, List removedAttrKeys) {
+ var argNames = ctx.getLinkedEntityArguments().get(entityId);
+ if (argNames.isEmpty()) {
+ return Collections.emptyMap();
+ }
+ return mapToArgumentsWithDefaultValue(argNames, ctx.getArguments(), scope, removedAttrKeys);
+ }
+
+ private Map mapToArgumentsWithDefaultValue(CalculatedFieldCtx ctx, AttributeScopeProto scope, List removedAttrKeys) {
+ return mapToArgumentsWithDefaultValue(ctx.getMainEntityArguments(), ctx.getArguments(), scope, removedAttrKeys);
+ }
+
+ private Map mapToArgumentsWithDefaultValue(Map argNames, Map configArguments, AttributeScopeProto scope, List removedAttrKeys) {
+ Map arguments = new HashMap<>();
+ for (String removedKey : removedAttrKeys) {
+ ReferencedEntityKey key = new ReferencedEntityKey(removedKey, ArgumentType.ATTRIBUTE, AttributeScope.valueOf(scope.name()));
+ String argName = argNames.get(key);
+ if (argName != null) {
+ Argument argument = configArguments.get(argName);
+ String defaultValue = (argument != null) ? argument.getDefaultValue() : null;
+ arguments.put(argName, StringUtils.isNotEmpty(defaultValue)
+ ? new SingleValueArgumentEntry(System.currentTimeMillis(), new StringDataEntry(removedKey, defaultValue), null)
+ : new SingleValueArgumentEntry());
+
+ }
+ }
+ return arguments;
+ }
+
+ private Map mapToArgumentsWithFetchedValue(CalculatedFieldCtx ctx, List removedTelemetryKeys) {
+ Map deletedArguments = ctx.getArguments().entrySet().stream()
+ .filter(entry -> removedTelemetryKeys.contains(entry.getKey()))
+ .collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue));
+
+ Map fetchedArgs = cfService.fetchArgsFromDb(tenantId, entityId, deletedArguments);
+
+ fetchedArgs.values().forEach(arg -> arg.setForceResetPrevious(true));
+ return fetchedArgs;
+ }
+
+ private static List getCalculatedFieldIds(CalculatedFieldTelemetryMsgProto proto) {
+ List cfIds = new LinkedList<>();
+ for (var cfId : proto.getPreviousCalculatedFieldsList()) {
+ cfIds.add(new CalculatedFieldId(new UUID(cfId.getCalculatedFieldIdMSB(), cfId.getCalculatedFieldIdLSB())));
+ }
+ return cfIds;
+ }
+
+ private UUID toTbMsgId(CalculatedFieldTelemetryMsgProto proto) {
+ if (proto.getTbMsgIdMSB() != 0 && proto.getTbMsgIdLSB() != 0) {
+ return new UUID(proto.getTbMsgIdMSB(), proto.getTbMsgIdLSB());
+ }
+ return null;
+ }
+
+ private TbMsgType toTbMsgType(CalculatedFieldTelemetryMsgProto proto) {
+ if (!proto.getTbMsgType().isEmpty()) {
+ return TbMsgType.valueOf(proto.getTbMsgType());
+ }
+ return null;
+ }
+
+}
diff --git a/application/src/main/java/org/thingsboard/server/actors/calculatedField/CalculatedFieldException.java b/application/src/main/java/org/thingsboard/server/actors/calculatedField/CalculatedFieldException.java
new file mode 100644
index 0000000000..70c8dfbfd2
--- /dev/null
+++ b/application/src/main/java/org/thingsboard/server/actors/calculatedField/CalculatedFieldException.java
@@ -0,0 +1,40 @@
+/**
+ * Copyright © 2016-2025 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.actors.calculatedField;
+
+import lombok.Builder;
+import lombok.Getter;
+import org.thingsboard.server.common.data.id.EntityId;
+import org.thingsboard.server.common.data.msg.TbMsgType;
+import org.thingsboard.server.service.cf.ctx.state.ArgumentEntry;
+import org.thingsboard.server.service.cf.ctx.state.CalculatedFieldCtx;
+
+import java.util.Map;
+import java.util.UUID;
+
+@Getter
+@Builder
+public class CalculatedFieldException extends Exception {
+
+ private final CalculatedFieldCtx ctx;
+ private final EntityId eventEntity;
+ private final UUID msgId;
+ private final TbMsgType msgType;
+ private Map arguments;
+ private String errorMessage;
+ private Exception cause;
+
+}
diff --git a/application/src/main/java/org/thingsboard/server/actors/calculatedField/CalculatedFieldLinkedTelemetryMsg.java b/application/src/main/java/org/thingsboard/server/actors/calculatedField/CalculatedFieldLinkedTelemetryMsg.java
new file mode 100644
index 0000000000..3e0fba2627
--- /dev/null
+++ b/application/src/main/java/org/thingsboard/server/actors/calculatedField/CalculatedFieldLinkedTelemetryMsg.java
@@ -0,0 +1,40 @@
+/**
+ * Copyright © 2016-2025 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.actors.calculatedField;
+
+import lombok.Data;
+import org.thingsboard.server.common.data.id.EntityId;
+import org.thingsboard.server.common.data.id.TenantId;
+import org.thingsboard.server.common.msg.MsgType;
+import org.thingsboard.server.common.msg.ToCalculatedFieldSystemMsg;
+import org.thingsboard.server.common.msg.queue.TbCallback;
+import org.thingsboard.server.gen.transport.TransportProtos.CalculatedFieldLinkedTelemetryMsgProto;
+import org.thingsboard.server.gen.transport.TransportProtos.CalculatedFieldTelemetryMsgProto;
+
+@Data
+public class CalculatedFieldLinkedTelemetryMsg implements ToCalculatedFieldSystemMsg {
+
+ private final TenantId tenantId;
+ private final EntityId entityId;
+ private final CalculatedFieldLinkedTelemetryMsgProto proto;
+ private final TbCallback callback;
+
+
+ @Override
+ public MsgType getMsgType() {
+ return MsgType.CF_LINKED_TELEMETRY_MSG;
+ }
+}
diff --git a/application/src/main/java/org/thingsboard/server/actors/calculatedField/CalculatedFieldManagerActor.java b/application/src/main/java/org/thingsboard/server/actors/calculatedField/CalculatedFieldManagerActor.java
new file mode 100644
index 0000000000..a5c935e83f
--- /dev/null
+++ b/application/src/main/java/org/thingsboard/server/actors/calculatedField/CalculatedFieldManagerActor.java
@@ -0,0 +1,90 @@
+/**
+ * Copyright © 2016-2025 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.actors.calculatedField;
+
+import lombok.extern.slf4j.Slf4j;
+import org.thingsboard.server.actors.ActorSystemContext;
+import org.thingsboard.server.actors.TbActorCtx;
+import org.thingsboard.server.actors.TbActorException;
+import org.thingsboard.server.common.data.id.TenantId;
+import org.thingsboard.server.common.msg.ToCalculatedFieldSystemMsg;
+import org.thingsboard.server.common.msg.cf.CalculatedFieldEntityLifecycleMsg;
+import org.thingsboard.server.common.msg.cf.CalculatedFieldInitMsg;
+import org.thingsboard.server.common.msg.cf.CalculatedFieldLinkInitMsg;
+import org.thingsboard.server.common.msg.cf.CalculatedFieldPartitionChangeMsg;
+
+/**
+ * Created by ashvayka on 15.03.18.
+ */
+@Slf4j
+public class CalculatedFieldManagerActor extends AbstractCalculatedFieldActor {
+
+ private final CalculatedFieldManagerMessageProcessor processor;
+
+ public CalculatedFieldManagerActor(ActorSystemContext systemContext, TenantId tenantId) {
+ super(systemContext, tenantId);
+ this.processor = new CalculatedFieldManagerMessageProcessor(systemContext, tenantId);
+ }
+
+ @Override
+ public void init(TbActorCtx ctx) throws TbActorException {
+ super.init(ctx);
+ log.debug("[{}] Starting CF manager actor.", processor.tenantId);
+ try {
+ processor.init(ctx);
+ log.debug("[{}] CF manager actor started.", processor.tenantId);
+ } catch (Exception e) {
+ log.warn("[{}] Unknown failure", processor.tenantId, e);
+ throw new TbActorException("Failed to initialize manager actor", e);
+ }
+ }
+
+ @Override
+ protected boolean doProcessCfMsg(ToCalculatedFieldSystemMsg msg) throws CalculatedFieldException {
+ switch (msg.getMsgType()) {
+ case CF_PARTITIONS_CHANGE_MSG:
+ processor.onPartitionChange((CalculatedFieldPartitionChangeMsg) msg);
+ break;
+ case CF_INIT_MSG:
+ processor.onFieldInitMsg((CalculatedFieldInitMsg) msg);
+ break;
+ case CF_LINK_INIT_MSG:
+ processor.onLinkInitMsg((CalculatedFieldLinkInitMsg) msg);
+ break;
+ case CF_STATE_RESTORE_MSG:
+ processor.onStateRestoreMsg((CalculatedFieldStateRestoreMsg) msg);
+ break;
+ case CF_ENTITY_LIFECYCLE_MSG:
+ processor.onEntityLifecycleMsg((CalculatedFieldEntityLifecycleMsg) msg);
+ break;
+ case CF_TELEMETRY_MSG:
+ processor.onTelemetryMsg((CalculatedFieldTelemetryMsg) msg);
+ break;
+ case CF_LINKED_TELEMETRY_MSG:
+ processor.onLinkedTelemetryMsg((CalculatedFieldLinkedTelemetryMsg) msg);
+ break;
+ default:
+ return false;
+ }
+ return true;
+ }
+
+ @Override
+ void logProcessingException(Exception e) {
+ log.warn("[{}] Processing failure", tenantId, e);
+ }
+
+}
diff --git a/application/src/main/java/org/thingsboard/server/actors/calculatedField/CalculatedFieldManagerActorCreator.java b/application/src/main/java/org/thingsboard/server/actors/calculatedField/CalculatedFieldManagerActorCreator.java
new file mode 100644
index 0000000000..99bf3cdbe9
--- /dev/null
+++ b/application/src/main/java/org/thingsboard/server/actors/calculatedField/CalculatedFieldManagerActorCreator.java
@@ -0,0 +1,46 @@
+/**
+ * Copyright © 2016-2025 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.actors.calculatedField;
+
+import org.thingsboard.server.actors.ActorSystemContext;
+import org.thingsboard.server.actors.TbActor;
+import org.thingsboard.server.actors.TbActorId;
+import org.thingsboard.server.actors.TbEntityActorId;
+import org.thingsboard.server.actors.TbStringActorId;
+import org.thingsboard.server.actors.service.ContextBasedCreator;
+import org.thingsboard.server.common.data.id.EntityId;
+import org.thingsboard.server.common.data.id.TenantId;
+
+public class CalculatedFieldManagerActorCreator extends ContextBasedCreator {
+
+ private final TenantId tenantId;
+
+ public CalculatedFieldManagerActorCreator(ActorSystemContext context, TenantId tenantId) {
+ super(context);
+ this.tenantId = tenantId;
+ }
+
+ @Override
+ public TbActorId createActorId() {
+ return new TbStringActorId("CFM|" + tenantId);
+ }
+
+ @Override
+ public TbActor createActor() {
+ return new CalculatedFieldManagerActor(context, tenantId);
+ }
+
+}
diff --git a/application/src/main/java/org/thingsboard/server/actors/calculatedField/CalculatedFieldManagerMessageProcessor.java b/application/src/main/java/org/thingsboard/server/actors/calculatedField/CalculatedFieldManagerMessageProcessor.java
new file mode 100644
index 0000000000..1418821081
--- /dev/null
+++ b/application/src/main/java/org/thingsboard/server/actors/calculatedField/CalculatedFieldManagerMessageProcessor.java
@@ -0,0 +1,468 @@
+/**
+ * Copyright © 2016-2025 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.actors.calculatedField;
+
+import lombok.extern.slf4j.Slf4j;
+import org.thingsboard.server.actors.ActorSystemContext;
+import org.thingsboard.server.actors.TbActorCtx;
+import org.thingsboard.server.actors.TbActorRef;
+import org.thingsboard.server.actors.TbCalculatedFieldEntityActorId;
+import org.thingsboard.server.actors.service.DefaultActorService;
+import org.thingsboard.server.actors.shared.AbstractContextAwareMsgProcessor;
+import org.thingsboard.server.common.data.EntityType;
+import org.thingsboard.server.common.data.cf.CalculatedField;
+import org.thingsboard.server.common.data.cf.CalculatedFieldLink;
+import org.thingsboard.server.common.data.id.AssetId;
+import org.thingsboard.server.common.data.id.CalculatedFieldId;
+import org.thingsboard.server.common.data.id.DeviceId;
+import org.thingsboard.server.common.data.id.EntityId;
+import org.thingsboard.server.common.data.id.TenantId;
+import org.thingsboard.server.common.msg.cf.CalculatedFieldEntityLifecycleMsg;
+import org.thingsboard.server.common.msg.cf.CalculatedFieldInitMsg;
+import org.thingsboard.server.common.msg.cf.CalculatedFieldLinkInitMsg;
+import org.thingsboard.server.common.msg.cf.CalculatedFieldPartitionChangeMsg;
+import org.thingsboard.server.common.msg.plugin.ComponentLifecycleMsg;
+import org.thingsboard.server.common.msg.queue.TbCallback;
+import org.thingsboard.server.dao.cf.CalculatedFieldService;
+import org.thingsboard.server.service.cf.CalculatedFieldProcessingService;
+import org.thingsboard.server.service.cf.CalculatedFieldStateService;
+import org.thingsboard.server.service.cf.cache.CalculatedFieldEntityProfileCache;
+import org.thingsboard.server.service.cf.ctx.CalculatedFieldEntityCtxId;
+import org.thingsboard.server.service.cf.ctx.state.CalculatedFieldCtx;
+import org.thingsboard.server.service.profile.TbAssetProfileCache;
+import org.thingsboard.server.service.profile.TbDeviceProfileCache;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.CopyOnWriteArrayList;
+
+import static org.thingsboard.server.utils.CalculatedFieldUtils.fromProto;
+
+
+/**
+ * @author Andrew Shvayka
+ */
+@Slf4j
+public class CalculatedFieldManagerMessageProcessor extends AbstractContextAwareMsgProcessor {
+
+ private final Map calculatedFields = new HashMap<>();
+ private final Map> entityIdCalculatedFields = new HashMap<>();
+ private final Map> entityIdCalculatedFieldLinks = new HashMap<>();
+
+ private final CalculatedFieldProcessingService cfExecService;
+ private final CalculatedFieldStateService cfStateService;
+ private final CalculatedFieldEntityProfileCache cfEntityCache;
+ private final CalculatedFieldService cfDaoService;
+ private final TbAssetProfileCache assetProfileCache;
+ private final TbDeviceProfileCache deviceProfileCache;
+ protected final TenantId tenantId;
+
+ protected TbActorCtx ctx;
+
+ CalculatedFieldManagerMessageProcessor(ActorSystemContext systemContext, TenantId tenantId) {
+ super(systemContext);
+ this.cfEntityCache = systemContext.getCalculatedFieldEntityProfileCache();
+ this.cfExecService = systemContext.getCalculatedFieldProcessingService();
+ this.cfStateService = systemContext.getCalculatedFieldStateService();
+ this.cfDaoService = systemContext.getCalculatedFieldService();
+ this.assetProfileCache = systemContext.getAssetProfileCache();
+ this.deviceProfileCache = systemContext.getDeviceProfileCache();
+ this.tenantId = tenantId;
+ }
+
+ void init(TbActorCtx ctx) {
+ this.ctx = ctx;
+ }
+
+ public void onFieldInitMsg(CalculatedFieldInitMsg msg) throws CalculatedFieldException {
+ log.info("[{}] Processing CF init message.", msg.getCf().getId());
+ var cf = msg.getCf();
+ var cfCtx = new CalculatedFieldCtx(cf, systemContext.getTbelInvokeService(), systemContext.getApiLimitService());
+ try {
+ cfCtx.init();
+ } catch (Exception e) {
+ throw CalculatedFieldException.builder().ctx(cfCtx).eventEntity(cf.getEntityId()).cause(e).errorMessage("Failed to initialize CF context").build();
+ }
+ calculatedFields.put(cf.getId(), cfCtx);
+ // We use copy on write lists to safely pass the reference to another actor for the iteration.
+ // Alternative approach would be to use any list but avoid modifications to the list (change the complete map value instead)
+ entityIdCalculatedFields.computeIfAbsent(cf.getEntityId(), id -> new CopyOnWriteArrayList<>()).add(cfCtx);
+ msg.getCallback().onSuccess();
+ }
+
+ public void onLinkInitMsg(CalculatedFieldLinkInitMsg msg) {
+ log.info("[{}] Processing CF link init message for entity [{}].", msg.getLink().getCalculatedFieldId(), msg.getLink().getEntityId());
+ var link = msg.getLink();
+ // We use copy on write lists to safely pass the reference to another actor for the iteration.
+ // Alternative approach would be to use any list but avoid modifications to the list (change the complete map value instead)
+ entityIdCalculatedFieldLinks.computeIfAbsent(link.getEntityId(), id -> new CopyOnWriteArrayList<>()).add(link);
+ msg.getCallback().onSuccess();
+ }
+
+ public void onStateRestoreMsg(CalculatedFieldStateRestoreMsg msg) {
+ var cfId = msg.getId().cfId();
+ var calculatedField = calculatedFields.get(cfId);
+
+ if (calculatedField != null) {
+ msg.getState().setRequiredArguments(calculatedField.getArgNames());
+ log.info("Pushing CF state restore msg to specific actor [{}]", msg.getId().entityId());
+ getOrCreateActor(msg.getId().entityId()).tell(msg);
+ } else {
+ cfStateService.removeState(msg.getId(), msg.getCallback());
+ }
+ }
+
+ public void onEntityLifecycleMsg(CalculatedFieldEntityLifecycleMsg msg) throws CalculatedFieldException {
+ log.info("Processing entity lifecycle event: [{}] for entity: [{}]", msg.getData().getEvent(), msg.getData().getEntityId());
+ var entityType = msg.getData().getEntityId().getEntityType();
+ var event = msg.getData().getEvent();
+ switch (entityType) {
+ case CALCULATED_FIELD: {
+ switch (event) {
+ case CREATED:
+ onCfCreated(msg.getData(), msg.getCallback());
+ break;
+ case UPDATED:
+ onCfUpdated(msg.getData(), msg.getCallback());
+ break;
+ case DELETED:
+ onCfDeleted(msg.getData(), msg.getCallback());
+ break;
+ default:
+ msg.getCallback().onSuccess();
+ break;
+ }
+ break;
+ }
+ case DEVICE:
+ case ASSET: {
+ switch (event) {
+ case CREATED:
+ onEntityCreated(msg.getData(), msg.getCallback());
+ break;
+ case UPDATED:
+ onEntityUpdated(msg.getData(), msg.getCallback());
+ break;
+ case DELETED:
+ onEntityDeleted(msg.getData(), msg.getCallback());
+ break;
+ default:
+ msg.getCallback().onSuccess();
+ break;
+ }
+ break;
+ }
+ default: {
+ msg.getCallback().onSuccess();
+ }
+ }
+ }
+
+ private void onEntityCreated(ComponentLifecycleMsg msg, TbCallback callback) {
+ EntityId entityId = msg.getEntityId();
+ EntityId profileId = getProfileId(tenantId, entityId);
+ cfEntityCache.add(tenantId, profileId, entityId);
+ var entityIdFields = getCalculatedFieldsByEntityId(entityId);
+ var profileIdFields = getCalculatedFieldsByEntityId(profileId);
+ var fieldsCount = entityIdFields.size() + profileIdFields.size();
+ if (fieldsCount > 0) {
+ MultipleTbCallback multiCallback = new MultipleTbCallback(fieldsCount, callback);
+ entityIdFields.forEach(ctx -> initCfForEntity(entityId, ctx, true, multiCallback));
+ profileIdFields.forEach(ctx -> initCfForEntity(entityId, ctx, true, multiCallback));
+ } else {
+ callback.onSuccess();
+ }
+ }
+
+ private void onEntityUpdated(ComponentLifecycleMsg msg, TbCallback callback) {
+ if (msg.getOldProfileId() != null && msg.getOldProfileId() != msg.getProfileId()) {
+ cfEntityCache.update(tenantId, msg.getOldProfileId(), msg.getProfileId(), msg.getEntityId());
+ var oldProfileCfs = getCalculatedFieldsByEntityId(msg.getOldProfileId());
+ var newProfileCfs = getCalculatedFieldsByEntityId(msg.getProfileId());
+ var fieldsCount = oldProfileCfs.size() + newProfileCfs.size();
+ if (fieldsCount > 0) {
+ MultipleTbCallback multiCallback = new MultipleTbCallback(fieldsCount, callback);
+ var entityId = msg.getEntityId();
+ oldProfileCfs.forEach(ctx -> deleteCfForEntity(entityId, ctx.getCfId(), multiCallback));
+ newProfileCfs.forEach(ctx -> initCfForEntity(entityId, ctx, true, multiCallback));
+ } else {
+ callback.onSuccess();
+ }
+ }
+ }
+
+ private void onEntityDeleted(ComponentLifecycleMsg msg, TbCallback callback) {
+ cfEntityCache.evict(tenantId, msg.getEntityId());
+ log.info("Pushing entity lifecycle msg to specific actor [{}]", msg.getEntityId());
+ getOrCreateActor(msg.getEntityId()).tell(new CalculatedFieldEntityDeleteMsg(tenantId, msg.getEntityId(), callback));
+ }
+
+ private void onCfCreated(ComponentLifecycleMsg msg, TbCallback callback) throws CalculatedFieldException {
+ var cfId = new CalculatedFieldId(msg.getEntityId().getId());
+ if (calculatedFields.containsKey(cfId)) {
+ log.warn("[{}] CF was already initialized [{}]", tenantId, cfId);
+ callback.onSuccess();
+ } else {
+ var cf = cfDaoService.findById(msg.getTenantId(), cfId);
+ if (cf == null) {
+ log.warn("[{}] Failed to lookup CF by id [{}]", tenantId, cfId);
+ callback.onSuccess();
+ } else {
+ var cfCtx = new CalculatedFieldCtx(cf, systemContext.getTbelInvokeService(), systemContext.getApiLimitService());
+ try {
+ cfCtx.init();
+ } catch (Exception e) {
+ throw CalculatedFieldException.builder().ctx(cfCtx).eventEntity(cf.getEntityId()).cause(e).errorMessage("Failed to initialize CF context").build();
+ }
+ calculatedFields.put(cf.getId(), cfCtx);
+ // We use copy on write lists to safely pass the reference to another actor for the iteration.
+ // Alternative approach would be to use any list but avoid modifications to the list (change the complete map value instead)
+ entityIdCalculatedFields.computeIfAbsent(cf.getEntityId(), id -> new CopyOnWriteArrayList<>()).add(cfCtx);
+ addLinks(cf);
+ initCf(cfCtx, callback, false);
+ }
+ }
+ }
+
+ private void onCfUpdated(ComponentLifecycleMsg msg, TbCallback callback) throws CalculatedFieldException {
+ var cfId = new CalculatedFieldId(msg.getEntityId().getId());
+ var oldCfCtx = calculatedFields.get(cfId);
+ if (oldCfCtx == null) {
+ onCfCreated(msg, callback);
+ } else {
+ var newCf = cfDaoService.findById(msg.getTenantId(), cfId);
+ if (newCf == null) {
+ log.warn("[{}] Failed to lookup CF by id [{}]", tenantId, cfId);
+ callback.onSuccess();
+ } else {
+ var newCfCtx = new CalculatedFieldCtx(newCf, systemContext.getTbelInvokeService(), systemContext.getApiLimitService());
+ try {
+ newCfCtx.init();
+ } catch (Exception e) {
+ throw CalculatedFieldException.builder().ctx(newCfCtx).eventEntity(newCfCtx.getEntityId()).cause(e).errorMessage("Failed to initialize CF context").build();
+ }
+ calculatedFields.put(newCf.getId(), newCfCtx);
+ List oldCfList = entityIdCalculatedFields.get(newCf.getEntityId());
+ List newCfList = new CopyOnWriteArrayList<>();
+ boolean found = false;
+ for (CalculatedFieldCtx oldCtx : oldCfList) {
+ if (oldCtx.getCfId().equals(newCf.getId())) {
+ newCfList.add(newCfCtx);
+ found = true;
+ } else {
+ newCfList.add(oldCtx);
+ }
+ }
+ if (!found) {
+ newCfList.add(newCfCtx);
+ }
+ entityIdCalculatedFields.put(newCf.getEntityId(), newCfList);
+
+ deleteLinks(oldCfCtx);
+ addLinks(newCf);
+
+ // We use copy on write lists to safely pass the reference to another actor for the iteration.
+ // Alternative approach would be to use any list but avoid modifications to the list (change the complete map value instead)
+ var stateChanges = newCfCtx.hasStateChanges(oldCfCtx);
+ if (stateChanges || newCfCtx.hasOtherSignificantChanges(oldCfCtx)) {
+ initCf(newCfCtx, callback, stateChanges);
+ } else {
+ callback.onSuccess();
+ }
+ }
+ }
+ }
+
+ private void onCfDeleted(ComponentLifecycleMsg msg, TbCallback callback) {
+ var cfId = new CalculatedFieldId(msg.getEntityId().getId());
+ var cfCtx = calculatedFields.remove(cfId);
+ if (cfCtx == null) {
+ log.warn("[{}] CF was already deleted [{}]", tenantId, cfId);
+ callback.onSuccess();
+ } else {
+ entityIdCalculatedFields.get(cfCtx.getEntityId()).remove(cfCtx);
+ deleteLinks(cfCtx);
+
+ EntityId entityId = cfCtx.getEntityId();
+ EntityType entityType = cfCtx.getEntityId().getEntityType();
+ if (isProfileEntity(entityType)) {
+ var entityIds = cfEntityCache.getMyEntityIdsByProfileId(tenantId, entityId);
+ if (!entityIds.isEmpty()) {
+ //TODO: no need to do this if we cache all created actors and know which one belong to us;
+ var multiCallback = new MultipleTbCallback(entityIds.size(), callback);
+ entityIds.forEach(id -> deleteCfForEntity(id, cfId, multiCallback));
+ } else {
+ callback.onSuccess();
+ }
+ } else {
+ deleteCfForEntity(entityId, cfId, callback);
+ }
+ }
+ }
+
+ public void onTelemetryMsg(CalculatedFieldTelemetryMsg msg) {
+ EntityId entityId = msg.getEntityId();
+ log.info("Received telemetry msg from entity [{}]", entityId);
+ // 2 = 1 for CF processing + 1 for links processing
+ MultipleTbCallback callback = new MultipleTbCallback(2, msg.getCallback());
+ // process all cfs related to entity, or it's profile;
+ var entityIdFields = getCalculatedFieldsByEntityId(entityId);
+ var profileIdFields = getCalculatedFieldsByEntityId(getProfileId(tenantId, entityId));
+ if (!entityIdFields.isEmpty() || !profileIdFields.isEmpty()) {
+ log.info("Pushing telemetry msg to specific actor [{}]", entityId);
+ getOrCreateActor(entityId).tell(new EntityCalculatedFieldTelemetryMsg(msg, entityIdFields, profileIdFields, callback));
+ } else {
+ callback.onSuccess();
+ }
+ // process all links (if any);
+ List linkedCalculatedFields = filterCalculatedFieldLinks(msg);
+ var linksSize = linkedCalculatedFields.size();
+ if (linksSize > 0) {
+ cfExecService.pushMsgToLinks(msg, linkedCalculatedFields, callback);
+ } else {
+ callback.onSuccess();
+ }
+ }
+
+ public void onLinkedTelemetryMsg(CalculatedFieldLinkedTelemetryMsg msg) {
+ EntityId sourceEntityId = msg.getEntityId();
+ log.info("Received linked telemetry msg from entity [{}]", sourceEntityId);
+ var proto = msg.getProto();
+ var linksList = proto.getLinksList();
+ for (var linkProto : linksList) {
+ var link = fromProto(linkProto);
+ var targetEntityId = link.entityId();
+ var targetEntityType = targetEntityId.getEntityType();
+ var cf = calculatedFields.get(link.cfId());
+ if (EntityType.DEVICE_PROFILE.equals(targetEntityType) || EntityType.ASSET_PROFILE.equals(targetEntityType)) {
+ // iterate over all entities that belong to profile and push the message for corresponding CF
+ var entityIds = cfEntityCache.getMyEntityIdsByProfileId(tenantId, targetEntityId);
+ if (!entityIds.isEmpty()) {
+ MultipleTbCallback callback = new MultipleTbCallback(entityIds.size(), msg.getCallback());
+ var newMsg = new EntityCalculatedFieldLinkedTelemetryMsg(tenantId, sourceEntityId, proto.getMsg(), cf, callback);
+ entityIds.forEach(entityId -> {
+ log.info("Pushing linked telemetry msg to specific actor [{}]", entityId);
+ getOrCreateActor(entityId).tell(newMsg);
+ });
+ } else {
+ msg.getCallback().onSuccess();
+ }
+ } else {
+ log.info("Pushing linked telemetry msg to specific actor [{}]", targetEntityId);
+ var newMsg = new EntityCalculatedFieldLinkedTelemetryMsg(tenantId, sourceEntityId, proto.getMsg(), cf, msg.getCallback());
+ getOrCreateActor(targetEntityId).tell(newMsg);
+ }
+ }
+ }
+
+ private List filterCalculatedFieldLinks(CalculatedFieldTelemetryMsg msg) {
+ EntityId entityId = msg.getEntityId();
+ var proto = msg.getProto();
+ List result = new ArrayList<>();
+ for (var link : getCalculatedFieldLinksByEntityId(entityId)) {
+ CalculatedFieldCtx ctx = calculatedFields.get(link.getCalculatedFieldId());
+ if (ctx.linkMatches(entityId, proto)) {
+ result.add(ctx.toCalculatedFieldEntityCtxId());
+ }
+ }
+ return result;
+ }
+
+ private List getCalculatedFieldsByEntityId(EntityId entityId) {
+ if (entityId == null) {
+ return Collections.emptyList();
+ }
+ var result = entityIdCalculatedFields.get(entityId);
+ if (result == null) {
+ result = Collections.emptyList();
+ }
+ return result;
+ }
+
+ private List getCalculatedFieldLinksByEntityId(EntityId entityId) {
+ if (entityId == null) {
+ return Collections.emptyList();
+ }
+ var result = entityIdCalculatedFieldLinks.get(entityId);
+ if (result == null) {
+ result = Collections.emptyList();
+ }
+ return result;
+ }
+
+ private void initCf(CalculatedFieldCtx cfCtx, TbCallback callback, boolean forceStateReinit) {
+ EntityId entityId = cfCtx.getEntityId();
+ EntityType entityType = cfCtx.getEntityId().getEntityType();
+ if (isProfileEntity(entityType)) {
+ var entityIds = cfEntityCache.getMyEntityIdsByProfileId(tenantId, entityId);
+ if (!entityIds.isEmpty()) {
+ var multiCallback = new MultipleTbCallback(entityIds.size(), callback);
+ entityIds.forEach(id -> initCfForEntity(id, cfCtx, forceStateReinit, multiCallback));
+ } else {
+ callback.onSuccess();
+ }
+ } else {
+ initCfForEntity(entityId, cfCtx, forceStateReinit, callback);
+ }
+ }
+
+ private void deleteCfForEntity(EntityId entityId, CalculatedFieldId cfId, TbCallback callback) {
+ log.info("Pushing delete CF msg to specific actor [{}]", entityId);
+ getOrCreateActor(entityId).tell(new CalculatedFieldEntityDeleteMsg(tenantId, cfId, callback));
+ }
+
+ private void initCfForEntity(EntityId entityId, CalculatedFieldCtx cfCtx, boolean forceStateReinit, TbCallback callback) {
+ log.info("Pushing entity init CF msg to specific actor [{}]", entityId);
+ getOrCreateActor(entityId).tell(new EntityInitCalculatedFieldMsg(tenantId, cfCtx, callback, forceStateReinit));
+ }
+
+ private static boolean isProfileEntity(EntityType entityType) {
+ return EntityType.DEVICE_PROFILE.equals(entityType) || EntityType.ASSET_PROFILE.equals(entityType);
+ }
+
+ private EntityId getProfileId(TenantId tenantId, EntityId entityId) {
+ return switch (entityId.getEntityType()) {
+ case ASSET -> assetProfileCache.get(tenantId, (AssetId) entityId).getId();
+ case DEVICE -> deviceProfileCache.get(tenantId, (DeviceId) entityId).getId();
+ default -> null;
+ };
+ }
+
+ private TbActorRef getOrCreateActor(EntityId entityId) {
+ return ctx.getOrCreateChildActor(new TbCalculatedFieldEntityActorId(entityId),
+ () -> DefaultActorService.CF_ENTITY_DISPATCHER_NAME,
+ () -> new CalculatedFieldEntityActorCreator(systemContext, tenantId, entityId),
+ () -> true);
+ }
+
+ private void addLinks(CalculatedField newCf) {
+ var newLinks = newCf.getConfiguration().buildCalculatedFieldLinks(tenantId, newCf.getEntityId(), newCf.getId());
+ newLinks.forEach(link -> entityIdCalculatedFieldLinks.computeIfAbsent(link.getEntityId(), id -> new CopyOnWriteArrayList<>()).add(link));
+ }
+
+ private void deleteLinks(CalculatedFieldCtx cfCtx) {
+ var oldCf = cfCtx.getCalculatedField();
+ var oldLinks = oldCf.getConfiguration().buildCalculatedFieldLinks(tenantId, oldCf.getEntityId(), oldCf.getId());
+ oldLinks.forEach(link -> entityIdCalculatedFieldLinks.computeIfAbsent(link.getEntityId(), id -> new CopyOnWriteArrayList<>()).remove(link));
+ }
+
+ public void onPartitionChange(CalculatedFieldPartitionChangeMsg msg) {
+ ctx.broadcastToChildren(msg, true);
+ }
+}
diff --git a/application/src/main/java/org/thingsboard/server/actors/calculatedField/CalculatedFieldStateRestoreMsg.java b/application/src/main/java/org/thingsboard/server/actors/calculatedField/CalculatedFieldStateRestoreMsg.java
new file mode 100644
index 0000000000..19be7c02fa
--- /dev/null
+++ b/application/src/main/java/org/thingsboard/server/actors/calculatedField/CalculatedFieldStateRestoreMsg.java
@@ -0,0 +1,40 @@
+/**
+ * Copyright © 2016-2025 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.actors.calculatedField;
+
+import lombok.Data;
+import org.thingsboard.server.common.data.id.TenantId;
+import org.thingsboard.server.common.msg.MsgType;
+import org.thingsboard.server.common.msg.ToCalculatedFieldSystemMsg;
+import org.thingsboard.server.service.cf.ctx.CalculatedFieldEntityCtxId;
+import org.thingsboard.server.service.cf.ctx.state.CalculatedFieldState;
+
+@Data
+public class CalculatedFieldStateRestoreMsg implements ToCalculatedFieldSystemMsg {
+
+ private final CalculatedFieldEntityCtxId id;
+ private final CalculatedFieldState state;
+
+ @Override
+ public MsgType getMsgType() {
+ return MsgType.CF_STATE_RESTORE_MSG;
+ }
+
+ @Override
+ public TenantId getTenantId() {
+ return id.tenantId();
+ }
+}
diff --git a/application/src/main/java/org/thingsboard/server/actors/calculatedField/CalculatedFieldTelemetryMsg.java b/application/src/main/java/org/thingsboard/server/actors/calculatedField/CalculatedFieldTelemetryMsg.java
new file mode 100644
index 0000000000..68cd149cdf
--- /dev/null
+++ b/application/src/main/java/org/thingsboard/server/actors/calculatedField/CalculatedFieldTelemetryMsg.java
@@ -0,0 +1,39 @@
+/**
+ * Copyright © 2016-2025 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.actors.calculatedField;
+
+import lombok.Data;
+import org.thingsboard.server.common.data.id.EntityId;
+import org.thingsboard.server.common.data.id.TenantId;
+import org.thingsboard.server.common.msg.MsgType;
+import org.thingsboard.server.common.msg.ToCalculatedFieldSystemMsg;
+import org.thingsboard.server.common.msg.queue.TbCallback;
+import org.thingsboard.server.gen.transport.TransportProtos.CalculatedFieldTelemetryMsgProto;
+
+@Data
+public class CalculatedFieldTelemetryMsg implements ToCalculatedFieldSystemMsg {
+
+ private final TenantId tenantId;
+ private final EntityId entityId;
+ private final CalculatedFieldTelemetryMsgProto proto;
+ private final TbCallback callback;
+
+
+ @Override
+ public MsgType getMsgType() {
+ return MsgType.CF_TELEMETRY_MSG;
+ }
+}
diff --git a/application/src/main/java/org/thingsboard/server/actors/calculatedField/EntityCalculatedFieldLinkedTelemetryMsg.java b/application/src/main/java/org/thingsboard/server/actors/calculatedField/EntityCalculatedFieldLinkedTelemetryMsg.java
new file mode 100644
index 0000000000..b83aeae416
--- /dev/null
+++ b/application/src/main/java/org/thingsboard/server/actors/calculatedField/EntityCalculatedFieldLinkedTelemetryMsg.java
@@ -0,0 +1,42 @@
+/**
+ * Copyright © 2016-2025 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.actors.calculatedField;
+
+import lombok.Data;
+import org.thingsboard.server.common.data.id.EntityId;
+import org.thingsboard.server.common.data.id.TenantId;
+import org.thingsboard.server.common.msg.MsgType;
+import org.thingsboard.server.common.msg.ToCalculatedFieldSystemMsg;
+import org.thingsboard.server.common.msg.queue.TbCallback;
+import org.thingsboard.server.gen.transport.TransportProtos.CalculatedFieldTelemetryMsgProto;
+import org.thingsboard.server.service.cf.ctx.state.CalculatedFieldCtx;
+
+import java.util.List;
+
+@Data
+public class EntityCalculatedFieldLinkedTelemetryMsg implements ToCalculatedFieldSystemMsg {
+
+ private final TenantId tenantId;
+ private final EntityId entityId;
+ private final CalculatedFieldTelemetryMsgProto proto;
+ private final CalculatedFieldCtx ctx;
+ private final TbCallback callback;
+
+ @Override
+ public MsgType getMsgType() {
+ return MsgType.CF_LINKED_TELEMETRY_MSG;
+ }
+}
diff --git a/application/src/main/java/org/thingsboard/server/actors/calculatedField/EntityCalculatedFieldTelemetryMsg.java b/application/src/main/java/org/thingsboard/server/actors/calculatedField/EntityCalculatedFieldTelemetryMsg.java
new file mode 100644
index 0000000000..8ded4b6028
--- /dev/null
+++ b/application/src/main/java/org/thingsboard/server/actors/calculatedField/EntityCalculatedFieldTelemetryMsg.java
@@ -0,0 +1,56 @@
+/**
+ * Copyright © 2016-2025 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.actors.calculatedField;
+
+import lombok.Data;
+import org.thingsboard.server.common.data.id.EntityId;
+import org.thingsboard.server.common.data.id.TenantId;
+import org.thingsboard.server.common.msg.MsgType;
+import org.thingsboard.server.common.msg.ToCalculatedFieldSystemMsg;
+import org.thingsboard.server.common.msg.queue.TbCallback;
+import org.thingsboard.server.gen.transport.TransportProtos.CalculatedFieldTelemetryMsgProto;
+import org.thingsboard.server.service.cf.ctx.state.CalculatedFieldCtx;
+
+import java.util.List;
+
+@Data
+public class EntityCalculatedFieldTelemetryMsg implements ToCalculatedFieldSystemMsg {
+
+ private final TenantId tenantId;
+ private final EntityId entityId;
+ private final CalculatedFieldTelemetryMsgProto proto;
+ // Both lists are effectively immutable in CalculatedFieldManagerMessageProcessor and must stay so.
+ private final List entityIdFields;
+ private final List profileIdFields;
+ private final TbCallback callback;
+
+ public EntityCalculatedFieldTelemetryMsg(CalculatedFieldTelemetryMsg msg,
+ List entityIdFields,
+ List profileIdFields,
+ TbCallback callback) {
+ this.tenantId = msg.getTenantId();
+ this.entityId = msg.getEntityId();
+ this.proto = msg.getProto();
+ this.entityIdFields = entityIdFields;
+ this.profileIdFields = profileIdFields;
+ this.callback = callback;
+ }
+
+ @Override
+ public MsgType getMsgType() {
+ return MsgType.CF_ENTITY_TELEMETRY_MSG;
+ }
+}
diff --git a/application/src/main/java/org/thingsboard/server/actors/calculatedField/EntityInitCalculatedFieldMsg.java b/application/src/main/java/org/thingsboard/server/actors/calculatedField/EntityInitCalculatedFieldMsg.java
new file mode 100644
index 0000000000..1e8990ff8d
--- /dev/null
+++ b/application/src/main/java/org/thingsboard/server/actors/calculatedField/EntityInitCalculatedFieldMsg.java
@@ -0,0 +1,41 @@
+/**
+ * Copyright © 2016-2025 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.actors.calculatedField;
+
+import lombok.Data;
+import org.thingsboard.server.common.data.id.EntityId;
+import org.thingsboard.server.common.data.id.TenantId;
+import org.thingsboard.server.common.msg.MsgType;
+import org.thingsboard.server.common.msg.ToCalculatedFieldSystemMsg;
+import org.thingsboard.server.common.msg.queue.TbCallback;
+import org.thingsboard.server.gen.transport.TransportProtos.CalculatedFieldTelemetryMsgProto;
+import org.thingsboard.server.service.cf.ctx.state.CalculatedFieldCtx;
+
+import java.util.List;
+
+@Data
+public class EntityInitCalculatedFieldMsg implements ToCalculatedFieldSystemMsg {
+
+ private final TenantId tenantId;
+ private final CalculatedFieldCtx ctx;
+ private final TbCallback callback;
+ private final boolean forceReinit;
+
+ @Override
+ public MsgType getMsgType() {
+ return MsgType.CF_ENTITY_INIT_CF_MSG;
+ }
+}
diff --git a/application/src/main/java/org/thingsboard/server/actors/calculatedField/MultipleTbCallback.java b/application/src/main/java/org/thingsboard/server/actors/calculatedField/MultipleTbCallback.java
new file mode 100644
index 0000000000..d1f4c9092e
--- /dev/null
+++ b/application/src/main/java/org/thingsboard/server/actors/calculatedField/MultipleTbCallback.java
@@ -0,0 +1,56 @@
+/**
+ * Copyright © 2016-2025 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.actors.calculatedField;
+
+import lombok.Getter;
+import lombok.extern.slf4j.Slf4j;
+import org.thingsboard.server.common.msg.queue.TbCallback;
+
+import java.util.UUID;
+import java.util.concurrent.atomic.AtomicInteger;
+
+@Slf4j
+public class MultipleTbCallback implements TbCallback {
+ @Getter
+ private final UUID id;
+ private final AtomicInteger counter;
+ private final TbCallback callback;
+
+ public MultipleTbCallback(int count, TbCallback callback) {
+ id = UUID.randomUUID();
+ this.counter = new AtomicInteger(count);
+ this.callback = callback;
+ }
+
+ @Override
+ public void onSuccess() {
+ onSuccess(1);
+ }
+
+ public void onSuccess(int number) {
+ log.trace("[{}][{}] onSuccess({})", id, callback.getId(), number);
+ if (counter.addAndGet(-number) <= 0) {
+ log.trace("[{}][{}] Done.", id, callback.getId());
+ callback.onSuccess();
+ }
+ }
+
+ @Override
+ public void onFailure(Throwable t) {
+ log.warn("[{}][{}] onFailure.", id, callback.getId());
+ callback.onFailure(t);
+ }
+}
diff --git a/application/src/main/java/org/thingsboard/server/actors/ruleChain/DefaultTbContext.java b/application/src/main/java/org/thingsboard/server/actors/ruleChain/DefaultTbContext.java
index cd8521027a..ee6758df6a 100644
--- a/application/src/main/java/org/thingsboard/server/actors/ruleChain/DefaultTbContext.java
+++ b/application/src/main/java/org/thingsboard/server/actors/ruleChain/DefaultTbContext.java
@@ -28,8 +28,9 @@ import org.thingsboard.rule.engine.api.NotificationCenter;
import org.thingsboard.rule.engine.api.RuleEngineAlarmService;
import org.thingsboard.rule.engine.api.RuleEngineApiUsageStateService;
import org.thingsboard.rule.engine.api.RuleEngineAssetProfileCache;
+import org.thingsboard.rule.engine.api.RuleEngineCalculatedFieldQueueService;
import org.thingsboard.rule.engine.api.RuleEngineDeviceProfileCache;
-import org.thingsboard.rule.engine.api.RuleEngineDeviceStateManager;
+import org.thingsboard.rule.engine.api.DeviceStateManager;
import org.thingsboard.rule.engine.api.RuleEngineRpcService;
import org.thingsboard.rule.engine.api.RuleEngineTelemetryService;
import org.thingsboard.rule.engine.api.ScriptEngine;
@@ -79,6 +80,7 @@ import org.thingsboard.server.dao.asset.AssetService;
import org.thingsboard.server.dao.attributes.AttributesService;
import org.thingsboard.server.dao.audit.AuditLogService;
import org.thingsboard.server.dao.cassandra.CassandraCluster;
+import org.thingsboard.server.dao.cf.CalculatedFieldService;
import org.thingsboard.server.dao.customer.CustomerService;
import org.thingsboard.server.dao.dashboard.DashboardService;
import org.thingsboard.server.dao.device.DeviceCredentialsService;
@@ -124,7 +126,6 @@ import java.util.concurrent.TimeUnit;
import java.util.function.BiConsumer;
import java.util.function.Consumer;
-import static org.thingsboard.server.common.data.DataConstants.MAIN_QUEUE_NAME;
import static org.thingsboard.server.common.data.msg.TbMsgType.ATTRIBUTES_DELETED;
import static org.thingsboard.server.common.data.msg.TbMsgType.ATTRIBUTES_UPDATED;
import static org.thingsboard.server.common.data.msg.TbMsgType.ENTITY_CREATED;
@@ -196,7 +197,7 @@ public class DefaultTbContext implements TbContext {
@Override
public void enqueue(TbMsg tbMsg, Runnable onSuccess, Consumer onFailure) {
- enqueue(tbMsg, MAIN_QUEUE_NAME, onSuccess, onFailure);
+ enqueue(tbMsg, tbMsg.getQueueName(), onSuccess, onFailure);
}
@Override
@@ -725,7 +726,7 @@ public class DefaultTbContext implements TbContext {
}
@Override
- public RuleEngineDeviceStateManager getDeviceStateManager() {
+ public DeviceStateManager getDeviceStateManager() {
return mainCtx.getDeviceStateManager();
}
@@ -897,6 +898,16 @@ public class DefaultTbContext implements TbContext {
return mainCtx.getSlackService();
}
+ @Override
+ public CalculatedFieldService getCalculatedFieldService() {
+ return mainCtx.getCalculatedFieldService();
+ }
+
+ @Override
+ public RuleEngineCalculatedFieldQueueService getCalculatedFieldQueueService() {
+ return mainCtx.getCalculatedFieldQueueService();
+ }
+
@Override
public boolean isExternalNodeForceAck() {
return mainCtx.isExternalNodeForceAck();
diff --git a/application/src/main/java/org/thingsboard/server/actors/service/DefaultActorService.java b/application/src/main/java/org/thingsboard/server/actors/service/DefaultActorService.java
index 6c8f253138..c2131d9e74 100644
--- a/application/src/main/java/org/thingsboard/server/actors/service/DefaultActorService.java
+++ b/application/src/main/java/org/thingsboard/server/actors/service/DefaultActorService.java
@@ -49,6 +49,8 @@ public class DefaultActorService extends TbApplicationEventListener deletedDevices;
+ private TbActorRef cfActor;
private TenantActor(ActorSystemContext systemContext, TenantId tenantId) {
super(systemContext, tenantId);
@@ -95,6 +98,11 @@ public class TenantActor extends RuleChainManagerActor {
} else {
log.info("[{}] Skip init of the rule chains due to API limits", tenantId);
}
+ //TODO: IM - extend API usage to have CF Exec Enabled? Not in 4.0;
+ cfActor = ctx.getOrCreateChildActor(new TbStringActorId("CFM|" + tenantId),
+ () -> DefaultActorService.CF_MANAGER_DISPATCHER_NAME,
+ () -> new CalculatedFieldManagerActorCreator(systemContext, tenantId),
+ () -> true);
} catch (Exception e) {
log.info("Failed to check ApiUsage \"ReExecEnabled\"!!!", e);
cantFindTenant = true;
@@ -159,12 +167,31 @@ public class TenantActor extends RuleChainManagerActor {
case RULE_CHAIN_TO_RULE_CHAIN_MSG:
onRuleChainMsg((RuleChainAwareMsg) msg);
break;
+ case CF_INIT_MSG:
+ case CF_LINK_INIT_MSG:
+ case CF_STATE_RESTORE_MSG:
+ case CF_PARTITIONS_CHANGE_MSG:
+ case CF_ENTITY_LIFECYCLE_MSG:
+ onToCalculatedFieldSystemActorMsg((ToCalculatedFieldSystemMsg) msg, true);
+ break;
+ case CF_TELEMETRY_MSG:
+ case CF_LINKED_TELEMETRY_MSG:
+ onToCalculatedFieldSystemActorMsg((ToCalculatedFieldSystemMsg) msg, false);
+ break;
default:
return false;
}
return true;
}
+ private void onToCalculatedFieldSystemActorMsg(ToCalculatedFieldSystemMsg msg, boolean priority) {
+ if (priority) {
+ cfActor.tellWithHighPriority(msg);
+ } else {
+ cfActor.tell(msg);
+ }
+ }
+
private boolean isMyPartition(EntityId entityId) {
return systemContext.resolve(ServiceType.TB_CORE, tenantId, entityId).isMyPartition();
}
diff --git a/application/src/main/java/org/thingsboard/server/controller/BaseController.java b/application/src/main/java/org/thingsboard/server/controller/BaseController.java
index cf9d6444a2..73e278389a 100644
--- a/application/src/main/java/org/thingsboard/server/controller/BaseController.java
+++ b/application/src/main/java/org/thingsboard/server/controller/BaseController.java
@@ -70,6 +70,7 @@ import org.thingsboard.server.common.data.asset.Asset;
import org.thingsboard.server.common.data.asset.AssetInfo;
import org.thingsboard.server.common.data.asset.AssetProfile;
import org.thingsboard.server.common.data.audit.ActionType;
+import org.thingsboard.server.common.data.cf.CalculatedField;
import org.thingsboard.server.common.data.domain.Domain;
import org.thingsboard.server.common.data.edge.Edge;
import org.thingsboard.server.common.data.edge.EdgeInfo;
@@ -80,6 +81,7 @@ import org.thingsboard.server.common.data.id.AlarmCommentId;
import org.thingsboard.server.common.data.id.AlarmId;
import org.thingsboard.server.common.data.id.AssetId;
import org.thingsboard.server.common.data.id.AssetProfileId;
+import org.thingsboard.server.common.data.id.CalculatedFieldId;
import org.thingsboard.server.common.data.id.CustomerId;
import org.thingsboard.server.common.data.id.DashboardId;
import org.thingsboard.server.common.data.id.DeviceId;
@@ -132,6 +134,7 @@ import org.thingsboard.server.dao.asset.AssetProfileService;
import org.thingsboard.server.dao.asset.AssetService;
import org.thingsboard.server.dao.attributes.AttributesService;
import org.thingsboard.server.dao.audit.AuditLogService;
+import org.thingsboard.server.dao.cf.CalculatedFieldService;
import org.thingsboard.server.dao.customer.CustomerService;
import org.thingsboard.server.dao.dashboard.DashboardService;
import org.thingsboard.server.dao.device.ClaimDevicesService;
@@ -367,6 +370,9 @@ public abstract class BaseController {
@Autowired
protected NotificationTargetService notificationTargetService;
+ @Autowired
+ protected CalculatedFieldService calculatedFieldService;
+
@Value("${server.log_controller_error_stack_trace}")
@Getter
private boolean logControllerErrorStackTrace;
@@ -672,6 +678,9 @@ public abstract class BaseController {
case MOBILE_APP_BUNDLE:
checkMobileAppBundleId(new MobileAppBundleId(entityId.getId()), operation);
return;
+ case CALCULATED_FIELD:
+ checkCalculatedFieldId(new CalculatedFieldId(entityId.getId()), operation);
+ return;
default:
checkEntityId(entityId, entitiesService::findEntityByTenantIdAndId, operation);
}
@@ -955,6 +964,10 @@ public abstract class BaseController {
}
}
+ protected CalculatedField checkCalculatedFieldId(CalculatedFieldId calculatedFieldId, Operation operation) throws ThingsboardException {
+ return checkEntityId(calculatedFieldId, calculatedFieldService::findById, operation);
+ }
+
protected HomeDashboardInfo getHomeDashboardInfo(SecurityUser securityUser, JsonNode additionalInfo) {
HomeDashboardInfo homeDashboardInfo = extractHomeDashboardInfoFromAdditionalInfo(additionalInfo);
if (homeDashboardInfo == null) {
@@ -982,7 +995,8 @@ public abstract class BaseController {
}
return new HomeDashboardInfo(dashboardId, hideDashboardToolbar);
}
- } catch (Exception ignored) {}
+ } catch (Exception ignored) {
+ }
return null;
}
diff --git a/application/src/main/java/org/thingsboard/server/controller/CalculatedFieldController.java b/application/src/main/java/org/thingsboard/server/controller/CalculatedFieldController.java
new file mode 100644
index 0000000000..f899d0f480
--- /dev/null
+++ b/application/src/main/java/org/thingsboard/server/controller/CalculatedFieldController.java
@@ -0,0 +1,283 @@
+/**
+ * Copyright © 2016-2025 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 com.fasterxml.jackson.core.type.TypeReference;
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.node.ObjectNode;
+import io.swagger.v3.oas.annotations.Parameter;
+import io.swagger.v3.oas.annotations.media.Schema;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.http.HttpStatus;
+import org.springframework.security.access.prepost.PreAuthorize;
+import org.springframework.web.bind.annotation.PathVariable;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RequestMethod;
+import org.springframework.web.bind.annotation.RequestParam;
+import org.springframework.web.bind.annotation.ResponseBody;
+import org.springframework.web.bind.annotation.ResponseStatus;
+import org.springframework.web.bind.annotation.RestController;
+import org.thingsboard.common.util.JacksonUtil;
+import org.thingsboard.script.api.tbel.TbelCfArg;
+import org.thingsboard.script.api.tbel.TbelCfCtx;
+import org.thingsboard.script.api.tbel.TbelCfSingleValueArg;
+import org.thingsboard.script.api.tbel.TbelInvokeService;
+import org.thingsboard.server.common.data.EntityType;
+import org.thingsboard.server.common.data.EventInfo;
+import org.thingsboard.server.common.data.HasTenantId;
+import org.thingsboard.server.common.data.cf.CalculatedField;
+import org.thingsboard.server.common.data.cf.configuration.CalculatedFieldConfiguration;
+import org.thingsboard.server.common.data.event.EventType;
+import org.thingsboard.server.common.data.exception.ThingsboardException;
+import org.thingsboard.server.common.data.id.CalculatedFieldId;
+import org.thingsboard.server.common.data.id.EntityId;
+import org.thingsboard.server.common.data.id.EntityIdFactory;
+import org.thingsboard.server.common.data.id.HasId;
+import org.thingsboard.server.common.data.id.TenantId;
+import org.thingsboard.server.common.data.page.PageData;
+import org.thingsboard.server.common.data.page.PageLink;
+import org.thingsboard.server.config.annotations.ApiOperation;
+import org.thingsboard.server.dao.event.EventService;
+import org.thingsboard.server.queue.util.TbCoreComponent;
+import org.thingsboard.server.service.cf.ctx.state.CalculatedFieldScriptEngine;
+import org.thingsboard.server.service.cf.ctx.state.CalculatedFieldTbelScriptEngine;
+import org.thingsboard.server.service.entitiy.cf.TbCalculatedFieldService;
+import org.thingsboard.server.service.security.model.SecurityUser;
+import org.thingsboard.server.service.security.permission.Operation;
+import org.thingsboard.server.service.security.permission.Resource;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+import java.util.Optional;
+import java.util.concurrent.TimeUnit;
+
+import static org.thingsboard.server.controller.ControllerConstants.CF_TEXT_SEARCH_DESCRIPTION;
+import static org.thingsboard.server.controller.ControllerConstants.ENTITY_ID_PARAM_DESCRIPTION;
+import static org.thingsboard.server.controller.ControllerConstants.ENTITY_TYPE_PARAM_DESCRIPTION;
+import static org.thingsboard.server.controller.ControllerConstants.MARKDOWN_CODE_BLOCK_END;
+import static org.thingsboard.server.controller.ControllerConstants.MARKDOWN_CODE_BLOCK_START;
+import static org.thingsboard.server.controller.ControllerConstants.PAGE_NUMBER_DESCRIPTION;
+import static org.thingsboard.server.controller.ControllerConstants.PAGE_SIZE_DESCRIPTION;
+import static org.thingsboard.server.controller.ControllerConstants.SORT_ORDER_DESCRIPTION;
+import static org.thingsboard.server.controller.ControllerConstants.SORT_PROPERTY_DESCRIPTION;
+import static org.thingsboard.server.controller.ControllerConstants.TENANT_AUTHORITY_PARAGRAPH;
+import static org.thingsboard.server.controller.ControllerConstants.TENANT_OR_CUSTOMER_AUTHORITY_PARAGRAPH;
+import static org.thingsboard.server.controller.ControllerConstants.UUID_WIKI_LINK;
+
+@RestController
+@TbCoreComponent
+@RequestMapping("/api")
+@RequiredArgsConstructor
+@Slf4j
+public class CalculatedFieldController extends BaseController {
+
+ private final TbCalculatedFieldService tbCalculatedFieldService;
+ private final EventService eventService;
+ private final TbelInvokeService tbelInvokeService;
+
+ public static final String CALCULATED_FIELD_ID = "calculatedFieldId";
+
+ public static final int TIMEOUT = 20;
+
+ private static final String TEST_SCRIPT_EXPRESSION = "Execute the Script expression and return the result. The format of request: \n\n"
+ + MARKDOWN_CODE_BLOCK_START
+ + "{\n" +
+ " \"expression\": \"var temp = 0; foreach(element: temperature.values) {temp += element.value;} var avgTemperature = temp / temperature.values.size(); var adjustedTemperature = avgTemperature + 0.1 * humidity.value; return {\\\"adjustedTemperature\\\": adjustedTemperature};\",\n" +
+ " \"arguments\": {\n" +
+ " \"temperature\": {\n" +
+ " \"type\": \"TS_ROLLING\",\n" +
+ " \"timeWindow\": {\n" +
+ " \"startTs\": 1739775630002,\n" +
+ " \"endTs\": 65432211,\n" +
+ " \"limit\": 5\n" +
+ " },\n" +
+ " \"values\": [\n" +
+ " { \"ts\": 1739775639851, \"value\": 23 },\n" +
+ " { \"ts\": 1739775664561, \"value\": 43 },\n" +
+ " { \"ts\": 1739775713079, \"value\": 15 },\n" +
+ " { \"ts\": 1739775999522, \"value\": 34 },\n" +
+ " { \"ts\": 1739776228452, \"value\": 22 }\n" +
+ " ]\n" +
+ " },\n" +
+ " \"humidity\": { \"type\": \"SINGLE_VALUE\", \"ts\": 1739776478057, \"value\": 23 }\n" +
+ " }\n" +
+ "}"
+ + MARKDOWN_CODE_BLOCK_END
+ + "\n\n Expected result JSON contains \"output\" and \"error\".";
+
+ @ApiOperation(value = "Create Or Update Calculated Field (saveCalculatedField)",
+ notes = "Creates or Updates the Calculated Field. When creating calculated field, platform generates Calculated Field Id as " + UUID_WIKI_LINK +
+ "The newly created Calculated Field Id will be present in the response. " +
+ "Specify existing Calculated Field Id to update the calculated field. " +
+ "Referencing non-existing Calculated Field Id will cause 'Not Found' error. " +
+ "Remove 'id', 'tenantId' from the request body example (below) to create new Calculated Field entity. "
+ + TENANT_OR_CUSTOMER_AUTHORITY_PARAGRAPH)
+ @PreAuthorize("hasAnyAuthority('TENANT_ADMIN')")
+ @RequestMapping(value = "/calculatedField", method = RequestMethod.POST)
+ @ResponseBody
+ public CalculatedField saveCalculatedField(@io.swagger.v3.oas.annotations.parameters.RequestBody(description = "A JSON value representing the calculated field.")
+ @RequestBody CalculatedField calculatedField) throws Exception {
+ calculatedField.setTenantId(getTenantId());
+ checkEntity(calculatedField.getId(), calculatedField, Resource.CALCULATED_FIELD);
+ checkEntityId(calculatedField.getEntityId(), Operation.WRITE_CALCULATED_FIELD);
+ checkReferencedEntities(calculatedField.getConfiguration(), getCurrentUser());
+ return tbCalculatedFieldService.save(calculatedField, getCurrentUser());
+ }
+
+ @ApiOperation(value = "Get Calculated Field (getCalculatedFieldById)",
+ notes = "Fetch the Calculated Field object based on the provided Calculated Field Id."
+ )
+ @PreAuthorize("hasAnyAuthority('TENANT_ADMIN')")
+ @RequestMapping(value = "/calculatedField/{calculatedFieldId}", method = RequestMethod.GET)
+ @ResponseBody
+ public CalculatedField getCalculatedFieldById(@Parameter @PathVariable(CALCULATED_FIELD_ID) String strCalculatedFieldId) throws ThingsboardException {
+ checkParameter(CALCULATED_FIELD_ID, strCalculatedFieldId);
+ CalculatedFieldId calculatedFieldId = new CalculatedFieldId(toUUID(strCalculatedFieldId));
+ CalculatedField calculatedField = tbCalculatedFieldService.findById(calculatedFieldId, getCurrentUser());
+ checkNotNull(calculatedField);
+ checkEntityId(calculatedField.getEntityId(), Operation.READ_CALCULATED_FIELD);
+ return calculatedField;
+ }
+
+ @ApiOperation(value = "Get Calculated Fields by Entity Id (getCalculatedFieldsByEntityId)",
+ notes = "Fetch the Calculated Fields based on the provided Entity Id."
+ )
+ @PreAuthorize("hasAnyAuthority('TENANT_ADMIN')")
+ @RequestMapping(value = "/{entityType}/{entityId}/calculatedFields", params = {"pageSize", "page"}, method = RequestMethod.GET)
+ @ResponseBody
+ public PageData getCalculatedFieldsByEntityId(
+ @Parameter(description = ENTITY_TYPE_PARAM_DESCRIPTION, required = true, schema = @Schema(defaultValue = "DEVICE")) @PathVariable("entityType") String entityType,
+ @Parameter(description = ENTITY_ID_PARAM_DESCRIPTION, required = true) @PathVariable("entityId") String entityIdStr,
+ @Parameter(description = PAGE_SIZE_DESCRIPTION, required = true) @RequestParam int pageSize,
+ @Parameter(description = PAGE_NUMBER_DESCRIPTION, required = true) @RequestParam int page,
+ @Parameter(description = CF_TEXT_SEARCH_DESCRIPTION) @RequestParam(required = false) String textSearch,
+ @Parameter(description = SORT_PROPERTY_DESCRIPTION, schema = @Schema(allowableValues = {"createdTime", "name"})) @RequestParam(required = false) String sortProperty,
+ @Parameter(description = SORT_ORDER_DESCRIPTION, schema = @Schema(allowableValues = {"ASC", "DESC"})) @RequestParam(required = false) String sortOrder) throws ThingsboardException {
+ PageLink pageLink = createPageLink(pageSize, page, textSearch, sortProperty, sortOrder);
+ checkParameter("entityId", entityIdStr);
+ EntityId entityId = EntityIdFactory.getByTypeAndUuid(entityType, entityIdStr);
+ checkEntityId(entityId, Operation.READ_CALCULATED_FIELD);
+ return checkNotNull(tbCalculatedFieldService.findAllByTenantIdAndEntityId(entityId, getCurrentUser(), pageLink));
+ }
+
+ @ApiOperation(value = "Delete Calculated Field (deleteCalculatedField)",
+ notes = "Deletes the calculated field. Referencing non-existing Calculated Field Id will cause an error." + TENANT_OR_CUSTOMER_AUTHORITY_PARAGRAPH)
+ @PreAuthorize("hasAuthority('TENANT_ADMIN')")
+ @RequestMapping(value = "/calculatedField/{calculatedFieldId}", method = RequestMethod.DELETE)
+ @ResponseStatus(value = HttpStatus.OK)
+ public void deleteCalculatedField(@PathVariable(CALCULATED_FIELD_ID) String strCalculatedFieldId) throws Exception {
+ checkParameter(CALCULATED_FIELD_ID, strCalculatedFieldId);
+ CalculatedFieldId calculatedFieldId = new CalculatedFieldId(toUUID(strCalculatedFieldId));
+ CalculatedField calculatedField = checkCalculatedFieldId(calculatedFieldId, Operation.DELETE);
+ checkEntityId(calculatedField.getEntityId(), Operation.WRITE_CALCULATED_FIELD);
+ tbCalculatedFieldService.delete(calculatedField, getCurrentUser());
+ }
+
+ @ApiOperation(value = "Get latest calculated field debug event (getLatestCalculatedFieldDebugEvent)",
+ notes = "Gets latest calculated field debug event for specified calculated field id. " +
+ "Referencing non-existing calculated field id will cause an error. " + TENANT_AUTHORITY_PARAGRAPH)
+ @PreAuthorize("hasAnyAuthority('TENANT_ADMIN')")
+ @RequestMapping(value = "/calculatedField/{calculatedFieldId}/debug", method = RequestMethod.GET)
+ @ResponseBody
+ public JsonNode getLatestCalculatedFieldDebugEvent(@Parameter @PathVariable(CALCULATED_FIELD_ID) String strCalculatedFieldId) throws ThingsboardException {
+ checkParameter(CALCULATED_FIELD_ID, strCalculatedFieldId);
+ CalculatedFieldId calculatedFieldId = new CalculatedFieldId(toUUID(strCalculatedFieldId));
+ CalculatedField calculatedField = checkCalculatedFieldId(calculatedFieldId, Operation.READ);
+ checkEntityId(calculatedField.getEntityId(), Operation.READ_CALCULATED_FIELD);
+ TenantId tenantId = getCurrentUser().getTenantId();
+ return Optional.ofNullable(eventService.findLatestEvents(tenantId, calculatedFieldId, EventType.DEBUG_CALCULATED_FIELD, 1))
+ .flatMap(events -> events.stream().map(EventInfo::getBody).findFirst())
+ .orElse(null);
+ }
+
+ @ApiOperation(value = "Test Script expression",
+ notes = TEST_SCRIPT_EXPRESSION + TENANT_AUTHORITY_PARAGRAPH)
+ @PreAuthorize("hasAuthority('TENANT_ADMIN')")
+ @RequestMapping(value = "/calculatedField/testScript", method = RequestMethod.POST)
+ @ResponseBody
+ public JsonNode testScript(
+ @io.swagger.v3.oas.annotations.parameters.RequestBody(description = "Test calculated field TBEL expression.")
+ @RequestBody JsonNode inputParams) {
+ String expression = inputParams.get("expression").asText();
+ Map arguments = Objects.requireNonNullElse(
+ JacksonUtil.convertValue(inputParams.get("arguments"), new TypeReference<>() {
+ }),
+ Collections.emptyMap()
+ );
+
+ ArrayList ctxAndArgNames = new ArrayList<>(arguments.size() + 1);
+ ctxAndArgNames.add("ctx");
+ ctxAndArgNames.addAll(arguments.keySet());
+
+ String output = "";
+ String errorText = "";
+
+ try {
+ if (tbelInvokeService == null) {
+ throw new IllegalArgumentException("TBEL script engine is disabled!");
+ }
+
+ CalculatedFieldScriptEngine calculatedFieldScriptEngine = new CalculatedFieldTbelScriptEngine(
+ getTenantId(),
+ tbelInvokeService,
+ expression,
+ ctxAndArgNames.toArray(String[]::new)
+ );
+
+
+ Object[] args = new Object[ctxAndArgNames.size()];
+ args[0] = new TbelCfCtx(arguments);
+ for (int i = 1; i < ctxAndArgNames.size(); i++) {
+ var arg = arguments.get(ctxAndArgNames.get(i));
+ if (arg instanceof TbelCfSingleValueArg svArg) {
+ args[i] = svArg.getValue();
+ } else {
+ args[i] = arg;
+ }
+ }
+
+ JsonNode json = calculatedFieldScriptEngine.executeJsonAsync(args).get(TIMEOUT, TimeUnit.SECONDS);
+ output = JacksonUtil.toString(json);
+ } catch (Exception e) {
+ log.error("Error evaluating expression", e);
+ errorText = e.getMessage();
+ }
+
+ ObjectNode result = JacksonUtil.newObjectNode();
+ result.put("output", output);
+ result.put("error", errorText);
+ return result;
+ }
+
+ private & HasTenantId, I extends EntityId> void checkReferencedEntities(CalculatedFieldConfiguration calculatedFieldConfig, SecurityUser user) throws ThingsboardException {
+ List referencedEntityIds = calculatedFieldConfig.getReferencedEntities();
+ for (EntityId referencedEntityId : referencedEntityIds) {
+ EntityType entityType = referencedEntityId.getEntityType();
+ switch (entityType) {
+ case TENANT, CUSTOMER, ASSET, DEVICE -> checkEntityId(referencedEntityId, Operation.READ);
+ default ->
+ throw new IllegalArgumentException("Calculated fields do not support '" + entityType + "' for referenced entities.");
+ }
+ }
+
+ }
+
+}
diff --git a/application/src/main/java/org/thingsboard/server/controller/ControllerConstants.java b/application/src/main/java/org/thingsboard/server/controller/ControllerConstants.java
index 4987e5c26f..e45d041950 100644
--- a/application/src/main/java/org/thingsboard/server/controller/ControllerConstants.java
+++ b/application/src/main/java/org/thingsboard/server/controller/ControllerConstants.java
@@ -96,6 +96,7 @@ public class ControllerConstants {
protected static final String EDGE_TEXT_SEARCH_DESCRIPTION = "The case insensitive 'substring' filter based on the edge name.";
protected static final String EVENT_TEXT_SEARCH_DESCRIPTION = "The value is not used in searching.";
protected static final String AUDIT_LOG_TEXT_SEARCH_DESCRIPTION = "The case insensitive 'substring' filter based on one of the next properties: entityType, entityName, userName, actionType, actionStatus.";
+ protected static final String CF_TEXT_SEARCH_DESCRIPTION = "The case insensitive 'substring' filter based on the calculated field name.";
protected static final String SORT_PROPERTY_DESCRIPTION = "Property of entity to sort by";
protected static final String SORT_ORDER_DESCRIPTION = "Sort order. ASC (ASCENDING) or DESC (DESCENDING)";
diff --git a/application/src/main/java/org/thingsboard/server/controller/EntityQueryController.java b/application/src/main/java/org/thingsboard/server/controller/EntityQueryController.java
index 0387e8c24a..7fd0b12077 100644
--- a/application/src/main/java/org/thingsboard/server/controller/EntityQueryController.java
+++ b/application/src/main/java/org/thingsboard/server/controller/EntityQueryController.java
@@ -20,6 +20,8 @@ import io.swagger.v3.oas.annotations.media.Schema;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestMethod;
@@ -27,6 +29,7 @@ import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.ResponseBody;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.context.request.async.DeferredResult;
+import org.thingsboard.server.common.data.edqs.ToCoreEdqsRequest;
import org.thingsboard.server.common.data.exception.ThingsboardException;
import org.thingsboard.server.common.data.id.TenantId;
import org.thingsboard.server.common.data.id.UserId;
@@ -38,6 +41,8 @@ import org.thingsboard.server.common.data.query.EntityCountQuery;
import org.thingsboard.server.common.data.query.EntityData;
import org.thingsboard.server.common.data.query.EntityDataPageLink;
import org.thingsboard.server.common.data.query.EntityDataQuery;
+import org.thingsboard.server.common.msg.edqs.EdqsApiService;
+import org.thingsboard.server.common.msg.edqs.EdqsService;
import org.thingsboard.server.config.annotations.ApiOperation;
import org.thingsboard.server.queue.util.TbCoreComponent;
import org.thingsboard.server.service.query.EntityQueryService;
@@ -55,6 +60,10 @@ public class EntityQueryController extends BaseController {
@Autowired
private EntityQueryService entityQueryService;
+ @Autowired
+ private EdqsService edqsService;
+ @Autowired
+ private EdqsApiService edqsApiService;
private static final int MAX_PAGE_SIZE = 100;
@@ -133,4 +142,16 @@ public class EntityQueryController extends BaseController {
return entityQueryService.getKeysByQuery(getCurrentUser(), tenantId, query, isTimeseries, isAttributes, scope);
}
+ @PreAuthorize("hasAnyAuthority('SYS_ADMIN')")
+ @PostMapping("/edqs/system/request")
+ public void processSystemEdqsRequest(@RequestBody ToCoreEdqsRequest request) {
+ edqsService.processSystemRequest(request);
+ }
+
+ @PreAuthorize("hasAnyAuthority('SYS_ADMIN')")
+ @GetMapping("/edqs/enabled")
+ public boolean isEdqsApiEnabled() {
+ return edqsApiService.isEnabled();
+ }
+
}
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 bc22313a03..4ee86871d6 100644
--- a/application/src/main/java/org/thingsboard/server/controller/SystemInfoController.java
+++ b/application/src/main/java/org/thingsboard/server/controller/SystemInfoController.java
@@ -35,8 +35,8 @@ import org.thingsboard.server.common.data.SystemParams;
import org.thingsboard.server.common.data.exception.ThingsboardException;
import org.thingsboard.server.common.data.id.CustomerId;
import org.thingsboard.server.common.data.id.TenantId;
-import org.thingsboard.server.common.data.mobile.qrCodeSettings.QrCodeSettings;
import org.thingsboard.server.common.data.mobile.qrCodeSettings.QRCodeConfig;
+import org.thingsboard.server.common.data.mobile.qrCodeSettings.QrCodeSettings;
import org.thingsboard.server.common.data.page.PageLink;
import org.thingsboard.server.common.data.settings.UserSettings;
import org.thingsboard.server.common.data.settings.UserSettingsType;
@@ -46,6 +46,7 @@ import org.thingsboard.server.queue.util.TbCoreComponent;
import org.thingsboard.server.service.security.model.SecurityUser;
import org.thingsboard.server.service.security.model.UserPrincipal;
import org.thingsboard.server.service.sync.vc.EntitiesVersionControlService;
+import org.thingsboard.server.utils.DebugModeRateLimitsConfig;
import java.util.Collections;
import java.util.List;
@@ -74,12 +75,6 @@ public class SystemInfoController extends BaseController {
@Value("${debug.settings.default_duration:15}")
private int defaultDebugDurationMinutes;
- @Value("${actors.rule.chain.debug_mode_rate_limits_per_tenant.enabled:true}")
- private boolean ruleChainDebugPerTenantLimitsEnabled;
-
- @Value("${actors.rule.chain.debug_mode_rate_limits_per_tenant.configuration:50000:3600}")
- private String ruleChainDebugPerTenantLimitsConfiguration;
-
@Autowired(required = false)
private BuildProperties buildProperties;
@@ -89,6 +84,9 @@ public class SystemInfoController extends BaseController {
@Autowired
private QrCodeSettingService qrCodeSettingService;
+ @Autowired
+ private DebugModeRateLimitsConfig debugModeRateLimitsConfig;
+
@PostConstruct
public void init() {
JsonNode info = buildInfoObject();
@@ -152,9 +150,14 @@ public class SystemInfoController extends BaseController {
DefaultTenantProfileConfiguration tenantProfileConfiguration = tenantProfileCache.get(tenantId).getDefaultProfileConfiguration();
systemParams.setMaxResourceSize(tenantProfileConfiguration.getMaxResourceSize());
systemParams.setMaxDebugModeDurationMinutes(DebugModeUtil.getMaxDebugAllDuration(tenantProfileConfiguration.getMaxDebugModeDurationMinutes(), defaultDebugDurationMinutes));
- if (ruleChainDebugPerTenantLimitsEnabled) {
- systemParams.setRuleChainDebugPerTenantLimitsConfiguration(ruleChainDebugPerTenantLimitsConfiguration);
+ if (debugModeRateLimitsConfig.isRuleChainDebugPerTenantLimitsEnabled()) {
+ systemParams.setRuleChainDebugPerTenantLimitsConfiguration(debugModeRateLimitsConfig.getRuleChainDebugPerTenantLimitsConfiguration());
+ }
+ if (debugModeRateLimitsConfig.isCalculatedFieldDebugPerTenantLimitsEnabled()) {
+ systemParams.setCalculatedFieldDebugPerTenantLimitsConfiguration(debugModeRateLimitsConfig.getCalculatedFieldDebugPerTenantLimitsConfiguration());
}
+ systemParams.setMaxArgumentsPerCF(tenantProfileConfiguration.getMaxArgumentsPerCF());
+ systemParams.setMaxDataPointsPerRollingArg(tenantProfileConfiguration.getMaxDataPointsPerRollingArg());
}
systemParams.setMobileQrEnabled(Optional.ofNullable(qrCodeSettingService.findQrCodeSettings(TenantId.SYS_TENANT_ID))
.map(QrCodeSettings::getQrCodeConfig).map(QRCodeConfig::isShowOnHomePage)
diff --git a/application/src/main/java/org/thingsboard/server/controller/TbResourceController.java b/application/src/main/java/org/thingsboard/server/controller/TbResourceController.java
index 9c72b9a5ad..b23603f6a2 100644
--- a/application/src/main/java/org/thingsboard/server/controller/TbResourceController.java
+++ b/application/src/main/java/org/thingsboard/server/controller/TbResourceController.java
@@ -41,6 +41,7 @@ import org.springframework.web.bind.annotation.RestController;
import org.thingsboard.server.common.data.ResourceSubType;
import org.thingsboard.server.common.data.ResourceType;
import org.thingsboard.server.common.data.TbResource;
+import org.thingsboard.server.common.data.TbResourceDeleteResult;
import org.thingsboard.server.common.data.TbResourceInfo;
import org.thingsboard.server.common.data.TbResourceInfoFilter;
import org.thingsboard.server.common.data.exception.ThingsboardException;
@@ -322,12 +323,14 @@ public class TbResourceController extends BaseController {
notes = "Deletes the Resource. Referencing non-existing Resource Id will cause an error." + SYSTEM_OR_TENANT_AUTHORITY_PARAGRAPH)
@PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN')")
@DeleteMapping(value = "/resource/{resourceId}")
- public void deleteResource(@Parameter(description = RESOURCE_ID_PARAM_DESCRIPTION)
- @PathVariable("resourceId") String strResourceId) throws ThingsboardException {
+ public ResponseEntity deleteResource(@Parameter(description = RESOURCE_ID_PARAM_DESCRIPTION)
+ @PathVariable("resourceId") String strResourceId,
+ @RequestParam(name = "force", required = false) boolean force) throws ThingsboardException {
checkParameter(RESOURCE_ID, strResourceId);
TbResourceId resourceId = new TbResourceId(toUUID(strResourceId));
TbResource tbResource = checkResourceId(resourceId, Operation.DELETE);
- tbResourceService.delete(tbResource, getCurrentUser());
+ TbResourceDeleteResult tbResourceDeleteResult = tbResourceService.delete(tbResource, force, getCurrentUser());
+ return (tbResourceDeleteResult.isSuccess() ? ResponseEntity.ok() : ResponseEntity.badRequest()).body(tbResourceDeleteResult);
}
private ResponseEntity downloadResourceIfChanged(String strResourceId, String etag) throws ThingsboardException {
diff --git a/application/src/main/java/org/thingsboard/server/controller/TenantProfileController.java b/application/src/main/java/org/thingsboard/server/controller/TenantProfileController.java
index ff4de0ce0c..1a3bdce1f6 100644
--- a/application/src/main/java/org/thingsboard/server/controller/TenantProfileController.java
+++ b/application/src/main/java/org/thingsboard/server/controller/TenantProfileController.java
@@ -18,6 +18,7 @@ package org.thingsboard.server.controller;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.media.ArraySchema;
import io.swagger.v3.oas.annotations.media.Schema;
+import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpStatus;
@@ -160,7 +161,12 @@ public class TenantProfileController extends BaseController {
" \"rpcTtlDays\": 0,\n" +
" \"queueStatsTtlDays\": 0,\n" +
" \"ruleEngineExceptionsTtlDays\": 0,\n" +
- " \"warnThreshold\": 0\n" +
+ " \"warnThreshold\": 0,\n" +
+ " \"maxCalculatedFieldsPerEntity\": 5,\n" +
+ " \"maxArgumentsPerCF\": 10,\n" +
+ " \"maxDataPointsPerRollingArg\": 1000,\n" +
+ " \"maxStateSizeInKBytes\": 32,\n" +
+ " \"maxSingleValueArgumentSizeInKBytes\": 2" +
" }\n" +
" },\n" +
" \"default\": false\n" +
@@ -172,7 +178,7 @@ public class TenantProfileController extends BaseController {
@RequestMapping(value = "/tenantProfile", method = RequestMethod.POST)
@ResponseBody
public TenantProfile saveTenantProfile(@Parameter(description = "A JSON value representing the tenant profile.")
- @RequestBody TenantProfile tenantProfile) throws ThingsboardException {
+ @Valid @RequestBody TenantProfile tenantProfile) throws ThingsboardException {
TenantProfile oldProfile;
if (tenantProfile.getId() == null) {
accessControlService.checkPermission(getCurrentUser(), Resource.TENANT_PROFILE, Operation.CREATE);
diff --git a/application/src/main/java/org/thingsboard/server/exception/CalculatedFieldStateException.java b/application/src/main/java/org/thingsboard/server/exception/CalculatedFieldStateException.java
new file mode 100644
index 0000000000..a30b7218a1
--- /dev/null
+++ b/application/src/main/java/org/thingsboard/server/exception/CalculatedFieldStateException.java
@@ -0,0 +1,24 @@
+/**
+ * Copyright © 2016-2025 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.exception;
+
+public class CalculatedFieldStateException extends RuntimeException {
+
+ public CalculatedFieldStateException(String message) {
+ super(message);
+ }
+
+}
diff --git a/application/src/main/java/org/thingsboard/server/service/apiusage/DefaultTbApiUsageStateService.java b/application/src/main/java/org/thingsboard/server/service/apiusage/DefaultTbApiUsageStateService.java
index aecb31ca0c..b0ab4edc27 100644
--- a/application/src/main/java/org/thingsboard/server/service/apiusage/DefaultTbApiUsageStateService.java
+++ b/application/src/main/java/org/thingsboard/server/service/apiusage/DefaultTbApiUsageStateService.java
@@ -15,13 +15,11 @@
*/
package org.thingsboard.server.service.apiusage;
-import com.google.common.util.concurrent.FutureCallback;
import com.google.common.util.concurrent.ListenableFuture;
import jakarta.annotation.PostConstruct;
import jakarta.annotation.PreDestroy;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
-import org.checkerframework.checker.nullness.qual.Nullable;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Lazy;
@@ -92,15 +90,7 @@ import java.util.stream.Collectors;
public class DefaultTbApiUsageStateService extends AbstractPartitionBasedService implements TbApiUsageStateService {
public static final String HOURLY = "Hourly";
- public static final FutureCallback VOID_CALLBACK = new FutureCallback() {
- @Override
- public void onSuccess(@Nullable Void result) {
- }
- @Override
- public void onFailure(Throwable t) {
- }
- };
private final PartitionService partitionService;
private final TenantService tenantService;
private final TimeseriesService tsService;
@@ -219,7 +209,6 @@ public class DefaultTbApiUsageStateService extends AbstractPartitionBasedService
.tenantId(tenantId)
.entityId(usageState.getApiUsageState().getId())
.entries(updatedEntries)
- .callback(VOID_CALLBACK)
.build());
if (!result.isEmpty()) {
persistAndNotify(usageState, result);
@@ -331,7 +320,6 @@ public class DefaultTbApiUsageStateService extends AbstractPartitionBasedService
.tenantId(tenantId)
.entityId(id)
.entries(profileThresholds)
- .callback(VOID_CALLBACK)
.build());
}
}
@@ -364,7 +352,6 @@ public class DefaultTbApiUsageStateService extends AbstractPartitionBasedService
.tenantId(state.getTenantId())
.entityId(state.getApiUsageState().getId())
.entries(stateTelemetry)
- .callback(VOID_CALLBACK)
.build());
if (state.getEntityType() == EntityType.TENANT && !state.getEntityId().equals(TenantId.SYS_TENANT_ID)) {
@@ -457,7 +444,6 @@ public class DefaultTbApiUsageStateService extends AbstractPartitionBasedService
.tenantId(state.getTenantId())
.entityId(state.getApiUsageState().getId())
.entries(counts)
- .callback(VOID_CALLBACK)
.build());
}
diff --git a/application/src/main/java/org/thingsboard/server/service/cf/AbstractCalculatedFieldStateService.java b/application/src/main/java/org/thingsboard/server/service/cf/AbstractCalculatedFieldStateService.java
new file mode 100644
index 0000000000..0163d65d98
--- /dev/null
+++ b/application/src/main/java/org/thingsboard/server/service/cf/AbstractCalculatedFieldStateService.java
@@ -0,0 +1,72 @@
+/**
+ * Copyright © 2016-2025 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.cf;
+
+import org.springframework.beans.factory.annotation.Autowired;
+import org.thingsboard.server.actors.ActorSystemContext;
+import org.thingsboard.server.actors.calculatedField.CalculatedFieldStateRestoreMsg;
+import org.thingsboard.server.common.msg.queue.TbCallback;
+import org.thingsboard.server.exception.CalculatedFieldStateException;
+import org.thingsboard.server.gen.transport.TransportProtos.CalculatedFieldStateProto;
+import org.thingsboard.server.gen.transport.TransportProtos.ToCalculatedFieldMsg;
+import org.thingsboard.server.queue.common.TbProtoQueueMsg;
+import org.thingsboard.server.queue.common.consumer.PartitionedQueueConsumerManager;
+import org.thingsboard.server.service.cf.ctx.CalculatedFieldEntityCtxId;
+import org.thingsboard.server.service.cf.ctx.state.CalculatedFieldState;
+
+import static org.thingsboard.server.utils.CalculatedFieldUtils.fromProto;
+import static org.thingsboard.server.utils.CalculatedFieldUtils.toProto;
+
+public abstract class AbstractCalculatedFieldStateService implements CalculatedFieldStateService {
+
+ @Autowired
+ private ActorSystemContext actorSystemContext;
+
+ protected PartitionedQueueConsumerManager> eventConsumer;
+
+ @Override
+ public void init(PartitionedQueueConsumerManager> eventConsumer) {
+ this.eventConsumer = eventConsumer;
+ }
+
+ @Override
+ public final void persistState(CalculatedFieldEntityCtxId stateId, CalculatedFieldState state, TbCallback callback) {
+ if (state.isSizeExceedsLimit()) {
+ throw new CalculatedFieldStateException("State size exceeds the maximum allowed limit. The state will not be persisted to RocksDB.");
+ }
+ doPersist(stateId, toProto(stateId, state), callback);
+ }
+
+ protected abstract void doPersist(CalculatedFieldEntityCtxId stateId, CalculatedFieldStateProto stateMsgProto, TbCallback callback);
+
+ @Override
+ public final void removeState(CalculatedFieldEntityCtxId stateId, TbCallback callback) {
+ doRemove(stateId, callback);
+ }
+
+ protected abstract void doRemove(CalculatedFieldEntityCtxId stateId, TbCallback callback);
+
+ protected void processRestoredState(CalculatedFieldStateProto stateMsg) {
+ var id = fromProto(stateMsg.getId());
+ var state = fromProto(stateMsg);
+ processRestoredState(id, state);
+ }
+
+ protected void processRestoredState(CalculatedFieldEntityCtxId id, CalculatedFieldState state) {
+ actorSystemContext.tell(new CalculatedFieldStateRestoreMsg(id, state));
+ }
+
+}
diff --git a/application/src/main/java/org/thingsboard/server/service/cf/CalculatedFieldCache.java b/application/src/main/java/org/thingsboard/server/service/cf/CalculatedFieldCache.java
new file mode 100644
index 0000000000..fb63432fed
--- /dev/null
+++ b/application/src/main/java/org/thingsboard/server/service/cf/CalculatedFieldCache.java
@@ -0,0 +1,45 @@
+/**
+ * Copyright © 2016-2025 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.cf;
+
+import org.thingsboard.server.common.data.cf.CalculatedField;
+import org.thingsboard.server.common.data.cf.CalculatedFieldLink;
+import org.thingsboard.server.common.data.id.CalculatedFieldId;
+import org.thingsboard.server.common.data.id.EntityId;
+import org.thingsboard.server.common.data.id.TenantId;
+import org.thingsboard.server.service.cf.ctx.state.CalculatedFieldCtx;
+
+import java.util.List;
+
+public interface CalculatedFieldCache {
+
+ CalculatedField getCalculatedField(CalculatedFieldId calculatedFieldId);
+
+ List getCalculatedFieldsByEntityId(EntityId entityId);
+
+ List getCalculatedFieldLinksByEntityId(EntityId entityId);
+
+ CalculatedFieldCtx getCalculatedFieldCtx(CalculatedFieldId calculatedFieldId);
+
+ List getCalculatedFieldCtxsByEntityId(EntityId entityId);
+
+ void addCalculatedField(TenantId tenantId, CalculatedFieldId calculatedFieldId);
+
+ void updateCalculatedField(TenantId tenantId, CalculatedFieldId calculatedFieldId);
+
+ void evict(CalculatedFieldId calculatedFieldId);
+
+}
diff --git a/application/src/main/java/org/thingsboard/server/service/cf/CalculatedFieldInitService.java b/application/src/main/java/org/thingsboard/server/service/cf/CalculatedFieldInitService.java
new file mode 100644
index 0000000000..6505dae581
--- /dev/null
+++ b/application/src/main/java/org/thingsboard/server/service/cf/CalculatedFieldInitService.java
@@ -0,0 +1,19 @@
+/**
+ * Copyright © 2016-2025 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.cf;
+
+public interface CalculatedFieldInitService {
+}
diff --git a/application/src/main/java/org/thingsboard/server/service/cf/CalculatedFieldProcessingService.java b/application/src/main/java/org/thingsboard/server/service/cf/CalculatedFieldProcessingService.java
new file mode 100644
index 0000000000..847caccaff
--- /dev/null
+++ b/application/src/main/java/org/thingsboard/server/service/cf/CalculatedFieldProcessingService.java
@@ -0,0 +1,43 @@
+/**
+ * Copyright © 2016-2025 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.cf;
+
+import com.google.common.util.concurrent.ListenableFuture;
+import org.thingsboard.server.actors.calculatedField.CalculatedFieldTelemetryMsg;
+import org.thingsboard.server.common.data.cf.configuration.Argument;
+import org.thingsboard.server.common.data.id.CalculatedFieldId;
+import org.thingsboard.server.common.data.id.EntityId;
+import org.thingsboard.server.common.data.id.TenantId;
+import org.thingsboard.server.common.msg.queue.TbCallback;
+import org.thingsboard.server.service.cf.ctx.CalculatedFieldEntityCtxId;
+import org.thingsboard.server.service.cf.ctx.state.ArgumentEntry;
+import org.thingsboard.server.service.cf.ctx.state.CalculatedFieldCtx;
+import org.thingsboard.server.service.cf.ctx.state.CalculatedFieldState;
+
+import java.util.List;
+import java.util.Map;
+
+public interface CalculatedFieldProcessingService {
+
+ ListenableFuture fetchStateFromDb(CalculatedFieldCtx ctx, EntityId entityId);
+
+ Map fetchArgsFromDb(TenantId tenantId, EntityId entityId, Map arguments);
+
+ void pushMsgToRuleEngine(TenantId tenantId, EntityId entityId, CalculatedFieldResult calculationResult, List cfIds, TbCallback callback);
+
+ void pushMsgToLinks(CalculatedFieldTelemetryMsg msg, List linkedCalculatedFields, TbCallback callback);
+
+}
diff --git a/application/src/main/java/org/thingsboard/server/service/cf/CalculatedFieldQueueService.java b/application/src/main/java/org/thingsboard/server/service/cf/CalculatedFieldQueueService.java
new file mode 100644
index 0000000000..eb86220361
--- /dev/null
+++ b/application/src/main/java/org/thingsboard/server/service/cf/CalculatedFieldQueueService.java
@@ -0,0 +1,44 @@
+/**
+ * Copyright © 2016-2025 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.cf;
+
+import com.google.common.util.concurrent.FutureCallback;
+import org.thingsboard.rule.engine.api.AttributesDeleteRequest;
+import org.thingsboard.rule.engine.api.AttributesSaveRequest;
+import org.thingsboard.rule.engine.api.RuleEngineCalculatedFieldQueueService;
+import org.thingsboard.rule.engine.api.TimeseriesDeleteRequest;
+import org.thingsboard.rule.engine.api.TimeseriesSaveRequest;
+import org.thingsboard.server.common.data.kv.TimeseriesSaveResult;
+
+import java.util.List;
+
+public interface CalculatedFieldQueueService extends RuleEngineCalculatedFieldQueueService {
+
+ /**
+ * Filter CFs based on the request entity. Push to the queue if any matching CF exist;
+ *
+ * @param request - telemetry save request;
+ * @param callback
+ */
+ void pushRequestToQueue(TimeseriesSaveRequest request, TimeseriesSaveResult result, FutureCallback callback);
+
+ void pushRequestToQueue(AttributesSaveRequest request, List result, FutureCallback callback);
+
+ void pushRequestToQueue(AttributesDeleteRequest request, List result, FutureCallback callback);
+
+ void pushRequestToQueue(TimeseriesDeleteRequest request, List result, FutureCallback callback);
+
+}
diff --git a/application/src/main/java/org/thingsboard/server/service/cf/CalculatedFieldResult.java b/application/src/main/java/org/thingsboard/server/service/cf/CalculatedFieldResult.java
new file mode 100644
index 0000000000..8eb27395c1
--- /dev/null
+++ b/application/src/main/java/org/thingsboard/server/service/cf/CalculatedFieldResult.java
@@ -0,0 +1,30 @@
+/**
+ * Copyright © 2016-2025 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.cf;
+
+import com.fasterxml.jackson.databind.JsonNode;
+import lombok.Data;
+import org.thingsboard.server.common.data.AttributeScope;
+import org.thingsboard.server.common.data.cf.configuration.OutputType;
+
+@Data
+public final class CalculatedFieldResult {
+
+ private final OutputType type;
+ private final AttributeScope scope;
+ private final JsonNode result;
+
+}
diff --git a/application/src/main/java/org/thingsboard/server/service/cf/CalculatedFieldStateService.java b/application/src/main/java/org/thingsboard/server/service/cf/CalculatedFieldStateService.java
new file mode 100644
index 0000000000..109f13f183
--- /dev/null
+++ b/application/src/main/java/org/thingsboard/server/service/cf/CalculatedFieldStateService.java
@@ -0,0 +1,41 @@
+/**
+ * Copyright © 2016-2025 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.cf;
+
+import org.thingsboard.server.common.msg.queue.TbCallback;
+import org.thingsboard.server.common.msg.queue.TopicPartitionInfo;
+import org.thingsboard.server.exception.CalculatedFieldStateException;
+import org.thingsboard.server.gen.transport.TransportProtos.ToCalculatedFieldMsg;
+import org.thingsboard.server.queue.common.TbProtoQueueMsg;
+import org.thingsboard.server.queue.common.consumer.PartitionedQueueConsumerManager;
+import org.thingsboard.server.service.cf.ctx.CalculatedFieldEntityCtxId;
+import org.thingsboard.server.service.cf.ctx.state.CalculatedFieldState;
+
+import java.util.Set;
+
+public interface CalculatedFieldStateService {
+
+ void init(PartitionedQueueConsumerManager> eventConsumer);
+
+ void persistState(CalculatedFieldEntityCtxId stateId, CalculatedFieldState state, TbCallback callback) throws CalculatedFieldStateException;
+
+ void removeState(CalculatedFieldEntityCtxId stateId, TbCallback callback);
+
+ void restore(Set partitions);
+
+ void stop();
+
+}
diff --git a/application/src/main/java/org/thingsboard/server/service/cf/CfRocksDb.java b/application/src/main/java/org/thingsboard/server/service/cf/CfRocksDb.java
new file mode 100644
index 0000000000..f95227bc24
--- /dev/null
+++ b/application/src/main/java/org/thingsboard/server/service/cf/CfRocksDb.java
@@ -0,0 +1,47 @@
+/**
+ * Copyright © 2016-2025 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.cf;
+
+import jakarta.annotation.PostConstruct;
+import jakarta.annotation.PreDestroy;
+import org.rocksdb.Options;
+import org.rocksdb.WriteOptions;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.boot.autoconfigure.condition.ConditionalOnExpression;
+import org.springframework.stereotype.Component;
+import org.thingsboard.server.edqs.util.TbRocksDb;
+
+@Component
+@ConditionalOnExpression("'${queue.type:null}'=='in-memory'")
+public class CfRocksDb extends TbRocksDb {
+
+ public CfRocksDb(@Value("${queue.calculated_fields.rocks_db_path:${user.home}/.rocksdb/cf_states}") String path) {
+ super(path, new Options().setCreateIfMissing(true), new WriteOptions().setSync(true));
+ }
+
+ @PostConstruct
+ @Override
+ public void init() {
+ super.init();
+ }
+
+ @PreDestroy
+ @Override
+ public void close() {
+ super.close();
+ }
+
+}
diff --git a/application/src/main/java/org/thingsboard/server/service/cf/DefaultCalculatedFieldCache.java b/application/src/main/java/org/thingsboard/server/service/cf/DefaultCalculatedFieldCache.java
new file mode 100644
index 0000000000..64487d9b3e
--- /dev/null
+++ b/application/src/main/java/org/thingsboard/server/service/cf/DefaultCalculatedFieldCache.java
@@ -0,0 +1,187 @@
+/**
+ * Copyright © 2016-2025 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.cf;
+
+import lombok.Getter;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.stereotype.Service;
+import org.thingsboard.script.api.tbel.TbelInvokeService;
+import org.thingsboard.server.actors.ActorSystemContext;
+import org.thingsboard.server.common.data.cf.CalculatedField;
+import org.thingsboard.server.common.data.cf.CalculatedFieldLink;
+import org.thingsboard.server.common.data.cf.configuration.CalculatedFieldConfiguration;
+import org.thingsboard.server.common.data.id.CalculatedFieldId;
+import org.thingsboard.server.common.data.id.EntityId;
+import org.thingsboard.server.common.data.id.TenantId;
+import org.thingsboard.server.common.data.page.PageDataIterable;
+import org.thingsboard.server.common.msg.cf.CalculatedFieldInitMsg;
+import org.thingsboard.server.common.msg.cf.CalculatedFieldLinkInitMsg;
+import org.thingsboard.server.dao.cf.CalculatedFieldService;
+import org.thingsboard.server.dao.usagerecord.ApiLimitService;
+import org.thingsboard.server.queue.util.AfterStartUp;
+import org.thingsboard.server.service.cf.ctx.state.CalculatedFieldCtx;
+
+import java.util.Collections;
+import java.util.List;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.ConcurrentMap;
+import java.util.concurrent.CopyOnWriteArrayList;
+import java.util.concurrent.locks.Lock;
+import java.util.concurrent.locks.ReentrantLock;
+
+@Service
+@Slf4j
+@RequiredArgsConstructor
+public class DefaultCalculatedFieldCache implements CalculatedFieldCache {
+
+ private static final Integer UNKNOWN_PARTITION = -1;
+
+ private final Lock calculatedFieldFetchLock = new ReentrantLock();
+
+ private final CalculatedFieldService calculatedFieldService;
+ private final TbelInvokeService tbelInvokeService;
+ private final ActorSystemContext actorSystemContext;
+ private final ApiLimitService apiLimitService;
+
+ private final ConcurrentMap calculatedFields = new ConcurrentHashMap<>();
+ private final ConcurrentMap> entityIdCalculatedFields = new ConcurrentHashMap<>();
+ private final ConcurrentMap> calculatedFieldLinks = new ConcurrentHashMap<>();
+ private final ConcurrentMap> entityIdCalculatedFieldLinks = new ConcurrentHashMap<>();
+ private final ConcurrentMap calculatedFieldsCtx = new ConcurrentHashMap<>();
+
+ @Value("${calculatedField.initFetchPackSize:50000}")
+ @Getter
+ private int initFetchPackSize;
+
+ @AfterStartUp(order = AfterStartUp.CF_READ_CF_SERVICE)
+ public void init() {
+ //TODO: move to separate place to avoid circular references with the ActorSystemContext (@Lazy for tsSubService)
+ PageDataIterable cfs = new PageDataIterable<>(calculatedFieldService::findAllCalculatedFields, initFetchPackSize);
+ cfs.forEach(cf -> {
+ calculatedFields.putIfAbsent(cf.getId(), cf);
+ actorSystemContext.tell(new CalculatedFieldInitMsg(cf.getTenantId(), cf));
+ });
+ calculatedFields.values().forEach(cf -> {
+ entityIdCalculatedFields.computeIfAbsent(cf.getEntityId(), id -> new CopyOnWriteArrayList<>()).add(cf);
+ });
+ PageDataIterable cfls = new PageDataIterable<>(calculatedFieldService::findAllCalculatedFieldLinks, initFetchPackSize);
+ cfls.forEach(link -> {
+ calculatedFieldLinks.computeIfAbsent(link.getCalculatedFieldId(), id -> new CopyOnWriteArrayList<>()).add(link);
+ actorSystemContext.tell(new CalculatedFieldLinkInitMsg(link.getTenantId(), link));
+ });
+ calculatedFieldLinks.values().stream()
+ .flatMap(List::stream)
+ .forEach(link ->
+ entityIdCalculatedFieldLinks.computeIfAbsent(link.getEntityId(), id -> new CopyOnWriteArrayList<>()).add(link)
+ );
+ }
+
+ @Override
+ public CalculatedField getCalculatedField(CalculatedFieldId calculatedFieldId) {
+ return calculatedFields.get(calculatedFieldId);
+ }
+
+ @Override
+ public List getCalculatedFieldsByEntityId(EntityId entityId) {
+ return entityIdCalculatedFields.getOrDefault(entityId, new CopyOnWriteArrayList<>());
+ }
+
+ @Override
+ public List getCalculatedFieldLinksByEntityId(EntityId entityId) {
+ return entityIdCalculatedFieldLinks.getOrDefault(entityId, new CopyOnWriteArrayList<>());
+ }
+
+ @Override
+ public CalculatedFieldCtx getCalculatedFieldCtx(CalculatedFieldId calculatedFieldId) {
+ CalculatedFieldCtx ctx = calculatedFieldsCtx.get(calculatedFieldId);
+ if (ctx == null) {
+ calculatedFieldFetchLock.lock();
+ try {
+ ctx = calculatedFieldsCtx.get(calculatedFieldId);
+ if (ctx == null) {
+ CalculatedField calculatedField = getCalculatedField(calculatedFieldId);
+ if (calculatedField != null) {
+ ctx = new CalculatedFieldCtx(calculatedField, tbelInvokeService, apiLimitService);
+ calculatedFieldsCtx.put(calculatedFieldId, ctx);
+ log.debug("[{}] Put calculated field ctx into cache: {}", calculatedFieldId, ctx);
+ }
+ }
+ } finally {
+ calculatedFieldFetchLock.unlock();
+ }
+ }
+ log.trace("[{}] Found calculated field ctx in cache: {}", calculatedFieldId, ctx);
+ return ctx;
+ }
+
+ @Override
+ public List getCalculatedFieldCtxsByEntityId(EntityId entityId) {
+ if (entityId == null) {
+ return Collections.emptyList();
+ }
+ return getCalculatedFieldsByEntityId(entityId).stream()
+ .map(cf -> getCalculatedFieldCtx(cf.getId()))
+ .toList();
+ }
+
+ @Override
+ public void addCalculatedField(TenantId tenantId, CalculatedFieldId calculatedFieldId) {
+ calculatedFieldFetchLock.lock();
+ try {
+ CalculatedField calculatedField = calculatedFieldService.findById(tenantId, calculatedFieldId);
+ EntityId cfEntityId = calculatedField.getEntityId();
+
+ calculatedFields.put(calculatedFieldId, calculatedField);
+
+ entityIdCalculatedFields.computeIfAbsent(cfEntityId, entityId -> new CopyOnWriteArrayList<>()).add(calculatedField);
+
+ CalculatedFieldConfiguration configuration = calculatedField.getConfiguration();
+ calculatedFieldLinks.put(calculatedFieldId, configuration.buildCalculatedFieldLinks(tenantId, cfEntityId, calculatedFieldId));
+
+ configuration.getReferencedEntities().stream()
+ .filter(referencedEntityId -> !referencedEntityId.equals(cfEntityId))
+ .forEach(referencedEntityId -> {
+ entityIdCalculatedFieldLinks.computeIfAbsent(referencedEntityId, entityId -> new CopyOnWriteArrayList<>())
+ .add(configuration.buildCalculatedFieldLink(tenantId, referencedEntityId, calculatedFieldId));
+ });
+ } finally {
+ calculatedFieldFetchLock.unlock();
+ }
+ }
+
+ @Override
+ public void updateCalculatedField(TenantId tenantId, CalculatedFieldId calculatedFieldId) {
+ evict(calculatedFieldId);
+ addCalculatedField(tenantId, calculatedFieldId);
+ }
+
+ @Override
+ public void evict(CalculatedFieldId calculatedFieldId) {
+ CalculatedField oldCalculatedField = calculatedFields.remove(calculatedFieldId);
+ log.debug("[{}] evict calculated field from cache: {}", calculatedFieldId, oldCalculatedField);
+ calculatedFieldLinks.remove(calculatedFieldId);
+ log.debug("[{}] evict calculated field from cached calculated fields by entity id: {}", calculatedFieldId, oldCalculatedField);
+ entityIdCalculatedFields.forEach((entityId, calculatedFields) -> calculatedFields.removeIf(cf -> cf.getId().equals(calculatedFieldId)));
+ log.debug("[{}] evict calculated field links from cache: {}", calculatedFieldId, oldCalculatedField);
+ calculatedFieldsCtx.remove(calculatedFieldId);
+ log.debug("[{}] evict calculated field ctx from cache: {}", calculatedFieldId, oldCalculatedField);
+ entityIdCalculatedFieldLinks.forEach((entityId, calculatedFieldLinks) -> calculatedFieldLinks.removeIf(link -> link.getCalculatedFieldId().equals(calculatedFieldId)));
+ log.debug("[{}] evict calculated field links from cached links by entity id: {}", calculatedFieldId, oldCalculatedField);
+ }
+
+}
diff --git a/application/src/main/java/org/thingsboard/server/service/cf/DefaultCalculatedFieldInitService.java b/application/src/main/java/org/thingsboard/server/service/cf/DefaultCalculatedFieldInitService.java
new file mode 100644
index 0000000000..9442329edb
--- /dev/null
+++ b/application/src/main/java/org/thingsboard/server/service/cf/DefaultCalculatedFieldInitService.java
@@ -0,0 +1,59 @@
+/**
+ * Copyright © 2016-2025 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.cf;
+
+import lombok.Getter;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.stereotype.Service;
+import org.thingsboard.server.common.data.ProfileEntityIdInfo;
+import org.thingsboard.server.common.data.page.PageDataIterable;
+import org.thingsboard.server.dao.asset.AssetService;
+import org.thingsboard.server.dao.device.DeviceService;
+import org.thingsboard.server.queue.util.AfterStartUp;
+import org.thingsboard.server.queue.util.TbRuleEngineComponent;
+import org.thingsboard.server.service.cf.cache.CalculatedFieldEntityProfileCache;
+
+@Slf4j
+@Service
+@TbRuleEngineComponent
+@RequiredArgsConstructor
+public class DefaultCalculatedFieldInitService implements CalculatedFieldInitService {
+
+ private final CalculatedFieldEntityProfileCache entityProfileCache;
+ private final AssetService assetService;
+ private final DeviceService deviceService;
+
+ @Value("${calculated_fields.init_fetch_pack_size:50000}")
+ @Getter
+ private int initFetchPackSize;
+
+ @AfterStartUp(order = AfterStartUp.CF_READ_PROFILE_ENTITIES_SERVICE)
+ public void initCalculatedFieldDefinitions() {
+ PageDataIterable deviceIdInfos = new PageDataIterable<>(deviceService::findProfileEntityIdInfos, initFetchPackSize);
+ for (ProfileEntityIdInfo idInfo : deviceIdInfos) {
+ log.trace("Processing device record: {}", idInfo);
+ entityProfileCache.add(idInfo.getTenantId(), idInfo.getProfileId(), idInfo.getEntityId());
+ }
+ PageDataIterable assetIdInfos = new PageDataIterable<>(assetService::findProfileEntityIdInfos, initFetchPackSize);
+ for (ProfileEntityIdInfo idInfo : assetIdInfos) {
+ log.trace("Processing asset record: {}", idInfo);
+ entityProfileCache.add(idInfo.getTenantId(), idInfo.getProfileId(), idInfo.getEntityId());
+ }
+ }
+
+}
diff --git a/application/src/main/java/org/thingsboard/server/service/cf/DefaultCalculatedFieldProcessingService.java b/application/src/main/java/org/thingsboard/server/service/cf/DefaultCalculatedFieldProcessingService.java
new file mode 100644
index 0000000000..27bc0120c4
--- /dev/null
+++ b/application/src/main/java/org/thingsboard/server/service/cf/DefaultCalculatedFieldProcessingService.java
@@ -0,0 +1,325 @@
+/**
+ * Copyright © 2016-2025 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.cf;
+
+import com.google.common.util.concurrent.Futures;
+import com.google.common.util.concurrent.ListenableFuture;
+import com.google.common.util.concurrent.ListeningExecutorService;
+import com.google.common.util.concurrent.MoreExecutors;
+import jakarta.annotation.PostConstruct;
+import jakarta.annotation.PreDestroy;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.apache.commons.lang3.math.NumberUtils;
+import org.springframework.stereotype.Service;
+import org.thingsboard.common.util.JacksonUtil;
+import org.thingsboard.common.util.ThingsBoardExecutors;
+import org.thingsboard.server.actors.calculatedField.CalculatedFieldTelemetryMsg;
+import org.thingsboard.server.actors.calculatedField.MultipleTbCallback;
+import org.thingsboard.server.cluster.TbClusterService;
+import org.thingsboard.server.common.data.EntityType;
+import org.thingsboard.server.common.data.StringUtils;
+import org.thingsboard.server.common.data.cf.configuration.Argument;
+import org.thingsboard.server.common.data.cf.configuration.OutputType;
+import org.thingsboard.server.common.data.id.CalculatedFieldId;
+import org.thingsboard.server.common.data.id.EntityId;
+import org.thingsboard.server.common.data.id.TenantId;
+import org.thingsboard.server.common.data.kv.Aggregation;
+import org.thingsboard.server.common.data.kv.BaseAttributeKvEntry;
+import org.thingsboard.server.common.data.kv.BaseReadTsKvQuery;
+import org.thingsboard.server.common.data.kv.BasicTsKvEntry;
+import org.thingsboard.server.common.data.kv.BooleanDataEntry;
+import org.thingsboard.server.common.data.kv.DoubleDataEntry;
+import org.thingsboard.server.common.data.kv.KvEntry;
+import org.thingsboard.server.common.data.kv.ReadTsKvQuery;
+import org.thingsboard.server.common.data.kv.StringDataEntry;
+import org.thingsboard.server.common.data.kv.TsKvEntry;
+import org.thingsboard.server.common.data.msg.TbMsgType;
+import org.thingsboard.server.common.data.tenant.profile.DefaultTenantProfileConfiguration;
+import org.thingsboard.server.common.msg.TbMsg;
+import org.thingsboard.server.common.msg.TbMsgMetaData;
+import org.thingsboard.server.common.msg.queue.TbCallback;
+import org.thingsboard.server.common.msg.queue.TopicPartitionInfo;
+import org.thingsboard.server.dao.attributes.AttributesService;
+import org.thingsboard.server.dao.timeseries.TimeseriesService;
+import org.thingsboard.server.dao.usagerecord.ApiLimitService;
+import org.thingsboard.server.gen.transport.TransportProtos.CalculatedFieldLinkedTelemetryMsgProto;
+import org.thingsboard.server.gen.transport.TransportProtos.CalculatedFieldLinkedTelemetryMsgProto.Builder;
+import org.thingsboard.server.gen.transport.TransportProtos.CalculatedFieldTelemetryMsgProto;
+import org.thingsboard.server.gen.transport.TransportProtos.ToCalculatedFieldMsg;
+import org.thingsboard.server.gen.transport.TransportProtos.ToCalculatedFieldNotificationMsg;
+import org.thingsboard.server.queue.TbQueueCallback;
+import org.thingsboard.server.queue.TbQueueMsgMetadata;
+import org.thingsboard.server.queue.discovery.PartitionService;
+import org.thingsboard.server.queue.discovery.QueueKey;
+import org.thingsboard.server.queue.util.TbRuleEngineComponent;
+import org.thingsboard.server.service.cf.ctx.CalculatedFieldEntityCtxId;
+import org.thingsboard.server.service.cf.ctx.state.ArgumentEntry;
+import org.thingsboard.server.service.cf.ctx.state.CalculatedFieldCtx;
+import org.thingsboard.server.service.cf.ctx.state.CalculatedFieldState;
+import org.thingsboard.server.service.cf.ctx.state.ScriptCalculatedFieldState;
+import org.thingsboard.server.service.cf.ctx.state.SimpleCalculatedFieldState;
+import org.thingsboard.server.service.cf.ctx.state.SingleValueArgumentEntry;
+import org.thingsboard.server.service.cf.ctx.state.TsRollingArgumentEntry;
+
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Map.Entry;
+import java.util.Optional;
+import java.util.UUID;
+import java.util.concurrent.ExecutionException;
+import java.util.stream.Collectors;
+
+import static org.thingsboard.server.common.data.DataConstants.SCOPE;
+import static org.thingsboard.server.utils.CalculatedFieldUtils.toProto;
+
+@TbRuleEngineComponent
+@Service
+@Slf4j
+@RequiredArgsConstructor
+public class DefaultCalculatedFieldProcessingService implements CalculatedFieldProcessingService {
+
+ private final AttributesService attributesService;
+ private final TimeseriesService timeseriesService;
+ private final TbClusterService clusterService;
+ private final ApiLimitService apiLimitService;
+ private final PartitionService partitionService;
+
+ private ListeningExecutorService calculatedFieldCallbackExecutor;
+
+ @PostConstruct
+ public void init() {
+ calculatedFieldCallbackExecutor = MoreExecutors.listeningDecorator(ThingsBoardExecutors.newWorkStealingPool(
+ Math.max(4, Runtime.getRuntime().availableProcessors()), "calculated-field-callback"));
+ }
+
+ @PreDestroy
+ public void stop() {
+ if (calculatedFieldCallbackExecutor != null) {
+ calculatedFieldCallbackExecutor.shutdownNow();
+ }
+ }
+
+ @Override
+ public ListenableFuture fetchStateFromDb(CalculatedFieldCtx ctx, EntityId entityId) {
+ Map> argFutures = new HashMap<>();
+ for (var entry : ctx.getArguments().entrySet()) {
+ var argEntityId = entry.getValue().getRefEntityId() != null ? entry.getValue().getRefEntityId() : entityId;
+ var argValueFuture = fetchKvEntry(ctx.getTenantId(), argEntityId, entry.getValue());
+ argFutures.put(entry.getKey(), argValueFuture);
+ }
+ return Futures.whenAllComplete(argFutures.values()).call(() -> {
+ var result = createStateByType(ctx);
+ result.updateState(ctx, argFutures.entrySet().stream()
+ .collect(Collectors.toMap(
+ Entry::getKey, // Keep the key as is
+ entry -> {
+ try {
+ // Resolve the future to get the value
+ return entry.getValue().get();
+ } catch (ExecutionException | InterruptedException e) {
+ throw new RuntimeException("Error getting future result for key: " + entry.getKey(), e);
+ }
+ }
+ )));
+ return result;
+ }, calculatedFieldCallbackExecutor);
+ }
+
+ @Override
+ public Map fetchArgsFromDb(TenantId tenantId, EntityId entityId, Map arguments) {
+ Map> argFutures = new HashMap<>();
+ for (var entry : arguments.entrySet()) {
+ var argEntityId = entry.getValue().getRefEntityId() != null ? entry.getValue().getRefEntityId() : entityId;
+ var argValueFuture = fetchKvEntry(tenantId, argEntityId, entry.getValue());
+ argFutures.put(entry.getKey(), argValueFuture);
+ }
+ return argFutures.entrySet().stream()
+ .collect(Collectors.toMap(
+ Entry::getKey, // Keep the key as is
+ entry -> {
+ try {
+ // Resolve the future to get the value
+ return entry.getValue().get();
+ } catch (ExecutionException | InterruptedException e) {
+ throw new RuntimeException("Error getting future result for key: " + entry.getKey(), e);
+ }
+ }
+ ));
+ }
+
+ @Override
+ public void pushMsgToRuleEngine(TenantId tenantId, EntityId entityId, CalculatedFieldResult calculatedFieldResult, List cfIds, TbCallback callback) {
+ try {
+ OutputType type = calculatedFieldResult.getType();
+ TbMsgType msgType = OutputType.ATTRIBUTES.equals(type) ? TbMsgType.POST_ATTRIBUTES_REQUEST : TbMsgType.POST_TELEMETRY_REQUEST;
+ TbMsgMetaData md = OutputType.ATTRIBUTES.equals(type) ? new TbMsgMetaData(Map.of(SCOPE, calculatedFieldResult.getScope().name())) : TbMsgMetaData.EMPTY;
+ TbMsg msg = TbMsg.newMsg().type(msgType).originator(entityId).previousCalculatedFieldIds(cfIds).metaData(md).data(JacksonUtil.writeValueAsString(calculatedFieldResult.getResult())).build();
+ clusterService.pushMsgToRuleEngine(tenantId, entityId, msg, new TbQueueCallback() {
+ @Override
+ public void onSuccess(TbQueueMsgMetadata metadata) {
+ callback.onSuccess();
+ log.trace("[{}][{}] Pushed message to rule engine: {} ", tenantId, entityId, msg);
+ }
+
+ @Override
+ public void onFailure(Throwable t) {
+ callback.onFailure(t);
+ }
+ });
+ } catch (Exception e) {
+ log.warn("[{}][{}] Failed to push message to rule engine. CalculatedFieldResult: {}", tenantId, entityId, calculatedFieldResult, e);
+ callback.onFailure(e);
+ }
+ }
+
+ @Override
+ public void pushMsgToLinks(CalculatedFieldTelemetryMsg msg, List linkedCalculatedFields, TbCallback callback) {
+ Map> unicasts = new HashMap<>();
+ List broadcasts = new ArrayList<>();
+ for (CalculatedFieldEntityCtxId link : linkedCalculatedFields) {
+ var linkEntityId = link.entityId();
+ var linkEntityType = linkEntityId.getEntityType();
+ // Let's assume number of entities in profile is N, and number of partitions is P. If N > P, we save by broadcasting to all partitions. Usually N >> P.
+ boolean broadcast = EntityType.DEVICE_PROFILE.equals(linkEntityType) || EntityType.ASSET_PROFILE.equals(linkEntityType);
+ if (broadcast) {
+ broadcasts.add(link);
+ } else {
+ TopicPartitionInfo tpi = partitionService.resolve(QueueKey.CF, link.entityId());
+ unicasts.computeIfAbsent(tpi, k -> new ArrayList<>()).add(link);
+ }
+ }
+ MultipleTbCallback linkCallback = new MultipleTbCallback(2, callback);
+ if (!broadcasts.isEmpty()) {
+ broadcast(broadcasts, msg, linkCallback);
+ } else {
+ linkCallback.onSuccess();
+ }
+ if (!unicasts.isEmpty()) {
+ unicast(unicasts, msg, linkCallback);
+ } else {
+ linkCallback.onSuccess();
+ }
+ }
+
+ private void unicast(Map> unicasts, CalculatedFieldTelemetryMsg msg, MultipleTbCallback mainCallback) {
+ TbQueueCallback callback = new TbCallbackWrapper(new MultipleTbCallback(unicasts.size(), mainCallback));
+ unicasts.forEach((topicPartitionInfo, ctxIds) -> {
+ CalculatedFieldLinkedTelemetryMsgProto linkedTelemetryMsgProto = buildLinkedTelemetryMsgProto(msg.getProto(), ctxIds);
+ clusterService.pushMsgToCalculatedFields(topicPartitionInfo, UUID.randomUUID(),
+ ToCalculatedFieldMsg.newBuilder().setLinkedTelemetryMsg(linkedTelemetryMsgProto).build(), callback);
+ });
+ }
+
+ private void broadcast(List broadcasts, CalculatedFieldTelemetryMsg msg, MultipleTbCallback mainCallback) {
+ TbQueueCallback callback = new TbCallbackWrapper(mainCallback);
+ CalculatedFieldLinkedTelemetryMsgProto linkedTelemetryMsgProto = buildLinkedTelemetryMsgProto(msg.getProto(), broadcasts);
+ clusterService.broadcastToCalculatedFields(ToCalculatedFieldNotificationMsg.newBuilder().setLinkedTelemetryMsg(linkedTelemetryMsgProto).build(), callback);
+ }
+
+ private CalculatedFieldLinkedTelemetryMsgProto buildLinkedTelemetryMsgProto(CalculatedFieldTelemetryMsgProto telemetryProto, List links) {
+ Builder builder = CalculatedFieldLinkedTelemetryMsgProto.newBuilder();
+ builder.setMsg(telemetryProto);
+ for (CalculatedFieldEntityCtxId link : links) {
+ builder.addLinks(toProto(link));
+ }
+ return builder.build();
+ }
+
+ private ListenableFuture fetchKvEntry(TenantId tenantId, EntityId entityId, Argument argument) {
+ return switch (argument.getRefEntityKey().getType()) {
+ case TS_ROLLING -> fetchTsRolling(tenantId, entityId, argument);
+ case ATTRIBUTE -> transformSingleValueArgument(
+ Futures.transform(
+ attributesService.find(tenantId, entityId, argument.getRefEntityKey().getScope(), argument.getRefEntityKey().getKey()),
+ result -> result.or(() -> Optional.of(new BaseAttributeKvEntry(createDefaultKvEntry(argument), System.currentTimeMillis(), 0L))),
+ calculatedFieldCallbackExecutor)
+ );
+ case TS_LATEST -> transformSingleValueArgument(
+ Futures.transform(
+ timeseriesService.findLatest(tenantId, entityId, argument.getRefEntityKey().getKey()),
+ result -> result.or(() -> Optional.of(new BasicTsKvEntry(System.currentTimeMillis(), createDefaultKvEntry(argument), 0L))),
+ calculatedFieldCallbackExecutor));
+ };
+ }
+
+ private ListenableFuture transformSingleValueArgument(ListenableFuture> kvEntryFuture) {
+ return Futures.transform(kvEntryFuture, kvEntry -> {
+ if (kvEntry.isPresent() && kvEntry.get().getValue() != null) {
+ return ArgumentEntry.createSingleValueArgument(kvEntry.get());
+ } else {
+ return new SingleValueArgumentEntry();
+ }
+ }, calculatedFieldCallbackExecutor);
+ }
+
+ private ListenableFuture fetchTsRolling(TenantId tenantId, EntityId entityId, Argument argument) {
+ long currentTime = System.currentTimeMillis();
+ long timeWindow = argument.getTimeWindow() == 0 ? System.currentTimeMillis() : argument.getTimeWindow();
+ long startTs = currentTime - timeWindow;
+ long maxDataPoints = apiLimitService.getLimit(tenantId, DefaultTenantProfileConfiguration::getMaxDataPointsPerRollingArg);
+ int argumentLimit = argument.getLimit();
+ int limit = argumentLimit == 0 || argumentLimit > maxDataPoints ? (int) maxDataPoints : argument.getLimit();
+
+ ReadTsKvQuery query = new BaseReadTsKvQuery(argument.getRefEntityKey().getKey(), startTs, currentTime, 0, limit, Aggregation.NONE);
+ ListenableFuture> tsRollingFuture = timeseriesService.findAll(tenantId, entityId, List.of(query));
+
+ return Futures.transform(tsRollingFuture, tsRolling -> tsRolling == null ? new TsRollingArgumentEntry(limit, timeWindow) : ArgumentEntry.createTsRollingArgument(tsRolling, limit, timeWindow), calculatedFieldCallbackExecutor);
+ }
+
+ private KvEntry createDefaultKvEntry(Argument argument) {
+ String key = argument.getRefEntityKey().getKey();
+ String defaultValue = argument.getDefaultValue();
+ if (StringUtils.isBlank(defaultValue)) {
+ return new StringDataEntry(key, null);
+ }
+ if (NumberUtils.isParsable(defaultValue)) {
+ return new DoubleDataEntry(key, Double.parseDouble(defaultValue));
+ }
+ if ("true".equalsIgnoreCase(defaultValue) || "false".equalsIgnoreCase(defaultValue)) {
+ return new BooleanDataEntry(key, Boolean.parseBoolean(defaultValue));
+ }
+ return new StringDataEntry(key, defaultValue);
+ }
+
+ private CalculatedFieldState createStateByType(CalculatedFieldCtx ctx) {
+ return switch (ctx.getCfType()) {
+ case SIMPLE -> new SimpleCalculatedFieldState(ctx.getArgNames());
+ case SCRIPT -> new ScriptCalculatedFieldState(ctx.getArgNames());
+ };
+ }
+
+ private static class TbCallbackWrapper implements TbQueueCallback {
+ private final TbCallback callback;
+
+ public TbCallbackWrapper(TbCallback callback) {
+ this.callback = callback;
+ }
+
+ @Override
+ public void onSuccess(TbQueueMsgMetadata metadata) {
+ callback.onSuccess();
+ }
+
+ @Override
+ public void onFailure(Throwable t) {
+ callback.onFailure(t);
+ }
+ }
+
+}
diff --git a/application/src/main/java/org/thingsboard/server/service/cf/DefaultCalculatedFieldQueueService.java b/application/src/main/java/org/thingsboard/server/service/cf/DefaultCalculatedFieldQueueService.java
new file mode 100644
index 0000000000..ba4be6ace6
--- /dev/null
+++ b/application/src/main/java/org/thingsboard/server/service/cf/DefaultCalculatedFieldQueueService.java
@@ -0,0 +1,275 @@
+/**
+ * Copyright © 2016-2025 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.cf;
+
+import com.google.common.util.concurrent.FutureCallback;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Service;
+import org.thingsboard.rule.engine.api.AttributesDeleteRequest;
+import org.thingsboard.rule.engine.api.AttributesSaveRequest;
+import org.thingsboard.rule.engine.api.TimeseriesDeleteRequest;
+import org.thingsboard.rule.engine.api.TimeseriesSaveRequest;
+import org.thingsboard.server.cluster.TbClusterService;
+import org.thingsboard.server.common.data.EntityType;
+import org.thingsboard.server.common.data.cf.CalculatedFieldLink;
+import org.thingsboard.server.common.data.id.AssetId;
+import org.thingsboard.server.common.data.id.CalculatedFieldId;
+import org.thingsboard.server.common.data.id.DeviceId;
+import org.thingsboard.server.common.data.id.EntityId;
+import org.thingsboard.server.common.data.id.TenantId;
+import org.thingsboard.server.common.data.kv.AttributeKvEntry;
+import org.thingsboard.server.common.data.kv.TimeseriesSaveResult;
+import org.thingsboard.server.common.data.kv.TsKvEntry;
+import org.thingsboard.server.common.data.msg.TbMsgType;
+import org.thingsboard.server.common.util.ProtoUtils;
+import org.thingsboard.server.gen.transport.TransportProtos.AttributeScopeProto;
+import org.thingsboard.server.gen.transport.TransportProtos.AttributeValueProto;
+import org.thingsboard.server.gen.transport.TransportProtos.CalculatedFieldTelemetryMsgProto;
+import org.thingsboard.server.gen.transport.TransportProtos.ToCalculatedFieldMsg;
+import org.thingsboard.server.gen.transport.TransportProtos.TsKvProto;
+import org.thingsboard.server.queue.TbQueueCallback;
+import org.thingsboard.server.queue.TbQueueMsgMetadata;
+import org.thingsboard.server.service.cf.ctx.state.CalculatedFieldCtx;
+import org.thingsboard.server.service.profile.TbAssetProfileCache;
+import org.thingsboard.server.service.profile.TbDeviceProfileCache;
+
+import java.util.Collections;
+import java.util.EnumSet;
+import java.util.List;
+import java.util.Set;
+import java.util.UUID;
+import java.util.function.Predicate;
+import java.util.function.Supplier;
+
+import static org.thingsboard.server.common.util.ProtoUtils.toTsKvProto;
+import static org.thingsboard.server.utils.CalculatedFieldUtils.toProto;
+
+@Service
+@Slf4j
+@RequiredArgsConstructor
+public class DefaultCalculatedFieldQueueService implements CalculatedFieldQueueService {
+
+ public static final TbQueueCallback DUMMY_TB_QUEUE_CALLBACK = new TbQueueCallback() {
+ @Override
+ public void onSuccess(TbQueueMsgMetadata metadata) {
+ }
+
+ @Override
+ public void onFailure(Throwable t) {
+ }
+ };
+
+ private final TbAssetProfileCache assetProfileCache;
+ private final TbDeviceProfileCache deviceProfileCache;
+ private final CalculatedFieldCache calculatedFieldCache;
+ private final TbClusterService clusterService;
+
+ private static final Set supportedReferencedEntities = EnumSet.of(
+ EntityType.DEVICE, EntityType.ASSET, EntityType.CUSTOMER, EntityType.TENANT
+ );
+
+ @Override
+ public void pushRequestToQueue(TimeseriesSaveRequest request, TimeseriesSaveResult result, FutureCallback callback) {
+ var tenantId = request.getTenantId();
+ var entityId = request.getEntityId();
+ checkEntityAndPushToQueue(tenantId, entityId, cf -> cf.matches(request.getEntries()), cf -> cf.linkMatches(entityId, request.getEntries()),
+ () -> toCalculatedFieldTelemetryMsgProto(request, result), callback);
+ }
+
+ @Override
+ public void pushRequestToQueue(TimeseriesSaveRequest request, FutureCallback callback) {
+ pushRequestToQueue(request, null, callback);
+ }
+
+ @Override
+ public void pushRequestToQueue(AttributesSaveRequest request, List result, FutureCallback callback) {
+ var tenantId = request.getTenantId();
+ var entityId = request.getEntityId();
+ checkEntityAndPushToQueue(tenantId, entityId, cf -> cf.matches(request.getEntries(), request.getScope()), cf -> cf.linkMatches(entityId, request.getEntries(), request.getScope()),
+ () -> toCalculatedFieldTelemetryMsgProto(request, result), callback);
+ }
+
+ @Override
+ public void pushRequestToQueue(AttributesSaveRequest request, FutureCallback callback) {
+ pushRequestToQueue(request, null, callback);
+ }
+
+ @Override
+ public void pushRequestToQueue(AttributesDeleteRequest request, List result, FutureCallback callback) {
+ var tenantId = request.getTenantId();
+ var entityId = request.getEntityId();
+ checkEntityAndPushToQueue(tenantId, entityId, cf -> cf.matchesKeys(result, request.getScope()), cf -> cf.linkMatchesAttrKeys(entityId, result, request.getScope()),
+ () -> toCalculatedFieldTelemetryMsgProto(request, result), callback);
+ }
+
+ @Override
+ public void pushRequestToQueue(TimeseriesDeleteRequest request, List result, FutureCallback callback) {
+ var tenantId = request.getTenantId();
+ var entityId = request.getEntityId();
+
+ checkEntityAndPushToQueue(tenantId, entityId, cf -> cf.matchesKeys(result), cf -> cf.linkMatchesTsKeys(entityId, result),
+ () -> toCalculatedFieldTelemetryMsgProto(request, result), callback);
+ }
+
+ private void checkEntityAndPushToQueue(TenantId tenantId, EntityId entityId,
+ Predicate mainEntityFilter, Predicate linkedEntityFilter,
+ Supplier msg, FutureCallback callback) {
+ boolean send = checkEntityForCalculatedFields(tenantId, entityId, mainEntityFilter, linkedEntityFilter);
+ if (send) {
+ clusterService.pushMsgToCalculatedFields(tenantId, entityId, msg.get(), wrap(callback));
+ } else {
+ if (callback != null) {
+ callback.onSuccess(null);
+ }
+ }
+ }
+
+ private boolean checkEntityForCalculatedFields(TenantId tenantId, EntityId entityId, Predicate filter, Predicate linkedEntityFilter) {
+ boolean send = false;
+ if (supportedReferencedEntities.contains(entityId.getEntityType())) {
+ send = calculatedFieldCache.getCalculatedFieldCtxsByEntityId(entityId).stream().anyMatch(filter);
+ if (!send) {
+ send = calculatedFieldCache.getCalculatedFieldCtxsByEntityId(getProfileId(tenantId, entityId)).stream().anyMatch(filter);
+ }
+ if (!send) {
+ send = calculatedFieldCache.getCalculatedFieldLinksByEntityId(entityId).stream()
+ .map(CalculatedFieldLink::getCalculatedFieldId)
+ .map(calculatedFieldCache::getCalculatedFieldCtx)
+ .anyMatch(linkedEntityFilter);
+ }
+ }
+ return send;
+ }
+
+ private EntityId getProfileId(TenantId tenantId, EntityId entityId) {
+ return switch (entityId.getEntityType()) {
+ case ASSET -> assetProfileCache.get(tenantId, (AssetId) entityId).getId();
+ case DEVICE -> deviceProfileCache.get(tenantId, (DeviceId) entityId).getId();
+ default -> null;
+ };
+ }
+
+ private ToCalculatedFieldMsg toCalculatedFieldTelemetryMsgProto(TimeseriesSaveRequest request, TimeseriesSaveResult result) {
+ ToCalculatedFieldMsg.Builder msg = ToCalculatedFieldMsg.newBuilder();
+
+ CalculatedFieldTelemetryMsgProto.Builder telemetryMsg = buildTelemetryMsgProto(request.getTenantId(), request.getEntityId(), request.getPreviousCalculatedFieldIds(), request.getTbMsgId(), request.getTbMsgType());
+
+ List entries = request.getEntries();
+ List versions = result != null ? result.getVersions() : Collections.emptyList();
+
+ for (int i = 0; i < entries.size(); i++) {
+ TsKvProto.Builder tsProtoBuilder = toTsKvProto(entries.get(i)).toBuilder();
+ if (result != null) {
+ tsProtoBuilder.setVersion(versions.get(i));
+ }
+ telemetryMsg.addTsData(tsProtoBuilder.build());
+ }
+
+ msg.setTelemetryMsg(telemetryMsg.build());
+ return msg.build();
+ }
+
+ private ToCalculatedFieldMsg toCalculatedFieldTelemetryMsgProto(AttributesSaveRequest request, List versions) {
+ ToCalculatedFieldMsg.Builder msg = ToCalculatedFieldMsg.newBuilder();
+
+ CalculatedFieldTelemetryMsgProto.Builder telemetryMsg = buildTelemetryMsgProto(request.getTenantId(), request.getEntityId(), request.getPreviousCalculatedFieldIds(), request.getTbMsgId(), request.getTbMsgType());
+ telemetryMsg.setScope(AttributeScopeProto.valueOf(request.getScope().name()));
+ List entries = request.getEntries();
+ for (int i = 0; i < entries.size(); i++) {
+ AttributeValueProto.Builder attrProtoBuilder = ProtoUtils.toProto(entries.get(i)).toBuilder();
+ if (versions != null) {
+ attrProtoBuilder.setVersion(versions.get(i));
+ }
+ telemetryMsg.addAttrData(attrProtoBuilder.build());
+ }
+ msg.setTelemetryMsg(telemetryMsg.build());
+
+ return msg.build();
+ }
+
+ private ToCalculatedFieldMsg toCalculatedFieldTelemetryMsgProto(AttributesDeleteRequest request, List removedKeys) {
+ CalculatedFieldTelemetryMsgProto telemetryMsg = buildTelemetryMsgProto(request.getTenantId(), request.getEntityId(), request.getPreviousCalculatedFieldIds(), request.getTbMsgId(), request.getTbMsgType())
+ .setScope(AttributeScopeProto.valueOf(request.getScope().name()))
+ .addAllRemovedAttrKeys(removedKeys).build();
+ return ToCalculatedFieldMsg.newBuilder()
+ .setTelemetryMsg(telemetryMsg)
+ .build();
+ }
+
+ private ToCalculatedFieldMsg toCalculatedFieldTelemetryMsgProto(TimeseriesDeleteRequest request, List removedKeys) {
+ CalculatedFieldTelemetryMsgProto telemetryMsg = buildTelemetryMsgProto(request.getTenantId(), request.getEntityId(), request.getPreviousCalculatedFieldIds(), request.getTbMsgId(), request.getTbMsgType())
+ .addAllRemovedTsKeys(removedKeys).build();
+ return ToCalculatedFieldMsg.newBuilder()
+ .setTelemetryMsg(telemetryMsg)
+ .build();
+ }
+
+ private CalculatedFieldTelemetryMsgProto.Builder buildTelemetryMsgProto(TenantId tenantId, EntityId entityId, List calculatedFieldIds, UUID tbMsgId, TbMsgType tbMsgType) {
+ CalculatedFieldTelemetryMsgProto.Builder telemetryMsg = CalculatedFieldTelemetryMsgProto.newBuilder();
+
+ telemetryMsg.setTenantIdMSB(tenantId.getId().getMostSignificantBits());
+ telemetryMsg.setTenantIdLSB(tenantId.getId().getLeastSignificantBits());
+
+ telemetryMsg.setEntityType(entityId.getEntityType().name());
+ telemetryMsg.setEntityIdMSB(entityId.getId().getMostSignificantBits());
+ telemetryMsg.setEntityIdLSB(entityId.getId().getLeastSignificantBits());
+
+ if (calculatedFieldIds != null) {
+ for (CalculatedFieldId cfId : calculatedFieldIds) {
+ telemetryMsg.addPreviousCalculatedFields(toProto(cfId));
+ }
+ }
+
+ if (tbMsgId != null) {
+ telemetryMsg.setTbMsgIdMSB(tbMsgId.getMostSignificantBits());
+ telemetryMsg.setTbMsgIdLSB(tbMsgId.getLeastSignificantBits());
+ }
+
+ if (tbMsgType != null) {
+ telemetryMsg.setTbMsgType(tbMsgType.name());
+ }
+
+ return telemetryMsg;
+ }
+
+ private static TbQueueCallback wrap(FutureCallback callback) {
+ if (callback != null) {
+ return new FutureCallbackWrapper(callback);
+ } else {
+ return DUMMY_TB_QUEUE_CALLBACK;
+ }
+ }
+
+ private static class FutureCallbackWrapper implements TbQueueCallback {
+ private final FutureCallback callback;
+
+ public FutureCallbackWrapper(FutureCallback callback) {
+ this.callback = callback;
+ }
+
+ @Override
+ public void onSuccess(TbQueueMsgMetadata metadata) {
+ callback.onSuccess(null);
+ }
+
+ @Override
+ public void onFailure(Throwable t) {
+ callback.onFailure(t);
+ }
+ }
+
+}
diff --git a/application/src/main/java/org/thingsboard/server/service/cf/cache/CalculatedFieldEntityProfileCache.java b/application/src/main/java/org/thingsboard/server/service/cf/cache/CalculatedFieldEntityProfileCache.java
new file mode 100644
index 0000000000..bb5ef91974
--- /dev/null
+++ b/application/src/main/java/org/thingsboard/server/service/cf/cache/CalculatedFieldEntityProfileCache.java
@@ -0,0 +1,36 @@
+/**
+ * Copyright © 2016-2025 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.cf.cache;
+
+import org.springframework.context.ApplicationListener;
+import org.thingsboard.server.common.data.id.EntityId;
+import org.thingsboard.server.common.data.id.TenantId;
+import org.thingsboard.server.queue.discovery.event.PartitionChangeEvent;
+
+import java.util.Collection;
+
+public interface CalculatedFieldEntityProfileCache extends ApplicationListener {
+
+ void add(TenantId tenantId, EntityId profileId, EntityId entityId);
+
+ void update(TenantId tenantId, EntityId oldProfileId, EntityId newProfileId, EntityId entityId);
+
+ void evict(TenantId tenantId, EntityId entityId);
+
+ Collection getMyEntityIdsByProfileId(TenantId tenantId, EntityId profileId);
+
+ int getEntityIdPartition(TenantId tenantId, EntityId entityId);
+}
diff --git a/application/src/main/java/org/thingsboard/server/service/cf/cache/DefaultCalculatedFieldEntityProfileCache.java b/application/src/main/java/org/thingsboard/server/service/cf/cache/DefaultCalculatedFieldEntityProfileCache.java
new file mode 100644
index 0000000000..4cf62c01b3
--- /dev/null
+++ b/application/src/main/java/org/thingsboard/server/service/cf/cache/DefaultCalculatedFieldEntityProfileCache.java
@@ -0,0 +1,93 @@
+/**
+ * Copyright © 2016-2025 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.cf.cache;
+
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Service;
+import org.thingsboard.server.common.data.id.EntityId;
+import org.thingsboard.server.common.data.id.TenantId;
+import org.thingsboard.server.common.msg.queue.TopicPartitionInfo;
+import org.thingsboard.server.queue.discovery.PartitionService;
+import org.thingsboard.server.queue.discovery.QueueKey;
+import org.thingsboard.server.queue.discovery.TbApplicationEventListener;
+import org.thingsboard.server.queue.discovery.event.PartitionChangeEvent;
+import org.thingsboard.server.queue.util.TbRuleEngineComponent;
+
+import java.util.Collection;
+import java.util.Collections;
+import java.util.List;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.ConcurrentMap;
+import java.util.stream.Collectors;
+
+@TbRuleEngineComponent
+@Service
+@Slf4j
+@RequiredArgsConstructor
+//TODO ashvayka: remove and use TenantEntityProfileCache in each CalculatedFieldManagerMessageProcessor;
+public class DefaultCalculatedFieldEntityProfileCache extends TbApplicationEventListener implements CalculatedFieldEntityProfileCache {
+
+ private static final Integer UNKNOWN = 0;
+ private final ConcurrentMap tenantCache = new ConcurrentHashMap<>();
+ private final PartitionService partitionService;
+ private volatile List myPartitions = Collections.emptyList();
+
+ @Override
+ protected void onTbApplicationEvent(PartitionChangeEvent event) {
+ myPartitions = event.getCfPartitions().stream()
+ .filter(TopicPartitionInfo::isMyPartition)
+ .map(tpi -> tpi.getPartition().orElse(UNKNOWN)).collect(Collectors.toList());
+ //Naive approach that need to be improved.
+ tenantCache.values().forEach(cache -> cache.setMyPartitions(myPartitions));
+ }
+
+ @Override
+ public void add(TenantId tenantId, EntityId profileId, EntityId entityId) {
+ var tpi = partitionService.resolve(QueueKey.CF, entityId);
+ var partition = tpi.getPartition().orElse(UNKNOWN);
+ tenantCache.computeIfAbsent(tenantId, id -> new TenantEntityProfileCache())
+ .add(profileId, entityId, partition, tpi.isMyPartition());
+ }
+
+ @Override
+ public void update(TenantId tenantId, EntityId oldProfileId, EntityId newProfileId, EntityId entityId) {
+ var tpi = partitionService.resolve(QueueKey.CF, entityId);
+ var partition = tpi.getPartition().orElse(UNKNOWN);
+ var cache = tenantCache.computeIfAbsent(tenantId, id -> new TenantEntityProfileCache());
+ //TODO: make this method atomic;
+ cache.remove(oldProfileId, entityId);
+ cache.add(newProfileId, entityId, partition, tpi.isMyPartition());
+ }
+
+ @Override
+ public void evict(TenantId tenantId, EntityId entityId) {
+ var cache = tenantCache.computeIfAbsent(tenantId, id -> new TenantEntityProfileCache());
+ cache.removeEntityId(entityId);
+ }
+
+ @Override
+ public Collection getMyEntityIdsByProfileId(TenantId tenantId, EntityId profileId) {
+ return tenantCache.computeIfAbsent(tenantId, id -> new TenantEntityProfileCache()).getMyEntityIdsByProfileId(profileId);
+ }
+
+ @Override
+ public int getEntityIdPartition(TenantId tenantId, EntityId entityId) {
+ var tpi = partitionService.resolve(QueueKey.CF, entityId);
+ return tpi.getPartition().orElse(UNKNOWN);
+ }
+
+}
diff --git a/application/src/main/java/org/thingsboard/server/service/cf/cache/TenantEntityProfileCache.java b/application/src/main/java/org/thingsboard/server/service/cf/cache/TenantEntityProfileCache.java
new file mode 100644
index 0000000000..1a17b9b8be
--- /dev/null
+++ b/application/src/main/java/org/thingsboard/server/service/cf/cache/TenantEntityProfileCache.java
@@ -0,0 +1,122 @@
+/**
+ * Copyright © 2016-2025 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.cf.cache;
+
+import org.thingsboard.server.common.data.EntityType;
+import org.thingsboard.server.common.data.id.EntityId;
+
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.concurrent.locks.ReadWriteLock;
+import java.util.concurrent.locks.ReentrantReadWriteLock;
+
+public class TenantEntityProfileCache {
+
+ private final ReadWriteLock lock = new ReentrantReadWriteLock();
+ private final Map>> allEntities = new HashMap<>();
+ private final Map> myEntities = new HashMap<>();
+
+ public void setMyPartitions(List myPartitions) {
+ lock.writeLock().lock();
+ try {
+ myEntities.clear();
+ myPartitions.forEach(partitionId -> {
+ var map = allEntities.get(partitionId);
+ if (map != null) {
+ map.forEach((profileId, entityIds) -> myEntities.computeIfAbsent(profileId, k -> new HashSet<>()).addAll(entityIds));
+ }
+ });
+ } finally {
+ lock.writeLock().unlock();
+ }
+ }
+
+ public void removeProfileId(EntityId profileId) {
+ lock.writeLock().lock();
+ try {
+ // Remove from allEntities
+ allEntities.values().forEach(map -> map.remove(profileId));
+ // Remove from myEntities
+ myEntities.remove(profileId);
+ } finally {
+ lock.writeLock().unlock();
+ }
+ }
+
+ public void removeEntityId(EntityId entityId) {
+ lock.writeLock().lock();
+ try {
+ // Remove from allEntities
+ allEntities.values().forEach(map -> map.values().forEach(set -> set.remove(entityId)));
+ // Remove from myEntities
+ myEntities.values().forEach(set -> set.remove(entityId));
+ } finally {
+ lock.writeLock().unlock();
+ }
+ }
+
+ public void remove(EntityId profileId, EntityId entityId) {
+ lock.writeLock().lock();
+ try {
+ // Remove from allEntities
+ allEntities.values().forEach(map -> removeSafely(map, profileId, entityId));
+ // Remove from myEntities
+ removeSafely(myEntities, profileId, entityId);
+ } finally {
+ lock.writeLock().unlock();
+ }
+ }
+
+ public void add(EntityId profileId, EntityId entityId, Integer partition, boolean mine) {
+ lock.writeLock().lock();
+ try {
+ if(EntityType.DEVICE.equals(profileId.getEntityType())){
+ throw new RuntimeException("WTF?");
+ }
+ if (mine) {
+ myEntities.computeIfAbsent(profileId, k -> new HashSet<>()).add(entityId);
+ }
+ allEntities.computeIfAbsent(partition, k -> new HashMap<>()).computeIfAbsent(profileId, p -> new HashSet<>()).add(entityId);
+ } finally {
+ lock.writeLock().unlock();
+ }
+ }
+
+ public Collection getMyEntityIdsByProfileId(EntityId profileId) {
+ lock.readLock().lock();
+ try {
+ var entities = myEntities.getOrDefault(profileId, Collections.emptySet());
+ List result = new ArrayList<>(entities.size());
+ result.addAll(entities);
+ return result;
+ } finally {
+ lock.readLock().unlock();
+ }
+ }
+
+ private void removeSafely(Map> map, EntityId profileId, EntityId entityId) {
+ var set = map.get(profileId);
+ if (set != null) {
+ set.remove(entityId);
+ }
+ }
+}
diff --git a/application/src/main/java/org/thingsboard/server/service/cf/ctx/CalculatedFieldEntityCtx.java b/application/src/main/java/org/thingsboard/server/service/cf/ctx/CalculatedFieldEntityCtx.java
new file mode 100644
index 0000000000..6694252652
--- /dev/null
+++ b/application/src/main/java/org/thingsboard/server/service/cf/ctx/CalculatedFieldEntityCtx.java
@@ -0,0 +1,34 @@
+/**
+ * Copyright © 2016-2025 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.cf.ctx;
+
+import lombok.Data;
+import lombok.NoArgsConstructor;
+import org.thingsboard.server.service.cf.ctx.state.CalculatedFieldState;
+
+@Data
+@NoArgsConstructor
+public class CalculatedFieldEntityCtx {
+
+ private CalculatedFieldEntityCtxId id;
+ private CalculatedFieldState state;
+
+ public CalculatedFieldEntityCtx(CalculatedFieldEntityCtxId id, CalculatedFieldState state) {
+ this.id = id;
+ this.state = state;
+ }
+
+}
diff --git a/application/src/main/java/org/thingsboard/server/service/cf/ctx/CalculatedFieldEntityCtxId.java b/application/src/main/java/org/thingsboard/server/service/cf/ctx/CalculatedFieldEntityCtxId.java
new file mode 100644
index 0000000000..329028eda4
--- /dev/null
+++ b/application/src/main/java/org/thingsboard/server/service/cf/ctx/CalculatedFieldEntityCtxId.java
@@ -0,0 +1,28 @@
+/**
+ * Copyright © 2016-2025 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.cf.ctx;
+
+import org.thingsboard.server.common.data.id.CalculatedFieldId;
+import org.thingsboard.server.common.data.id.EntityId;
+import org.thingsboard.server.common.data.id.TenantId;
+
+public record CalculatedFieldEntityCtxId(TenantId tenantId, CalculatedFieldId cfId, EntityId entityId) {
+
+ public String toKey() {
+ return cfId + "_" + entityId;
+ }
+
+}
diff --git a/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/ArgumentEntry.java b/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/ArgumentEntry.java
new file mode 100644
index 0000000000..83e10b8194
--- /dev/null
+++ b/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/ArgumentEntry.java
@@ -0,0 +1,61 @@
+/**
+ * Copyright © 2016-2025 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.cf.ctx.state;
+
+import com.fasterxml.jackson.annotation.JsonIgnore;
+import com.fasterxml.jackson.annotation.JsonSubTypes;
+import com.fasterxml.jackson.annotation.JsonTypeInfo;
+import org.thingsboard.script.api.tbel.TbelCfArg;
+import org.thingsboard.server.common.data.kv.KvEntry;
+import org.thingsboard.server.common.data.kv.TsKvEntry;
+
+import java.util.List;
+
+@JsonTypeInfo(
+ use = JsonTypeInfo.Id.NAME,
+ include = JsonTypeInfo.As.PROPERTY,
+ property = "type"
+)
+@JsonSubTypes({
+ @JsonSubTypes.Type(value = SingleValueArgumentEntry.class, name = "SINGLE_VALUE"),
+ @JsonSubTypes.Type(value = TsRollingArgumentEntry.class, name = "TS_ROLLING")
+})
+public interface ArgumentEntry {
+
+ @JsonIgnore
+ ArgumentEntryType getType();
+
+ Object getValue();
+
+ boolean updateEntry(ArgumentEntry entry);
+
+ boolean isEmpty();
+
+ TbelCfArg toTbelCfArg();
+
+ boolean isForceResetPrevious();
+
+ void setForceResetPrevious(boolean forceResetPrevious);
+
+ static ArgumentEntry createSingleValueArgument(KvEntry kvEntry) {
+ return new SingleValueArgumentEntry(kvEntry);
+ }
+
+ static ArgumentEntry createTsRollingArgument(List kvEntries, int limit, long timeWindow) {
+ return new TsRollingArgumentEntry(kvEntries, limit, timeWindow);
+ }
+
+}
diff --git a/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/ArgumentEntryType.java b/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/ArgumentEntryType.java
new file mode 100644
index 0000000000..68f973c7c1
--- /dev/null
+++ b/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/ArgumentEntryType.java
@@ -0,0 +1,20 @@
+/**
+ * Copyright © 2016-2025 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.cf.ctx.state;
+
+public enum ArgumentEntryType {
+ SINGLE_VALUE, TS_ROLLING
+}
diff --git a/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/BaseCalculatedFieldState.java b/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/BaseCalculatedFieldState.java
new file mode 100644
index 0000000000..80b003b3cc
--- /dev/null
+++ b/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/BaseCalculatedFieldState.java
@@ -0,0 +1,103 @@
+/**
+ * Copyright © 2016-2025 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.cf.ctx.state;
+
+import lombok.AllArgsConstructor;
+import lombok.Data;
+import org.thingsboard.server.service.cf.ctx.CalculatedFieldEntityCtxId;
+import org.thingsboard.server.utils.CalculatedFieldUtils;
+
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+import static org.thingsboard.server.utils.CalculatedFieldUtils.toSingleValueArgumentProto;
+
+@Data
+@AllArgsConstructor
+public abstract class BaseCalculatedFieldState implements CalculatedFieldState {
+
+ protected List requiredArguments;
+ protected Map arguments;
+ protected boolean sizeExceedsLimit;
+
+ public BaseCalculatedFieldState(List requiredArguments) {
+ this.requiredArguments = requiredArguments;
+ this.arguments = new HashMap<>();
+ }
+
+ public BaseCalculatedFieldState() {
+ this(new ArrayList<>(), new HashMap<>(), false);
+ }
+
+ @Override
+ public boolean updateState(CalculatedFieldCtx ctx, Map argumentValues) {
+ if (arguments == null) {
+ arguments = new HashMap<>();
+ }
+
+ boolean stateUpdated = false;
+
+ for (Map.Entry entry : argumentValues.entrySet()) {
+ String key = entry.getKey();
+ ArgumentEntry newEntry = entry.getValue();
+
+ checkArgumentSize(key, newEntry, ctx);
+
+ ArgumentEntry existingEntry = arguments.get(key);
+
+ if (existingEntry == null || newEntry.isForceResetPrevious()) {
+ validateNewEntry(newEntry);
+ arguments.put(key, newEntry);
+ stateUpdated = true;
+ } else {
+ stateUpdated = existingEntry.updateEntry(newEntry);
+ }
+ }
+
+ return stateUpdated;
+ }
+
+ @Override
+ public boolean isReady() {
+ return arguments.keySet().containsAll(requiredArguments) &&
+ arguments.values().stream().noneMatch(ArgumentEntry::isEmpty);
+ }
+
+ @Override
+ public void checkStateSize(CalculatedFieldEntityCtxId ctxId, long maxStateSize) {
+ if (!sizeExceedsLimit && maxStateSize > 0 && CalculatedFieldUtils.toProto(ctxId, this).getSerializedSize() > maxStateSize) {
+ arguments.clear();
+ sizeExceedsLimit = true;
+ }
+ }
+
+ @Override
+ public void checkArgumentSize(String name, ArgumentEntry entry, CalculatedFieldCtx ctx) {
+ if (entry instanceof TsRollingArgumentEntry) {
+ return;
+ }
+ if (entry instanceof SingleValueArgumentEntry singleValueArgumentEntry) {
+ if (ctx.getMaxSingleValueArgumentSize() > 0 && toSingleValueArgumentProto(name, singleValueArgumentEntry).getSerializedSize() > ctx.getMaxSingleValueArgumentSize()) {
+ throw new IllegalArgumentException("Single value size exceeds the maximum allowed limit. The argument will not be used for calculation.");
+ }
+ }
+ }
+
+ protected abstract void validateNewEntry(ArgumentEntry newEntry);
+
+}
diff --git a/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/CalculatedFieldCtx.java b/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/CalculatedFieldCtx.java
new file mode 100644
index 0000000000..e5a3d0e05e
--- /dev/null
+++ b/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/CalculatedFieldCtx.java
@@ -0,0 +1,282 @@
+/**
+ * Copyright © 2016-2025 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.cf.ctx.state;
+
+import lombok.Data;
+import net.objecthunter.exp4j.Expression;
+import net.objecthunter.exp4j.ExpressionBuilder;
+import org.mvel2.MVEL;
+import org.thingsboard.script.api.tbel.TbelInvokeService;
+import org.thingsboard.server.common.data.AttributeScope;
+import org.thingsboard.server.common.data.cf.CalculatedField;
+import org.thingsboard.server.common.data.cf.CalculatedFieldType;
+import org.thingsboard.server.common.data.cf.configuration.Argument;
+import org.thingsboard.server.common.data.cf.configuration.ArgumentType;
+import org.thingsboard.server.common.data.cf.configuration.CalculatedFieldConfiguration;
+import org.thingsboard.server.common.data.cf.configuration.Output;
+import org.thingsboard.server.common.data.cf.configuration.ReferencedEntityKey;
+import org.thingsboard.server.common.data.id.CalculatedFieldId;
+import org.thingsboard.server.common.data.id.EntityId;
+import org.thingsboard.server.common.data.id.TenantId;
+import org.thingsboard.server.common.data.kv.AttributeKvEntry;
+import org.thingsboard.server.common.data.kv.TsKvEntry;
+import org.thingsboard.server.common.data.tenant.profile.DefaultTenantProfileConfiguration;
+import org.thingsboard.server.common.data.util.TbPair;
+import org.thingsboard.server.common.util.ProtoUtils;
+import org.thingsboard.server.dao.usagerecord.ApiLimitService;
+import org.thingsboard.server.gen.transport.TransportProtos.CalculatedFieldTelemetryMsgProto;
+import org.thingsboard.server.service.cf.ctx.CalculatedFieldEntityCtxId;
+
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.stream.Collectors;
+
+@Data
+public class CalculatedFieldCtx {
+
+ private CalculatedField calculatedField;
+
+ private CalculatedFieldId cfId;
+ private TenantId tenantId;
+ private EntityId entityId;
+ private CalculatedFieldType cfType;
+ private final Map arguments;
+ private final Map mainEntityArguments;
+ private final Map> linkedEntityArguments;
+
+ private final Map, String> referencedEntityKeys;
+ private final List argNames;
+ private Output output;
+ private String expression;
+ private TbelInvokeService tbelInvokeService;
+ private CalculatedFieldScriptEngine calculatedFieldScriptEngine;
+ private ThreadLocal customExpression;
+
+ private boolean initialized;
+
+ private long maxDataPointsPerRollingArg;
+ private long maxStateSize;
+ private long maxSingleValueArgumentSize;
+
+ public CalculatedFieldCtx(CalculatedField calculatedField, TbelInvokeService tbelInvokeService, ApiLimitService apiLimitService) {
+ this.calculatedField = calculatedField;
+
+ this.cfId = calculatedField.getId();
+ this.tenantId = calculatedField.getTenantId();
+ this.entityId = calculatedField.getEntityId();
+ this.cfType = calculatedField.getType();
+ CalculatedFieldConfiguration configuration = calculatedField.getConfiguration();
+ this.arguments = configuration.getArguments();
+ this.mainEntityArguments = new HashMap<>();
+ this.linkedEntityArguments = new HashMap<>();
+ for (Map.Entry entry : arguments.entrySet()) {
+ var refId = entry.getValue().getRefEntityId();
+ var refKey = entry.getValue().getRefEntityKey();
+ if (refId == null) {
+ mainEntityArguments.put(refKey, entry.getKey());
+ } else {
+ linkedEntityArguments.computeIfAbsent(refId, key -> new HashMap<>()).put(refKey, entry.getKey());
+ }
+ }
+ this.referencedEntityKeys = arguments.entrySet().stream()
+ .collect(Collectors.toMap(
+ entry -> new TbPair<>(entry.getValue().getRefEntityId() == null ? entityId : entry.getValue().getRefEntityId(), entry.getValue().getRefEntityKey()),
+ Map.Entry::getKey
+ ));
+ this.argNames = new ArrayList<>(arguments.keySet());
+ this.output = configuration.getOutput();
+ this.expression = configuration.getExpression();
+ this.tbelInvokeService = tbelInvokeService;
+
+ this.maxDataPointsPerRollingArg = apiLimitService.getLimit(tenantId, DefaultTenantProfileConfiguration::getMaxDataPointsPerRollingArg);
+ this.maxStateSize = apiLimitService.getLimit(tenantId, DefaultTenantProfileConfiguration::getMaxStateSizeInKBytes) * 1024;
+ this.maxSingleValueArgumentSize = apiLimitService.getLimit(tenantId, DefaultTenantProfileConfiguration::getMaxSingleValueArgumentSizeInKBytes) * 1024;
+ }
+
+ public void init() {
+ if (CalculatedFieldType.SCRIPT.equals(cfType)) {
+ try {
+ this.calculatedFieldScriptEngine = initEngine(tenantId, expression, tbelInvokeService);
+ initialized = true;
+ } catch (Exception e) {
+ throw new RuntimeException("Failed to init calculated field ctx. Invalid expression syntax.", e);
+ }
+ } else {
+ if (isValidExpression(expression)) {
+ this.customExpression = ThreadLocal.withInitial(() ->
+ new ExpressionBuilder(expression)
+ .implicitMultiplication(true)
+ .variables(this.arguments.keySet())
+ .build()
+ );
+ initialized = true;
+ } else {
+ throw new RuntimeException("Failed to init calculated field ctx. Invalid expression syntax.");
+ }
+ }
+ }
+
+ private CalculatedFieldScriptEngine initEngine(TenantId tenantId, String expression, TbelInvokeService tbelInvokeService) {
+ if (tbelInvokeService == null) {
+ throw new IllegalArgumentException("TBEL script engine is disabled!");
+ }
+
+ List ctxAndArgNames = new ArrayList<>(argNames.size() + 1);
+ ctxAndArgNames.add("ctx");
+ ctxAndArgNames.addAll(argNames);
+ return new CalculatedFieldTbelScriptEngine(
+ tenantId,
+ tbelInvokeService,
+ expression,
+ ctxAndArgNames.toArray(String[]::new)
+ );
+ }
+
+ private boolean isValidExpression(String expression) {
+ try {
+ MVEL.compileExpression(expression);
+ return true;
+ } catch (Exception e) {
+ return false;
+ }
+ }
+
+ public boolean matches(List values, AttributeScope scope) {
+ return matchesAttributes(mainEntityArguments, values, scope);
+ }
+
+ public boolean linkMatches(EntityId entityId, List values, AttributeScope scope) {
+ var map = linkedEntityArguments.get(entityId);
+ return map != null && matchesAttributes(map, values, scope);
+ }
+
+ public boolean matches(List values) {
+ return matchesTimeSeries(mainEntityArguments, values);
+ }
+
+ public boolean linkMatches(EntityId entityId, List values) {
+ var map = linkedEntityArguments.get(entityId);
+ return map != null && matchesTimeSeries(map, values);
+ }
+
+ private boolean matchesAttributes(Map argMap, List values, AttributeScope scope) {
+ for (AttributeKvEntry attrKv : values) {
+ ReferencedEntityKey attrKey = new ReferencedEntityKey(attrKv.getKey(), ArgumentType.ATTRIBUTE, scope);
+ if (argMap.containsKey(attrKey)) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ private boolean matchesTimeSeries(Map argMap, List values) {
+ for (TsKvEntry tsKv : values) {
+ ReferencedEntityKey latestKey = new ReferencedEntityKey(tsKv.getKey(), ArgumentType.TS_LATEST, null);
+ if (argMap.containsKey(latestKey)) {
+ return true;
+ }
+ ReferencedEntityKey rollingKey = new ReferencedEntityKey(tsKv.getKey(), ArgumentType.TS_ROLLING, null);
+ if (argMap.containsKey(rollingKey)) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ public boolean matchesKeys(List keys, AttributeScope scope) {
+ return matchesAttributesKeys(mainEntityArguments, keys, scope);
+ }
+
+ public boolean matchesKeys(List keys) {
+ return matchesTimeSeriesKeys(mainEntityArguments, keys);
+ }
+
+ private boolean matchesAttributesKeys(Map argMap, List keys, AttributeScope scope) {
+ for (String key : keys) {
+ ReferencedEntityKey attrKey = new ReferencedEntityKey(key, ArgumentType.ATTRIBUTE, scope);
+ if (argMap.containsKey(attrKey)) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ private boolean matchesTimeSeriesKeys(Map argMap, List keys) {
+ for (String key : keys) {
+ ReferencedEntityKey latestKey = new ReferencedEntityKey(key, ArgumentType.TS_LATEST, null);
+ if (argMap.containsKey(latestKey)) {
+ return true;
+ }
+ ReferencedEntityKey rollingKey = new ReferencedEntityKey(key, ArgumentType.TS_ROLLING, null);
+ if (argMap.containsKey(rollingKey)) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ public boolean linkMatchesAttrKeys(EntityId entityId, List keys, AttributeScope scope) {
+ var map = linkedEntityArguments.get(entityId);
+ return map != null && matchesAttributesKeys(map, keys, scope);
+ }
+
+ public boolean linkMatchesTsKeys(EntityId entityId, List keys) {
+ var map = linkedEntityArguments.get(entityId);
+ return map != null && matchesTimeSeriesKeys(map, keys);
+ }
+
+ public boolean linkMatches(EntityId entityId, CalculatedFieldTelemetryMsgProto proto) {
+ if (!proto.getTsDataList().isEmpty()) {
+ List updatedTelemetry = proto.getTsDataList().stream()
+ .map(ProtoUtils::fromProto)
+ .toList();
+ return linkMatches(entityId, updatedTelemetry);
+ } else if (!proto.getAttrDataList().isEmpty()) {
+ AttributeScope scope = AttributeScope.valueOf(proto.getScope().name());
+ List updatedTelemetry = proto.getAttrDataList().stream()
+ .map(ProtoUtils::fromProto)
+ .toList();
+ return linkMatches(entityId, updatedTelemetry, scope);
+ } else if (!proto.getRemovedTsKeysList().isEmpty()) {
+ return linkMatchesTsKeys(entityId, proto.getRemovedTsKeysList());
+ } else {
+ return linkMatchesAttrKeys(entityId, proto.getRemovedAttrKeysList(), AttributeScope.valueOf(proto.getScope().name()));
+ }
+ }
+
+ public CalculatedFieldEntityCtxId toCalculatedFieldEntityCtxId() {
+ return new CalculatedFieldEntityCtxId(tenantId, cfId, entityId);
+ }
+
+ public boolean hasOtherSignificantChanges(CalculatedFieldCtx other) {
+ boolean expressionChanged = !expression.equals(other.expression);
+ boolean outputChanged = !output.equals(other.output);
+ return expressionChanged || outputChanged;
+ }
+
+ public boolean hasStateChanges(CalculatedFieldCtx other) {
+ boolean typeChanged = !cfType.equals(other.cfType);
+ boolean argumentsChanged = !arguments.equals(other.arguments);
+ return typeChanged || argumentsChanged;
+ }
+
+ public String getSizeExceedsLimitMessage() {
+ return "Failed to init CF state. State size exceeds limit of " + (maxStateSize / 1024) + "Kb!";
+ }
+
+}
diff --git a/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/CalculatedFieldScriptEngine.java b/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/CalculatedFieldScriptEngine.java
new file mode 100644
index 0000000000..caad1e4cfe
--- /dev/null
+++ b/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/CalculatedFieldScriptEngine.java
@@ -0,0 +1,29 @@
+/**
+ * Copyright © 2016-2025 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.cf.ctx.state;
+
+import com.fasterxml.jackson.databind.JsonNode;
+import com.google.common.util.concurrent.ListenableFuture;
+
+public interface CalculatedFieldScriptEngine {
+
+ ListenableFuture