From c033f4b5b3a248a27e7d871dfc13df04f1e83eb2 Mon Sep 17 00:00:00 2001 From: dashevchenko Date: Tue, 24 Mar 2026 18:08:28 +0200 Subject: [PATCH 1/2] refactoring, added tests --- .../cf/DefaultCalculatedFieldCache.java | 56 +++++---- .../profile/DefaultTbAssetProfileCache.java | 23 ++-- .../profile/DefaultTbDeviceProfileCache.java | 23 ++-- .../cf/DefaultCalculatedFieldCacheTest.java | 108 ++++++++++++++++++ 4 files changed, 151 insertions(+), 59 deletions(-) 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 index 5922551a29..66282135cd 100644 --- a/application/src/main/java/org/thingsboard/server/service/cf/DefaultCalculatedFieldCache.java +++ b/application/src/main/java/org/thingsboard/server/service/cf/DefaultCalculatedFieldCache.java @@ -25,13 +25,13 @@ import org.springframework.util.ConcurrentReferenceHashMap; import org.thingsboard.script.api.tbel.TbelInvokeService; import org.thingsboard.server.common.data.cf.CalculatedField; import org.thingsboard.server.common.data.cf.CalculatedFieldLink; -import org.thingsboard.server.common.data.plugin.ComponentLifecycleEvent; -import org.thingsboard.server.common.msg.plugin.ComponentLifecycleMsg; 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.data.plugin.ComponentLifecycleEvent; +import org.thingsboard.server.common.msg.plugin.ComponentLifecycleMsg; import org.thingsboard.server.dao.cf.CalculatedFieldService; import org.thingsboard.server.dao.usagerecord.ApiLimitService; import org.thingsboard.server.queue.util.AfterStartUp; @@ -46,6 +46,7 @@ import java.util.concurrent.ConcurrentMap; import java.util.concurrent.CopyOnWriteArrayList; import java.util.concurrent.locks.Lock; import java.util.concurrent.locks.ReentrantLock; +import java.util.stream.Collectors; @Service @Slf4j @@ -214,41 +215,38 @@ public class DefaultCalculatedFieldCache implements CalculatedFieldCache { } private void evictTenantCfs(TenantId tenantId) { - var removedCfIds = new HashSet(); var removedCfEntityIds = new HashSet(); var removedLinkEntityIds = new HashSet(); - for (Map.Entry entry : calculatedFields.entrySet()) { - CalculatedFieldId cfId = entry.getKey(); - CalculatedField cf = entry.getValue(); - if (cf.getTenantId().equals(tenantId)) { - calculatedFields.remove(cfId); - List links = calculatedFieldLinks.remove(cfId); - if (links != null) { - links.forEach(link -> removedLinkEntityIds.add(link.getEntityId())); - } - calculatedFieldsCtx.remove(cfId); - removedCfIds.add(cfId); - removedCfEntityIds.add(cf.getEntityId()); - log.debug("[{}] evict calculated field from cache on tenant deletion: {}", cfId, cf); + var toRemove = calculatedFields.entrySet().stream() + .filter(e -> e.getValue().getTenantId().equals(tenantId)) + .map(Map.Entry::getKey) + .collect(Collectors.toSet()); + toRemove.forEach(cfId -> { + CalculatedField cf = calculatedFields.remove(cfId); + List links = calculatedFieldLinks.remove(cfId); + if (links != null) { + links.forEach(link -> removedLinkEntityIds.add(link.getEntityId())); } - } + calculatedFieldsCtx.remove(cfId); + removedCfEntityIds.add(cf.getEntityId()); + }); removedCfEntityIds.forEach(entityId -> { - List cfs = entityIdCalculatedFields.get(entityId); - if (cfs != null) { - cfs.removeIf(cf -> removedCfIds.contains(cf.getId())); - if (cfs.isEmpty()) { - entityIdCalculatedFields.remove(entityId); + entityIdCalculatedFields.compute(entityId, (k, cfs) -> { + if (cfs != null) { + cfs.removeIf(cf -> toRemove.contains(cf.getId())); + return cfs.isEmpty() ? null : cfs; } - } + return null; + }); }); removedLinkEntityIds.forEach(entityId -> { - List entityLinks = entityIdCalculatedFieldLinks.get(entityId); - if (entityLinks != null) { - entityLinks.removeIf(link -> removedCfIds.contains(link.getCalculatedFieldId())); - if (entityLinks.isEmpty()) { - entityIdCalculatedFieldLinks.remove(entityId); + entityIdCalculatedFieldLinks.compute(entityId, ((entityId1, links) -> { + if (links != null) { + links.removeIf(link -> toRemove.contains(link.getCalculatedFieldId())); + return links.isEmpty() ? null : links; } - } + return null; + })); }); } diff --git a/application/src/main/java/org/thingsboard/server/service/profile/DefaultTbAssetProfileCache.java b/application/src/main/java/org/thingsboard/server/service/profile/DefaultTbAssetProfileCache.java index a0bae27b68..e0a9917509 100644 --- a/application/src/main/java/org/thingsboard/server/service/profile/DefaultTbAssetProfileCache.java +++ b/application/src/main/java/org/thingsboard/server/service/profile/DefaultTbAssetProfileCache.java @@ -29,14 +29,14 @@ import org.thingsboard.server.common.msg.plugin.ComponentLifecycleMsg; import org.thingsboard.server.dao.asset.AssetProfileService; import org.thingsboard.server.dao.asset.AssetService; -import java.util.HashSet; -import java.util.Map; +import java.util.Set; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ConcurrentMap; import java.util.concurrent.locks.Lock; import java.util.concurrent.locks.ReentrantLock; import java.util.function.BiConsumer; import java.util.function.Consumer; +import java.util.stream.Collectors; @Service @Slf4j @@ -154,19 +154,12 @@ public class DefaultTbAssetProfileCache implements TbAssetProfileCache { case TENANT: if (event.getEvent() == ComponentLifecycleEvent.DELETED) { TenantId tenantId = event.getTenantId(); - var removedProfileIds = new HashSet(); - for (Map.Entry entry : assetProfilesMap.entrySet()) { - if (entry.getValue().getTenantId().equals(tenantId)) { - assetProfilesMap.remove(entry.getKey()); - removedProfileIds.add(entry.getKey()); - log.debug("[{}] evict asset profile from cache: {}", entry.getKey(), entry.getValue()); - } - } - for (Map.Entry entry : assetsMap.entrySet()) { - if (removedProfileIds.contains(entry.getValue())) { - assetsMap.remove(entry.getKey()); - } - } + Set toRemove = assetProfilesMap.values().stream() + .filter(assetProfile -> assetProfile.getTenantId().equals(tenantId)) + .map(AssetProfile::getId) + .collect(Collectors.toSet()); + assetProfilesMap.keySet().removeAll(toRemove); + assetsMap.entrySet().removeIf(entry -> toRemove.contains(entry.getValue())); profileListeners.remove(tenantId); assetProfileListeners.remove(tenantId); } diff --git a/application/src/main/java/org/thingsboard/server/service/profile/DefaultTbDeviceProfileCache.java b/application/src/main/java/org/thingsboard/server/service/profile/DefaultTbDeviceProfileCache.java index 34b5f365f5..4729a8c118 100644 --- a/application/src/main/java/org/thingsboard/server/service/profile/DefaultTbDeviceProfileCache.java +++ b/application/src/main/java/org/thingsboard/server/service/profile/DefaultTbDeviceProfileCache.java @@ -29,14 +29,14 @@ import org.thingsboard.server.common.msg.plugin.ComponentLifecycleMsg; import org.thingsboard.server.dao.device.DeviceProfileService; import org.thingsboard.server.dao.device.DeviceService; -import java.util.HashSet; -import java.util.Map; +import java.util.Set; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ConcurrentMap; import java.util.concurrent.locks.Lock; import java.util.concurrent.locks.ReentrantLock; import java.util.function.BiConsumer; import java.util.function.Consumer; +import java.util.stream.Collectors; @Service @Slf4j @@ -154,19 +154,12 @@ public class DefaultTbDeviceProfileCache implements TbDeviceProfileCache { case TENANT: if (event.getEvent() == ComponentLifecycleEvent.DELETED) { TenantId tenantId = event.getTenantId(); - var removedProfileIds = new HashSet(); - for (Map.Entry entry : deviceProfilesMap.entrySet()) { - if (entry.getValue().getTenantId().equals(tenantId)) { - deviceProfilesMap.remove(entry.getKey()); - removedProfileIds.add(entry.getKey()); - log.debug("[{}] evict device profile from cache: {}", entry.getKey(), entry.getValue()); - } - } - for (Map.Entry entry : devicesMap.entrySet()) { - if (removedProfileIds.contains(entry.getValue())) { - devicesMap.remove(entry.getKey()); - } - } + Set toRemove = deviceProfilesMap.values().stream() + .filter(deviceProfile -> deviceProfile.getTenantId().equals(tenantId)) + .map(DeviceProfile::getId) + .collect(Collectors.toSet()); + deviceProfilesMap.keySet().removeAll(toRemove); + devicesMap.entrySet().removeIf(entry -> toRemove.contains(entry.getValue())); profileListeners.remove(tenantId); deviceProfileListeners.remove(tenantId); } diff --git a/application/src/test/java/org/thingsboard/server/service/cf/DefaultCalculatedFieldCacheTest.java b/application/src/test/java/org/thingsboard/server/service/cf/DefaultCalculatedFieldCacheTest.java index df0acef244..cde66dce65 100644 --- a/application/src/test/java/org/thingsboard/server/service/cf/DefaultCalculatedFieldCacheTest.java +++ b/application/src/test/java/org/thingsboard/server/service/cf/DefaultCalculatedFieldCacheTest.java @@ -26,9 +26,11 @@ import org.thingsboard.server.common.data.cf.CalculatedFieldLink; import org.thingsboard.server.common.data.cf.CalculatedFieldType; import org.thingsboard.server.common.data.cf.configuration.CalculatedFieldConfiguration; 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.DeviceId; +import org.thingsboard.server.common.data.id.DeviceProfileId; import org.thingsboard.server.common.data.id.EntityId; import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.common.data.page.PageData; @@ -151,6 +153,112 @@ public class DefaultCalculatedFieldCacheTest { assertThat(cache.getCalculatedFieldsByEntityId(asset)).isEmpty(); } + // --- DeviceProfile/AssetProfile deletion tests --- + + @Test + public void onComponentLifecycleEvent_deviceProfileDeleted_evictsCfsForThatProfile() { + TenantId tenant = new TenantId(UUID.randomUUID()); + DeviceProfileId profileId = new DeviceProfileId(UUID.randomUUID()); + CalculatedField cf = addCfToCache(tenant, profileId); + + cache.onComponentLifecycleEvent(new ComponentLifecycleMsg(tenant, profileId, ComponentLifecycleEvent.DELETED)); + + assertThat(cache.getCalculatedField(cf.getId())).isNull(); + assertThat(cache.getCalculatedFieldsByEntityId(profileId)).isEmpty(); + } + + @Test + public void onComponentLifecycleEvent_deviceProfileDeleted_removesLinksForLinkedEntities() { + TenantId tenant = new TenantId(UUID.randomUUID()); + DeviceProfileId profileId = new DeviceProfileId(UUID.randomUUID()); + DeviceId linkedDevice = new DeviceId(UUID.randomUUID()); + addCfToCache(tenant, profileId, linkedDevice); + + cache.onComponentLifecycleEvent(new ComponentLifecycleMsg(tenant, profileId, ComponentLifecycleEvent.DELETED)); + + assertThat(cache.getCalculatedFieldLinksByEntityId(linkedDevice)).isEmpty(); + } + + @Test + public void onComponentLifecycleEvent_deviceProfileDeleted_doesNotEvictOtherProfilesCfs() { + TenantId tenant = new TenantId(UUID.randomUUID()); + DeviceProfileId profile1 = new DeviceProfileId(UUID.randomUUID()); + DeviceProfileId profile2 = new DeviceProfileId(UUID.randomUUID()); + CalculatedField cf1 = addCfToCache(tenant, profile1); + CalculatedField cf2 = addCfToCache(tenant, profile2); + + cache.onComponentLifecycleEvent(new ComponentLifecycleMsg(tenant, profile1, ComponentLifecycleEvent.DELETED)); + + assertThat(cache.getCalculatedField(cf1.getId())).isNull(); + assertThat(cache.getCalculatedFieldsByEntityId(profile1)).isEmpty(); + assertThat(cache.getCalculatedField(cf2.getId())).isEqualTo(cf2); + assertThat(cache.getCalculatedFieldsByEntityId(profile2)).containsExactly(cf2); + } + + @Test + public void onComponentLifecycleEvent_deviceProfileUpdated_doesNotEvictCfs() { + TenantId tenant = new TenantId(UUID.randomUUID()); + DeviceProfileId profileId = new DeviceProfileId(UUID.randomUUID()); + CalculatedField cf = addCfToCache(tenant, profileId); + + cache.onComponentLifecycleEvent(new ComponentLifecycleMsg(tenant, profileId, ComponentLifecycleEvent.UPDATED)); + + assertThat(cache.getCalculatedField(cf.getId())).isEqualTo(cf); + assertThat(cache.getCalculatedFieldsByEntityId(profileId)).containsExactly(cf); + } + + @Test + public void onComponentLifecycleEvent_assetProfileDeleted_evictsCfsForThatProfile() { + TenantId tenant = new TenantId(UUID.randomUUID()); + AssetProfileId profileId = new AssetProfileId(UUID.randomUUID()); + CalculatedField cf = addCfToCache(tenant, profileId); + + cache.onComponentLifecycleEvent(new ComponentLifecycleMsg(tenant, profileId, ComponentLifecycleEvent.DELETED)); + + assertThat(cache.getCalculatedField(cf.getId())).isNull(); + assertThat(cache.getCalculatedFieldsByEntityId(profileId)).isEmpty(); + } + + @Test + public void onComponentLifecycleEvent_assetProfileDeleted_removesLinksForLinkedEntities() { + TenantId tenant = new TenantId(UUID.randomUUID()); + AssetProfileId profileId = new AssetProfileId(UUID.randomUUID()); + AssetId linkedAsset = new AssetId(UUID.randomUUID()); + addCfToCache(tenant, profileId, linkedAsset); + + cache.onComponentLifecycleEvent(new ComponentLifecycleMsg(tenant, profileId, ComponentLifecycleEvent.DELETED)); + + assertThat(cache.getCalculatedFieldLinksByEntityId(linkedAsset)).isEmpty(); + } + + @Test + public void onComponentLifecycleEvent_assetProfileDeleted_doesNotEvictOtherProfilesCfs() { + TenantId tenant = new TenantId(UUID.randomUUID()); + AssetProfileId profile1 = new AssetProfileId(UUID.randomUUID()); + AssetProfileId profile2 = new AssetProfileId(UUID.randomUUID()); + CalculatedField cf1 = addCfToCache(tenant, profile1); + CalculatedField cf2 = addCfToCache(tenant, profile2); + + cache.onComponentLifecycleEvent(new ComponentLifecycleMsg(tenant, profile1, ComponentLifecycleEvent.DELETED)); + + assertThat(cache.getCalculatedField(cf1.getId())).isNull(); + assertThat(cache.getCalculatedFieldsByEntityId(profile1)).isEmpty(); + assertThat(cache.getCalculatedField(cf2.getId())).isEqualTo(cf2); + assertThat(cache.getCalculatedFieldsByEntityId(profile2)).containsExactly(cf2); + } + + @Test + public void onComponentLifecycleEvent_assetProfileUpdated_doesNotEvictCfs() { + TenantId tenant = new TenantId(UUID.randomUUID()); + AssetProfileId profileId = new AssetProfileId(UUID.randomUUID()); + CalculatedField cf = addCfToCache(tenant, profileId); + + cache.onComponentLifecycleEvent(new ComponentLifecycleMsg(tenant, profileId, ComponentLifecycleEvent.UPDATED)); + + assertThat(cache.getCalculatedField(cf.getId())).isEqualTo(cf); + assertThat(cache.getCalculatedFieldsByEntityId(profileId)).containsExactly(cf); + } + // --- CalculatedField lifecycle tests --- @Test From 7cce88f330e06c14752ba4e979fdfaf5be27428d Mon Sep 17 00:00:00 2001 From: dashevchenko Date: Tue, 24 Mar 2026 18:17:49 +0200 Subject: [PATCH 2/2] fixed potential NPE, code cleanup --- .../cf/DefaultCalculatedFieldCache.java | 4 +++- .../cf/DefaultCalculatedFieldCacheTest.java | 19 ------------------- .../DefaultTbAssetProfileCacheTest.java | 1 - 3 files changed, 3 insertions(+), 21 deletions(-) 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 index 66282135cd..fc54b4b0db 100644 --- a/application/src/main/java/org/thingsboard/server/service/cf/DefaultCalculatedFieldCache.java +++ b/application/src/main/java/org/thingsboard/server/service/cf/DefaultCalculatedFieldCache.java @@ -228,7 +228,9 @@ public class DefaultCalculatedFieldCache implements CalculatedFieldCache { links.forEach(link -> removedLinkEntityIds.add(link.getEntityId())); } calculatedFieldsCtx.remove(cfId); - removedCfEntityIds.add(cf.getEntityId()); + if (cf != null) { + removedCfEntityIds.add(cf.getEntityId()); + } }); removedCfEntityIds.forEach(entityId -> { entityIdCalculatedFields.compute(entityId, (k, cfs) -> { diff --git a/application/src/test/java/org/thingsboard/server/service/cf/DefaultCalculatedFieldCacheTest.java b/application/src/test/java/org/thingsboard/server/service/cf/DefaultCalculatedFieldCacheTest.java index cde66dce65..ee83f9df64 100644 --- a/application/src/test/java/org/thingsboard/server/service/cf/DefaultCalculatedFieldCacheTest.java +++ b/application/src/test/java/org/thingsboard/server/service/cf/DefaultCalculatedFieldCacheTest.java @@ -302,25 +302,6 @@ public class DefaultCalculatedFieldCacheTest { assertThat(cache.getCalculatedField(cf.getId())).isEqualTo(updatedCf); } - // --- Helpers --- - - private void stubDeviceOwner(TenantId tenantId, DeviceId deviceId, EntityId ownerId) { - Device device = new Device(); - device.setId(deviceId); - device.setTenantId(tenantId); - if (ownerId instanceof CustomerId customerId) { - device.setCustomerId(customerId); - } - // If ownerId is a TenantId, leaving customerId null means getOwnerId() returns tenantId - when(deviceService.findDeviceById(tenantId, deviceId)).thenReturn(device); - // Stubs for getOwnedEntities iteration (empty pages — device is added explicitly) - when(deviceService.findDeviceInfosByFilter(any(), any())).thenReturn(PageData.emptyPageData()); - when(assetService.findAssetsByTenantIdAndCustomerId(any(), any(), any())).thenReturn(PageData.emptyPageData()); - if (ownerId instanceof TenantId) { - when(customerService.findCustomersByTenantId(any(), any())).thenReturn(PageData.emptyPageData()); - } - } - private CalculatedField addCfToCache(TenantId tenantId, EntityId entityId) { CalculatedFieldId cfId = new CalculatedFieldId(UUID.randomUUID()); CalculatedField cf = buildCalculatedField(cfId, tenantId, entityId, simpleCfConfig()); diff --git a/application/src/test/java/org/thingsboard/server/service/profile/DefaultTbAssetProfileCacheTest.java b/application/src/test/java/org/thingsboard/server/service/profile/DefaultTbAssetProfileCacheTest.java index 6d1a66e27b..f9b8d428d7 100644 --- a/application/src/test/java/org/thingsboard/server/service/profile/DefaultTbAssetProfileCacheTest.java +++ b/application/src/test/java/org/thingsboard/server/service/profile/DefaultTbAssetProfileCacheTest.java @@ -70,7 +70,6 @@ public class DefaultTbAssetProfileCacheTest { // After deletion tenant1 profile should be reloaded from service on next get when(assetProfileService.findAssetProfileById(any(), any())).thenReturn(null); assertThat(cache.get(tenant1, profileId1)).isNull(); - // tenant2 profile should still be served from cache (no extra service call) verify(assetProfileService, times(1)).findAssetProfileById(tenant2, profileId2); }