From 49c34ebe91f20fa65fed267f9509f15b783c4156 Mon Sep 17 00:00:00 2001 From: Dmytro Skarzhynets Date: Thu, 15 Jan 2026 17:33:12 +0200 Subject: [PATCH] Use min value from tenant profile for scheduled update interval when not provided --- ...SupportedCalculatedFieldConfiguration.java | 8 +- ...gregationCalculatedFieldConfiguration.java | 2 +- ...eofencingCalculatedFieldConfiguration.java | 5 +- ...ncingCalculatedFieldConfigurationTest.java | 14 ++ .../dao/cf/BaseCalculatedFieldService.java | 14 ++ .../service/CalculatedFieldServiceTest.java | 169 +++++++++++++++++- 6 files changed, 201 insertions(+), 11 deletions(-) diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/ScheduledUpdateSupportedCalculatedFieldConfiguration.java b/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/ScheduledUpdateSupportedCalculatedFieldConfiguration.java index f5b45c6d5c..f78ff72af0 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/ScheduledUpdateSupportedCalculatedFieldConfiguration.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/ScheduledUpdateSupportedCalculatedFieldConfiguration.java @@ -22,14 +22,14 @@ public interface ScheduledUpdateSupportedCalculatedFieldConfiguration extends Ca boolean isScheduledUpdateEnabled(); @PositiveOrZero - int getScheduledUpdateInterval(); + Integer getScheduledUpdateInterval(); - void setScheduledUpdateInterval(int interval); + void setScheduledUpdateInterval(Integer interval); default void validate(long minAllowedScheduledUpdateInterval) { if (getScheduledUpdateInterval() < minAllowedScheduledUpdateInterval) { - throw new IllegalArgumentException("Scheduled update interval is less than configured " + - "minimum allowed interval in tenant profile: " + minAllowedScheduledUpdateInterval); + throw new IllegalArgumentException("Scheduled update interval (" + getScheduledUpdateInterval() + + " seconds) is less than minimum allowed interval in tenant profile: " + minAllowedScheduledUpdateInterval + " seconds"); } } } diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/aggregation/RelatedEntitiesAggregationCalculatedFieldConfiguration.java b/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/aggregation/RelatedEntitiesAggregationCalculatedFieldConfiguration.java index 3b0df7ed82..98b4b691e3 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/aggregation/RelatedEntitiesAggregationCalculatedFieldConfiguration.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/aggregation/RelatedEntitiesAggregationCalculatedFieldConfiguration.java @@ -44,7 +44,7 @@ public class RelatedEntitiesAggregationCalculatedFieldConfiguration implements A private Output output; private boolean useLatestTs; - private int scheduledUpdateInterval; + private Integer scheduledUpdateInterval; @Override public CalculatedFieldType getType() { diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/geofencing/GeofencingCalculatedFieldConfiguration.java b/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/geofencing/GeofencingCalculatedFieldConfiguration.java index 2d1abd0024..c85604d2ab 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/geofencing/GeofencingCalculatedFieldConfiguration.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/geofencing/GeofencingCalculatedFieldConfiguration.java @@ -46,7 +46,7 @@ public class GeofencingCalculatedFieldConfiguration implements ArgumentsBasedCal private Map zoneGroups; private boolean scheduledUpdateEnabled; - private int scheduledUpdateInterval; + private Integer scheduledUpdateInterval; private Output output; @@ -79,6 +79,9 @@ public class GeofencingCalculatedFieldConfiguration implements ArgumentsBasedCal @Override public void validate() { + if (scheduledUpdateEnabled && scheduledUpdateInterval == null) { + throw new IllegalArgumentException("Refresh interval is required when periodic zone group refresh is enabled."); + } zoneGroups.forEach((key, value) -> value.validate(key)); } diff --git a/common/data/src/test/java/org/thingsboard/server/common/data/cf/configuration/geofencing/GeofencingCalculatedFieldConfigurationTest.java b/common/data/src/test/java/org/thingsboard/server/common/data/cf/configuration/geofencing/GeofencingCalculatedFieldConfigurationTest.java index 2f391435b2..2c68bcd8c5 100644 --- a/common/data/src/test/java/org/thingsboard/server/common/data/cf/configuration/geofencing/GeofencingCalculatedFieldConfigurationTest.java +++ b/common/data/src/test/java/org/thingsboard/server/common/data/cf/configuration/geofencing/GeofencingCalculatedFieldConfigurationTest.java @@ -28,6 +28,7 @@ import java.util.Map; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatCode; +import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.verify; import static org.thingsboard.server.common.data.cf.configuration.geofencing.EntityCoordinates.ENTITY_ID_LATITUDE_ARGUMENT_KEY; @@ -103,4 +104,17 @@ public class GeofencingCalculatedFieldConfigurationTest { assertThat(allowedZonesArgument.getRefEntityKey()).isEqualTo(new ReferencedEntityKey("perimeter", ArgumentType.ATTRIBUTE, AttributeScope.SERVER_SCOPE)); } + @Test + void validateShouldThrowWhenScheduledUpdateEnabledButIntervalNotSet() { + var cfg = new GeofencingCalculatedFieldConfiguration(); + cfg.setEntityCoordinates(mock(EntityCoordinates.class)); + cfg.setZoneGroups(Map.of("zone", mock(ZoneGroupConfiguration.class))); + cfg.setScheduledUpdateEnabled(true); + cfg.setScheduledUpdateInterval(null); + + assertThatThrownBy(cfg::validate) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("Refresh interval is required when periodic zone group refresh is enabled."); + } + } diff --git a/dao/src/main/java/org/thingsboard/server/dao/cf/BaseCalculatedFieldService.java b/dao/src/main/java/org/thingsboard/server/dao/cf/BaseCalculatedFieldService.java index 00c5ec372e..075e2d8a9d 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/cf/BaseCalculatedFieldService.java +++ b/dao/src/main/java/org/thingsboard/server/dao/cf/BaseCalculatedFieldService.java @@ -26,18 +26,21 @@ import org.thingsboard.server.common.data.cf.CalculatedFieldFilter; import org.thingsboard.server.common.data.cf.CalculatedFieldInfo; import org.thingsboard.server.common.data.cf.CalculatedFieldType; import org.thingsboard.server.common.data.cf.configuration.CalculatedFieldConfiguration; +import org.thingsboard.server.common.data.cf.configuration.aggregation.RelatedEntitiesAggregationCalculatedFieldConfiguration; import org.thingsboard.server.common.data.id.CalculatedFieldId; import org.thingsboard.server.common.data.id.EntityId; import org.thingsboard.server.common.data.id.HasId; import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.common.data.page.PageData; import org.thingsboard.server.common.data.page.PageLink; +import org.thingsboard.server.common.data.tenant.profile.DefaultTenantProfileConfiguration; import org.thingsboard.server.dao.entity.AbstractEntityService; import org.thingsboard.server.dao.entity.EntityService; import org.thingsboard.server.dao.eventsourcing.DeleteEntityEvent; import org.thingsboard.server.dao.eventsourcing.SaveEntityEvent; import org.thingsboard.server.dao.exception.IncorrectParameterException; import org.thingsboard.server.dao.service.validator.CalculatedFieldDataValidator; +import org.thingsboard.server.dao.usagerecord.ApiLimitService; import java.util.EnumSet; import java.util.List; @@ -62,6 +65,7 @@ public class BaseCalculatedFieldService extends AbstractEntityService implements private final EntityService entityService; private final CalculatedFieldDao calculatedFieldDao; private final CalculatedFieldDataValidator calculatedFieldDataValidator; + private final ApiLimitService apiLimitService; @Override public CalculatedField save(CalculatedField calculatedField) { @@ -70,6 +74,7 @@ public class BaseCalculatedFieldService extends AbstractEntityService implements @Override public CalculatedField save(CalculatedField calculatedField, boolean doValidate) { + setConfigurationDefaults(calculatedField); CalculatedField oldCalculatedField = null; if (doValidate) { oldCalculatedField = calculatedFieldDataValidator.validate(calculatedField, CalculatedField::getTenantId); @@ -79,6 +84,15 @@ public class BaseCalculatedFieldService extends AbstractEntityService implements return doSave(calculatedField, oldCalculatedField); } + private void setConfigurationDefaults(CalculatedField calculatedField) { + if (calculatedField.getConfiguration() instanceof RelatedEntitiesAggregationCalculatedFieldConfiguration config + && config.getScheduledUpdateInterval() == null) { + int minScheduledUpdateInterval = (int) apiLimitService.getLimit( + calculatedField.getTenantId(), DefaultTenantProfileConfiguration::getMinAllowedScheduledUpdateIntervalInSecForCF + ); + config.setScheduledUpdateInterval(minScheduledUpdateInterval); + } + } private CalculatedField doSave(CalculatedField calculatedField, CalculatedField oldCalculatedField) { try { diff --git a/dao/src/test/java/org/thingsboard/server/dao/service/CalculatedFieldServiceTest.java b/dao/src/test/java/org/thingsboard/server/dao/service/CalculatedFieldServiceTest.java index 97e06ed879..afb380c5be 100644 --- a/dao/src/test/java/org/thingsboard/server/dao/service/CalculatedFieldServiceTest.java +++ b/dao/src/test/java/org/thingsboard/server/dao/service/CalculatedFieldServiceTest.java @@ -15,6 +15,7 @@ */ package org.thingsboard.server.dao.service; +import org.apache.commons.lang3.RandomUtils; import org.junit.Test; import org.springframework.beans.factory.annotation.Autowired; import org.thingsboard.server.common.data.Device; @@ -27,6 +28,10 @@ import org.thingsboard.server.common.data.cf.configuration.ReferencedEntityKey; import org.thingsboard.server.common.data.cf.configuration.RelationPathQueryDynamicSourceConfiguration; import org.thingsboard.server.common.data.cf.configuration.SimpleCalculatedFieldConfiguration; import org.thingsboard.server.common.data.cf.configuration.TimeSeriesOutput; +import org.thingsboard.server.common.data.cf.configuration.aggregation.AggFunction; +import org.thingsboard.server.common.data.cf.configuration.aggregation.AggKeyInput; +import org.thingsboard.server.common.data.cf.configuration.aggregation.AggMetric; +import org.thingsboard.server.common.data.cf.configuration.aggregation.RelatedEntitiesAggregationCalculatedFieldConfiguration; import org.thingsboard.server.common.data.cf.configuration.geofencing.EntityCoordinates; import org.thingsboard.server.common.data.cf.configuration.geofencing.GeofencingCalculatedFieldConfiguration; import org.thingsboard.server.common.data.cf.configuration.geofencing.ZoneGroupConfiguration; @@ -36,8 +41,8 @@ import org.thingsboard.server.common.data.relation.EntitySearchDirection; import org.thingsboard.server.common.data.relation.RelationPathLevel; import org.thingsboard.server.dao.cf.CalculatedFieldService; import org.thingsboard.server.dao.device.DeviceService; -import org.thingsboard.server.exception.DataValidationException; import org.thingsboard.server.dao.tenant.TbTenantProfileCache; +import org.thingsboard.server.exception.DataValidationException; import java.util.ArrayList; import java.util.List; @@ -107,11 +112,11 @@ public class CalculatedFieldServiceTest extends AbstractServiceTest { int min = tbTenantProfileCache.get(tenantId) .getDefaultProfileConfiguration() .getMinAllowedScheduledUpdateIntervalInSecForCF(); - int valueFromConfig = min - 10; // Enable scheduling with an interval below tenant min cfg.setScheduledUpdateEnabled(true); - cfg.setScheduledUpdateInterval(valueFromConfig); + int invalidInterval = RandomUtils.insecure().randomInt(1, min); + cfg.setScheduledUpdateInterval(invalidInterval); // Create & save Calculated Field CalculatedField cf = new CalculatedField(); @@ -125,8 +130,8 @@ public class CalculatedFieldServiceTest extends AbstractServiceTest { assertThatThrownBy(() -> calculatedFieldService.save(cf)) .isInstanceOf(DataValidationException.class) .hasCauseInstanceOf(IllegalArgumentException.class) - .hasMessageStartingWith("Scheduled update interval is less than configured " + - "minimum allowed interval in tenant profile: "); + .hasMessage("Scheduled update interval (" + invalidInterval + + " seconds) is less than minimum allowed interval in tenant profile: " + min + " seconds"); } @Test @@ -253,6 +258,160 @@ public class CalculatedFieldServiceTest extends AbstractServiceTest { assertThat(calculatedFieldService.findById(tenantId, savedCalculatedField.getId())).isNull(); } + @Test + public void testSaveRelatedEntitiesAggregationCF_shouldUseMinScheduledUpdateIntervalFromTenantProfileWhenNotSet() { + // GIVEN + var device = createTestDevice(); + + var cfg = new RelatedEntitiesAggregationCalculatedFieldConfiguration(); + cfg.setRelation(new RelationPathLevel(EntitySearchDirection.FROM, EntityRelation.CONTAINS_TYPE)); + + var argument = new Argument(); + argument.setRefEntityKey(new ReferencedEntityKey("temperature", ArgumentType.TS_LATEST, null)); + cfg.setArguments(Map.of("temp", argument)); + + var metric = new AggMetric(); + metric.setFunction(AggFunction.AVG); + metric.setInput(new AggKeyInput("temp")); + cfg.setMetrics(Map.of("avgTemp", metric)); + + var output = new TimeSeriesOutput(); + output.setName("avgTemperature"); + cfg.setOutput(output); + + int minDeduplicationInterval = (int) tbTenantProfileCache.get(tenantId) + .getDefaultProfileConfiguration() + .getMinAllowedDeduplicationIntervalInSecForCF(); + cfg.setDeduplicationIntervalInSec(minDeduplicationInterval); + + // Do NOT set scheduledUpdateInterval - it should default to tenant profile min value + + var cf = new CalculatedField(); + cf.setTenantId(tenantId); + cf.setEntityId(device.getId()); + cf.setType(CalculatedFieldType.RELATED_ENTITIES_AGGREGATION); + cf.setName("Related Entities Aggregation CF - default scheduled interval test"); + cf.setConfigurationVersion(0); + cf.setConfiguration(cfg); + + // WHEN + CalculatedField saved = calculatedFieldService.save(cf); + + // THEN + assertThat(saved).isNotNull(); + assertThat(saved.getConfiguration()).isInstanceOf(RelatedEntitiesAggregationCalculatedFieldConfiguration.class); + + var savedConfig = (RelatedEntitiesAggregationCalculatedFieldConfiguration) saved.getConfiguration(); + int expectedMinScheduledUpdateInterval = tbTenantProfileCache.get(tenantId) + .getDefaultProfileConfiguration() + .getMinAllowedScheduledUpdateIntervalInSecForCF(); + + assertThat(savedConfig.getScheduledUpdateInterval()).isEqualTo(expectedMinScheduledUpdateInterval); + + calculatedFieldService.deleteCalculatedField(tenantId, saved.getId()); + } + + @Test + public void testSaveRelatedEntitiesAggregationCF_shouldThrowWhenScheduledUpdateIntervalLessThanMinAllowed() { + // GIVEN + var device = createTestDevice(); + + var cfg = new RelatedEntitiesAggregationCalculatedFieldConfiguration(); + cfg.setRelation(new RelationPathLevel(EntitySearchDirection.FROM, EntityRelation.CONTAINS_TYPE)); + + var argument = new Argument(); + argument.setRefEntityKey(new ReferencedEntityKey("temperature", ArgumentType.TS_LATEST, null)); + cfg.setArguments(Map.of("temp", argument)); + + var metric = new AggMetric(); + metric.setFunction(AggFunction.AVG); + metric.setInput(new AggKeyInput("temp")); + cfg.setMetrics(Map.of("avgTemp", metric)); + + var output = new TimeSeriesOutput(); + output.setName("avgTemperature"); + cfg.setOutput(output); + + int minDeduplicationInterval = (int) tbTenantProfileCache.get(tenantId) + .getDefaultProfileConfiguration() + .getMinAllowedDeduplicationIntervalInSecForCF(); + cfg.setDeduplicationIntervalInSec(minDeduplicationInterval); + + int minScheduledUpdateInterval = tbTenantProfileCache.get(tenantId) + .getDefaultProfileConfiguration() + .getMinAllowedScheduledUpdateIntervalInSecForCF(); + int invalidInterval = RandomUtils.insecure().randomInt(1, minScheduledUpdateInterval); + cfg.setScheduledUpdateInterval(invalidInterval); + + var cf = new CalculatedField(); + cf.setTenantId(tenantId); + cf.setEntityId(device.getId()); + cf.setType(CalculatedFieldType.RELATED_ENTITIES_AGGREGATION); + cf.setName("Related Entities Aggregation CF - invalid scheduled interval test"); + cf.setConfigurationVersion(0); + cf.setConfiguration(cfg); + + // WHEN-THEN + assertThatThrownBy(() -> calculatedFieldService.save(cf)) + .isInstanceOf(DataValidationException.class) + .hasCauseInstanceOf(IllegalArgumentException.class) + .hasMessage("Scheduled update interval (" + invalidInterval + + " seconds) is less than minimum allowed interval in tenant profile: " + minScheduledUpdateInterval + " seconds"); + } + + @Test + public void testSaveRelatedEntitiesAggregationCF_shouldAcceptValidScheduledUpdateInterval() { + // GIVEN + var device = createTestDevice(); + + var cfg = new RelatedEntitiesAggregationCalculatedFieldConfiguration(); + cfg.setRelation(new RelationPathLevel(EntitySearchDirection.FROM, EntityRelation.CONTAINS_TYPE)); + + var argument = new Argument(); + argument.setRefEntityKey(new ReferencedEntityKey("temperature", ArgumentType.TS_LATEST, null)); + cfg.setArguments(Map.of("temp", argument)); + + var metric = new AggMetric(); + metric.setFunction(AggFunction.AVG); + metric.setInput(new AggKeyInput("temp")); + cfg.setMetrics(Map.of("avgTemp", metric)); + + var output = new TimeSeriesOutput(); + output.setName("avgTemperature"); + cfg.setOutput(output); + + int minDeduplicationInterval = (int) tbTenantProfileCache.get(tenantId) + .getDefaultProfileConfiguration() + .getMinAllowedDeduplicationIntervalInSecForCF(); + cfg.setDeduplicationIntervalInSec(minDeduplicationInterval); + + int minScheduledUpdateInterval = tbTenantProfileCache.get(tenantId) + .getDefaultProfileConfiguration() + .getMinAllowedScheduledUpdateIntervalInSecForCF(); + int customScheduledUpdateInterval = minScheduledUpdateInterval + 100; + cfg.setScheduledUpdateInterval(customScheduledUpdateInterval); + + var cf = new CalculatedField(); + cf.setTenantId(tenantId); + cf.setEntityId(device.getId()); + cf.setType(CalculatedFieldType.RELATED_ENTITIES_AGGREGATION); + cf.setName("Related Entities Aggregation CF - valid scheduled interval test"); + cf.setConfigurationVersion(0); + cf.setConfiguration(cfg); + + // WHEN + CalculatedField saved = calculatedFieldService.save(cf); + + // THEN + assertThat(saved).isNotNull(); + assertThat(saved.getConfiguration()).isInstanceOf(RelatedEntitiesAggregationCalculatedFieldConfiguration.class); + + var savedConfig = (RelatedEntitiesAggregationCalculatedFieldConfiguration) saved.getConfiguration(); + assertThat(savedConfig.getScheduledUpdateInterval()).isEqualTo(customScheduledUpdateInterval); + + calculatedFieldService.deleteCalculatedField(tenantId, saved.getId()); + } + private CalculatedField saveValidCalculatedField() { Device device = createTestDevice(); CalculatedField calculatedField = getCalculatedField(device.getId(), device.getId());