From f651f0a721c39fc6e1be59425aeff0fa206ccfa1 Mon Sep 17 00:00:00 2001 From: dashevchenko Date: Thu, 19 Mar 2026 17:27:32 +0200 Subject: [PATCH 01/10] fixed cache cleanup on tenant/entity deletion for DefaultCalculatedFieldCache, DefaultTbAssetProfileCache, DefaultTbDeviceProfileCache --- .../cf/DefaultCalculatedFieldCache.java | 47 +++++++++++++++++++ .../profile/DefaultTbAssetProfileCache.java | 30 ++++++++++++ .../profile/DefaultTbDeviceProfileCache.java | 30 ++++++++++++ 3 files changed, 107 insertions(+) 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 1eba2c4549..57ce166014 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 @@ -19,11 +19,14 @@ import lombok.Getter; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.event.EventListener; import org.springframework.stereotype.Service; 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; @@ -35,6 +38,7 @@ import org.thingsboard.server.queue.util.AfterStartUp; import org.thingsboard.server.service.cf.ctx.state.CalculatedFieldCtx; import java.util.Collections; +import java.util.HashSet; import java.util.List; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ConcurrentMap; @@ -183,6 +187,49 @@ public class DefaultCalculatedFieldCache implements CalculatedFieldCache { log.debug("[{}] evict calculated field links from cached links by entity id: {}", calculatedFieldId, oldCalculatedField); } + @EventListener(ComponentLifecycleMsg.class) + public void onComponentLifecycleEvent(ComponentLifecycleMsg event) { + if (event.getEvent() != ComponentLifecycleEvent.DELETED) { + return; + } + switch (event.getEntityId().getEntityType()) { + case TENANT: + TenantId tenantId = event.getTenantId(); + var removedCfIds = new HashSet(); + calculatedFields.forEach((cfId, cf) -> { + if (cf.getTenantId().equals(tenantId)) { + calculatedFields.remove(cfId); + calculatedFieldLinks.remove(cfId); + calculatedFieldsCtx.remove(cfId); + removedCfIds.add(cfId); + log.debug("[{}] evict calculated field from cache on tenant deletion: {}", cfId, cf); + } + }); + entityIdCalculatedFields.values().forEach(list -> list.removeIf(cf -> removedCfIds.contains(cf.getId()))); + entityIdCalculatedFieldLinks.values().forEach(list -> list.removeIf(link -> removedCfIds.contains(link.getCalculatedFieldId()))); + break; + case DEVICE: + case ASSET: + case DEVICE_PROFILE: + case ASSET_PROFILE: + EntityId entityId = event.getEntityId(); + List cfs = entityIdCalculatedFields.remove(entityId); + if (cfs != null) { + var cfIds = new HashSet(); + cfs.forEach(cf -> { + calculatedFields.remove(cf.getId()); + calculatedFieldLinks.remove(cf.getId()); + calculatedFieldsCtx.remove(cf.getId()); + cfIds.add(cf.getId()); + log.debug("[{}] evict calculated field from cache on entity deletion: {}", cf.getId(), cf); + }); + entityIdCalculatedFieldLinks.values().forEach(list -> list.removeIf(link -> cfIds.contains(link.getCalculatedFieldId()))); + } + entityIdCalculatedFieldLinks.remove(entityId); + break; + } + } + private Lock getFetchLock(CalculatedFieldId id) { return calculatedFieldFetchLocks.computeIfAbsent(id, __ -> new ReentrantLock()); } 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 28fa68d803..a2795e6929 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 @@ -16,6 +16,7 @@ package org.thingsboard.server.service.profile; import lombok.extern.slf4j.Slf4j; +import org.springframework.context.event.EventListener; import org.springframework.stereotype.Service; import org.thingsboard.server.common.data.asset.Asset; import org.thingsboard.server.common.data.asset.AssetProfile; @@ -23,9 +24,12 @@ import org.thingsboard.server.common.data.id.AssetId; import org.thingsboard.server.common.data.id.AssetProfileId; import org.thingsboard.server.common.data.id.EntityId; import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.data.plugin.ComponentLifecycleEvent; +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.concurrent.ConcurrentHashMap; import java.util.concurrent.ConcurrentMap; import java.util.concurrent.locks.Lock; @@ -143,6 +147,32 @@ public class DefaultTbAssetProfileCache implements TbAssetProfileCache { } } + @EventListener(ComponentLifecycleMsg.class) + public void onComponentLifecycleEvent(ComponentLifecycleMsg event) { + switch (event.getEntityId().getEntityType()) { + case TENANT: + if (event.getEvent() == ComponentLifecycleEvent.DELETED) { + TenantId tenantId = event.getTenantId(); + var removedProfileIds = new HashSet(); + assetProfilesMap.forEach((assetProfileId, assetProfile) -> { + if (assetProfile.getTenantId().equals(tenantId)) { + assetProfilesMap.remove(assetProfileId); + removedProfileIds.add(assetProfileId); + log.debug("[{}] evict asset profile from cache: {}", assetProfileId, assetProfile); + } + }); + assetsMap.forEach((assetId, assetProfileId) -> { + if (removedProfileIds.contains(assetProfileId)) { + assetsMap.remove(assetId); + } + }); + profileListeners.remove(tenantId); + assetProfileListeners.remove(tenantId); + } + break; + } + } + private void notifyProfileListeners(AssetProfile profile) { ConcurrentMap> tenantListeners = profileListeners.get(profile.getTenantId()); if (tenantListeners != null) { 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 6b356adf94..93072a72c7 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 @@ -16,6 +16,7 @@ package org.thingsboard.server.service.profile; import lombok.extern.slf4j.Slf4j; +import org.springframework.context.event.EventListener; import org.springframework.stereotype.Service; import org.thingsboard.server.common.data.Device; import org.thingsboard.server.common.data.DeviceProfile; @@ -23,9 +24,12 @@ 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.plugin.ComponentLifecycleEvent; +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.concurrent.ConcurrentHashMap; import java.util.concurrent.ConcurrentMap; import java.util.concurrent.locks.Lock; @@ -143,6 +147,32 @@ public class DefaultTbDeviceProfileCache implements TbDeviceProfileCache { } } + @EventListener(ComponentLifecycleMsg.class) + public void onComponentLifecycleEvent(ComponentLifecycleMsg event) { + switch (event.getEntityId().getEntityType()) { + case TENANT: + if (event.getEvent() == ComponentLifecycleEvent.DELETED) { + TenantId tenantId = event.getTenantId(); + var removedProfileIds = new HashSet(); + deviceProfilesMap.forEach((deviceProfileId, deviceProfile) -> { + if (deviceProfile.getTenantId().equals(tenantId)) { + deviceProfilesMap.remove(deviceProfileId); + removedProfileIds.add(deviceProfileId); + log.debug("[{}] evict device profile from cache: {}", deviceProfileId, deviceProfile); + } + }); + devicesMap.forEach((deviceId, deviceProfileId) -> { + if (removedProfileIds.contains(deviceProfileId)) { + devicesMap.remove(deviceId); + } + }); + profileListeners.remove(tenantId); + deviceProfileListeners.remove(tenantId); + } + break; + } + } + private void notifyProfileListeners(DeviceProfile profile) { ConcurrentMap> tenantListeners = profileListeners.get(profile.getTenantId()); if (tenantListeners != null) { From 262565a411dcfe305768798d00fafb17090b0acf Mon Sep 17 00:00:00 2001 From: dashevchenko Date: Thu, 19 Mar 2026 19:56:07 +0200 Subject: [PATCH 02/10] refactoring --- .../cf/DefaultCalculatedFieldCache.java | 35 ++++++++++++++++--- .../profile/DefaultTbAssetProfileCache.java | 21 +++++------ .../profile/DefaultTbDeviceProfileCache.java | 21 +++++------ 3 files changed, 53 insertions(+), 24 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 57ce166014..05c60e8f9a 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 @@ -40,6 +40,7 @@ import org.thingsboard.server.service.cf.ctx.state.CalculatedFieldCtx; import java.util.Collections; import java.util.HashSet; import java.util.List; +import java.util.Map; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ConcurrentMap; import java.util.concurrent.CopyOnWriteArrayList; @@ -196,17 +197,42 @@ public class DefaultCalculatedFieldCache implements CalculatedFieldCache { case TENANT: TenantId tenantId = event.getTenantId(); var removedCfIds = new HashSet(); - calculatedFields.forEach((cfId, cf) -> { + 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); - calculatedFieldLinks.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); } + } + removedCfEntityIds.forEach(entityId -> { + List cfs = entityIdCalculatedFields.get(entityId); + if (cfs != null) { + cfs.removeIf(cf -> removedCfIds.contains(cf.getId())); + if (cfs.isEmpty()) { + entityIdCalculatedFields.remove(entityId); + } + } + }); + removedLinkEntityIds.forEach(entityId -> { + List entityLinks = entityIdCalculatedFieldLinks.get(entityId); + if (entityLinks != null) { + entityLinks.removeIf(link -> removedCfIds.contains(link.getCalculatedFieldId())); + if (entityLinks.isEmpty()) { + entityIdCalculatedFieldLinks.remove(entityId); + } + } }); - entityIdCalculatedFields.values().forEach(list -> list.removeIf(cf -> removedCfIds.contains(cf.getId()))); - entityIdCalculatedFieldLinks.values().forEach(list -> list.removeIf(link -> removedCfIds.contains(link.getCalculatedFieldId()))); + removedCfIds.forEach(calculatedFieldFetchLocks::remove); break; case DEVICE: case ASSET: @@ -224,6 +250,7 @@ public class DefaultCalculatedFieldCache implements CalculatedFieldCache { log.debug("[{}] evict calculated field from cache on entity deletion: {}", cf.getId(), cf); }); entityIdCalculatedFieldLinks.values().forEach(list -> list.removeIf(link -> cfIds.contains(link.getCalculatedFieldId()))); + cfIds.forEach(calculatedFieldFetchLocks::remove); } entityIdCalculatedFieldLinks.remove(entityId); break; 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 a2795e6929..a0bae27b68 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 @@ -30,6 +30,7 @@ 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.concurrent.ConcurrentHashMap; import java.util.concurrent.ConcurrentMap; import java.util.concurrent.locks.Lock; @@ -154,18 +155,18 @@ public class DefaultTbAssetProfileCache implements TbAssetProfileCache { if (event.getEvent() == ComponentLifecycleEvent.DELETED) { TenantId tenantId = event.getTenantId(); var removedProfileIds = new HashSet(); - assetProfilesMap.forEach((assetProfileId, assetProfile) -> { - if (assetProfile.getTenantId().equals(tenantId)) { - assetProfilesMap.remove(assetProfileId); - removedProfileIds.add(assetProfileId); - log.debug("[{}] evict asset profile from cache: {}", assetProfileId, assetProfile); + 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()); } - }); - assetsMap.forEach((assetId, assetProfileId) -> { - if (removedProfileIds.contains(assetProfileId)) { - assetsMap.remove(assetId); + } + for (Map.Entry entry : assetsMap.entrySet()) { + if (removedProfileIds.contains(entry.getValue())) { + assetsMap.remove(entry.getKey()); } - }); + } 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 93072a72c7..34b5f365f5 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 @@ -30,6 +30,7 @@ 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.concurrent.ConcurrentHashMap; import java.util.concurrent.ConcurrentMap; import java.util.concurrent.locks.Lock; @@ -154,18 +155,18 @@ public class DefaultTbDeviceProfileCache implements TbDeviceProfileCache { if (event.getEvent() == ComponentLifecycleEvent.DELETED) { TenantId tenantId = event.getTenantId(); var removedProfileIds = new HashSet(); - deviceProfilesMap.forEach((deviceProfileId, deviceProfile) -> { - if (deviceProfile.getTenantId().equals(tenantId)) { - deviceProfilesMap.remove(deviceProfileId); - removedProfileIds.add(deviceProfileId); - log.debug("[{}] evict device profile from cache: {}", deviceProfileId, deviceProfile); + 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()); } - }); - devicesMap.forEach((deviceId, deviceProfileId) -> { - if (removedProfileIds.contains(deviceProfileId)) { - devicesMap.remove(deviceId); + } + for (Map.Entry entry : devicesMap.entrySet()) { + if (removedProfileIds.contains(entry.getValue())) { + devicesMap.remove(entry.getKey()); } - }); + } profileListeners.remove(tenantId); deviceProfileListeners.remove(tenantId); } From c410b9317f91072f3b67fabe25935b13eb39d9fd Mon Sep 17 00:00:00 2001 From: dashevchenko Date: Fri, 20 Mar 2026 15:16:24 +0200 Subject: [PATCH 03/10] refactoring: moved cf cache cleanup logic from AbstractConsumerService to DefaultCalculatedFieldCache --- .../service/cf/CalculatedFieldCache.java | 2 +- .../cf/DefaultCalculatedFieldCache.java | 154 +++++++++++------- .../processing/AbstractConsumerService.java | 33 +--- 3 files changed, 96 insertions(+), 93 deletions(-) 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 index 75da5c5d7a..77e9cb3d9b 100644 --- a/application/src/main/java/org/thingsboard/server/service/cf/CalculatedFieldCache.java +++ b/application/src/main/java/org/thingsboard/server/service/cf/CalculatedFieldCache.java @@ -61,7 +61,7 @@ public interface CalculatedFieldCache { void addOwnerEntity(TenantId tenantId, EntityId entityId); - void evictEntity(EntityId entityId); + void evictOwnerEntity(EntityId entityId); void evictOwner(EntityId owner); 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 35584528a2..0f08754bf6 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 @@ -29,8 +29,6 @@ import org.thingsboard.server.common.data.TenantProfile; import org.thingsboard.server.common.data.cf.CalculatedField; import org.thingsboard.server.common.data.cf.CalculatedFieldLink; import org.thingsboard.server.common.data.cf.CalculatedFieldType; -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.AssetId; import org.thingsboard.server.common.data.id.CalculatedFieldId; @@ -40,6 +38,8 @@ import org.thingsboard.server.common.data.id.HasId; import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.common.data.id.TenantProfileId; 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.tenant.TbTenantProfileCache; import org.thingsboard.server.queue.util.AfterStartUp; @@ -50,8 +50,8 @@ import org.thingsboard.server.service.profile.TbDeviceProfileCache; import java.util.Collections; import java.util.HashSet; import java.util.List; -import java.util.Set; import java.util.Map; +import java.util.Set; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ConcurrentMap; import java.util.concurrent.CopyOnWriteArrayList; @@ -273,12 +273,12 @@ public class DefaultCalculatedFieldCache implements CalculatedFieldCache { @Override public void updateOwnerEntity(TenantId tenantId, EntityId entityId) { - evictEntity(entityId); + evictOwnerEntity(entityId); addOwnerEntity(tenantId, entityId); } @Override - public void evictEntity(EntityId entityId) { + public void evictOwnerEntity(EntityId entityId) { ownerEntities.values().forEach(entities -> entities.remove(entityId)); } @@ -297,71 +297,105 @@ public class DefaultCalculatedFieldCache implements CalculatedFieldCache { @EventListener(ComponentLifecycleMsg.class) public void onComponentLifecycleEvent(ComponentLifecycleMsg event) { - if (event.getEvent() != ComponentLifecycleEvent.DELETED) { - return; - } switch (event.getEntityId().getEntityType()) { + case TENANT_PROFILE: + if (event.getEvent() == ComponentLifecycleEvent.UPDATED) { + TenantProfileId tenantProfileId = new TenantProfileId(event.getEntityId().getId()); + handleTenantProfileUpdate(tenantProfileId); + } + break; case TENANT: - TenantId tenantId = event.getTenantId(); - 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.entityId())); + if (event.getEvent() == ComponentLifecycleEvent.DELETED) { + TenantId tenantId = event.getTenantId(); + 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.entityId())); + } + calculatedFieldsCtx.remove(cfId); + removedCfIds.add(cfId); + removedCfEntityIds.add(cf.getEntityId()); + log.debug("[{}] evict calculated field from cache on tenant deletion: {}", cfId, cf); } - calculatedFieldsCtx.remove(cfId); - removedCfIds.add(cfId); - removedCfEntityIds.add(cf.getEntityId()); - log.debug("[{}] evict calculated field from cache on tenant deletion: {}", cfId, cf); } - } - removedCfEntityIds.forEach(entityId -> { - List cfs = entityIdCalculatedFields.get(entityId); - if (cfs != null) { - cfs.removeIf(cf -> removedCfIds.contains(cf.getId())); - if (cfs.isEmpty()) { - entityIdCalculatedFields.remove(entityId); + removedCfEntityIds.forEach(entityId -> { + List cfs = entityIdCalculatedFields.get(entityId); + if (cfs != null) { + cfs.removeIf(cf -> removedCfIds.contains(cf.getId())); + if (cfs.isEmpty()) { + entityIdCalculatedFields.remove(entityId); + } } - } - }); - removedLinkEntityIds.forEach(entityId -> { - List entityLinks = entityIdCalculatedFieldLinks.get(entityId); - if (entityLinks != null) { - entityLinks.removeIf(link -> removedCfIds.contains(link.calculatedFieldId())); - if (entityLinks.isEmpty()) { - entityIdCalculatedFieldLinks.remove(entityId); + }); + removedLinkEntityIds.forEach(entityId -> { + List entityLinks = entityIdCalculatedFieldLinks.get(entityId); + if (entityLinks != null) { + entityLinks.removeIf(link -> removedCfIds.contains(link.calculatedFieldId())); + if (entityLinks.isEmpty()) { + entityIdCalculatedFieldLinks.remove(entityId); + } } - } - }); - removedCfIds.forEach(calculatedFieldFetchLocks::remove); - break; - case DEVICE: - case ASSET: - case DEVICE_PROFILE: - case ASSET_PROFILE: - EntityId entityId = event.getEntityId(); - List cfs = entityIdCalculatedFields.remove(entityId); - if (cfs != null) { - var cfIds = new HashSet(); - cfs.forEach(cf -> { - calculatedFields.remove(cf.getId()); - calculatedFieldLinks.remove(cf.getId()); - calculatedFieldsCtx.remove(cf.getId()); - cfIds.add(cf.getId()); - log.debug("[{}] evict calculated field from cache on entity deletion: {}", cf.getId(), cf); }); - entityIdCalculatedFieldLinks.values().forEach(list -> list.removeIf(link -> cfIds.contains(link.calculatedFieldId()))); - cfIds.forEach(calculatedFieldFetchLocks::remove); + removedCfIds.forEach(calculatedFieldFetchLocks::remove); + evictOwner(tenantId); + } + break; + case CUSTOMER: + if (event.getEvent().equals(ComponentLifecycleEvent.CREATED)) { + addOwnerEntity(event.getTenantId(), event.getEntityId()); + } else if (event.getEvent().equals(ComponentLifecycleEvent.UPDATED) && event.isOwnerChanged()) { + updateOwnerEntity(event.getTenantId(), event.getEntityId()); + } else if (event.getEvent() == ComponentLifecycleEvent.DELETED) { + evictOwner(event.getEntityId()); + evictOwnerEntity(event.getEntityId()); + } + break; + case DEVICE, ASSET: + if (event.getEvent().equals(ComponentLifecycleEvent.CREATED)) { + addOwnerEntity(event.getTenantId(), event.getEntityId()); + } else if (event.getEvent().equals(ComponentLifecycleEvent.UPDATED) && event.isOwnerChanged()) { + updateOwnerEntity(event.getTenantId(), event.getEntityId()); + } else if (event.getEvent().equals(ComponentLifecycleEvent.DELETED)) { + evictOwnerEntity(event.getEntityId()); + evictEntity(event.getEntityId()); } - entityIdCalculatedFieldLinks.remove(entityId); break; + case DEVICE_PROFILE, ASSET_PROFILE: + evictEntity(event.getEntityId()); + break; + case CALCULATED_FIELD: + if (event.getEvent() == ComponentLifecycleEvent.CREATED) { + addCalculatedField(event.getTenantId(), (CalculatedFieldId) event.getEntityId()); + } else if (event.getEvent() == ComponentLifecycleEvent.UPDATED) { + updateCalculatedField(event.getTenantId(), (CalculatedFieldId) event.getEntityId()); + } else { + evict((CalculatedFieldId) event.getEntityId()); + } + } + } + + private void evictEntity(EntityId entityId) { + List cfs = entityIdCalculatedFields.remove(entityId); + if (cfs != null) { + var cfIds = new HashSet(); + cfs.forEach(cf -> { + calculatedFields.remove(cf.getId()); + calculatedFieldLinks.remove(cf.getId()); + calculatedFieldsCtx.remove(cf.getId()); + cfIds.add(cf.getId()); + log.debug("[{}] evict calculated field from cache on entity deletion: {}", cf.getId(), cf); + }); + entityIdCalculatedFieldLinks.values().forEach(list -> list.removeIf(link -> cfIds.contains(link.calculatedFieldId()))); + cfIds.forEach(calculatedFieldFetchLocks::remove); } + entityIdCalculatedFieldLinks.remove(entityId); } private Lock getFetchLock(CalculatedFieldId id) { diff --git a/application/src/main/java/org/thingsboard/server/service/queue/processing/AbstractConsumerService.java b/application/src/main/java/org/thingsboard/server/service/queue/processing/AbstractConsumerService.java index ec3b50b7ce..b13ca7989d 100644 --- a/application/src/main/java/org/thingsboard/server/service/queue/processing/AbstractConsumerService.java +++ b/application/src/main/java/org/thingsboard/server/service/queue/processing/AbstractConsumerService.java @@ -26,7 +26,6 @@ import org.thingsboard.server.actors.ActorSystemContext; import org.thingsboard.server.common.data.EntityType; 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; @@ -166,7 +165,6 @@ public abstract class AbstractConsumerService Date: Fri, 20 Mar 2026 16:38:29 +0200 Subject: [PATCH 04/10] fixed DEVICE_PROFILE,ASSET_PROFILE cfs cleanup, refactoring --- .../cf/DefaultCalculatedFieldCache.java | 88 ++--- ...faultTbCalculatedFieldConsumerService.java | 4 +- .../queue/DefaultTbCoreConsumerService.java | 4 +- .../queue/DefaultTbEdgeConsumerService.java | 2 +- .../DefaultTbRuleEngineConsumerService.java | 6 +- .../processing/AbstractConsumerService.java | 2 - ...AbstractPartitionBasedConsumerService.java | 4 +- .../cf/DefaultCalculatedFieldCacheTest.java | 334 ++++++++++++++++++ .../DefaultTbAssetProfileCacheTest.java | 160 +++++++++ .../DefaultTbDeviceProfileCacheTest.java | 160 +++++++++ 10 files changed, 707 insertions(+), 57 deletions(-) create mode 100644 application/src/test/java/org/thingsboard/server/service/cf/DefaultCalculatedFieldCacheTest.java create mode 100644 application/src/test/java/org/thingsboard/server/service/profile/DefaultTbAssetProfileCacheTest.java create mode 100644 application/src/test/java/org/thingsboard/server/service/profile/DefaultTbDeviceProfileCacheTest.java 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 0f08754bf6..05419348f1 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 @@ -18,6 +18,7 @@ package org.thingsboard.server.service.cf; import lombok.Getter; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.jspecify.annotations.Nullable; import org.springframework.beans.factory.annotation.Value; import org.springframework.context.annotation.Lazy; import org.springframework.context.event.EventListener; @@ -307,43 +308,7 @@ public class DefaultCalculatedFieldCache implements CalculatedFieldCache { case TENANT: if (event.getEvent() == ComponentLifecycleEvent.DELETED) { TenantId tenantId = event.getTenantId(); - 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.entityId())); - } - calculatedFieldsCtx.remove(cfId); - removedCfIds.add(cfId); - removedCfEntityIds.add(cf.getEntityId()); - log.debug("[{}] evict calculated field from cache on tenant deletion: {}", cfId, cf); - } - } - removedCfEntityIds.forEach(entityId -> { - List cfs = entityIdCalculatedFields.get(entityId); - if (cfs != null) { - cfs.removeIf(cf -> removedCfIds.contains(cf.getId())); - if (cfs.isEmpty()) { - entityIdCalculatedFields.remove(entityId); - } - } - }); - removedLinkEntityIds.forEach(entityId -> { - List entityLinks = entityIdCalculatedFieldLinks.get(entityId); - if (entityLinks != null) { - entityLinks.removeIf(link -> removedCfIds.contains(link.calculatedFieldId())); - if (entityLinks.isEmpty()) { - entityIdCalculatedFieldLinks.remove(entityId); - } - } - }); - removedCfIds.forEach(calculatedFieldFetchLocks::remove); + evictTenantCfs(tenantId); evictOwner(tenantId); } break; @@ -364,11 +329,13 @@ public class DefaultCalculatedFieldCache implements CalculatedFieldCache { updateOwnerEntity(event.getTenantId(), event.getEntityId()); } else if (event.getEvent().equals(ComponentLifecycleEvent.DELETED)) { evictOwnerEntity(event.getEntityId()); - evictEntity(event.getEntityId()); + evictEntityCfs(event.getEntityId()); } break; case DEVICE_PROFILE, ASSET_PROFILE: - evictEntity(event.getEntityId()); + if (event.getEvent().equals(ComponentLifecycleEvent.DELETED)) { + evictEntityCfs(event.getEntityId()); + } break; case CALCULATED_FIELD: if (event.getEvent() == ComponentLifecycleEvent.CREATED) { @@ -378,10 +345,50 @@ public class DefaultCalculatedFieldCache implements CalculatedFieldCache { } else { evict((CalculatedFieldId) event.getEntityId()); } + break; + } + } + + 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.entityId())); + } + calculatedFieldsCtx.remove(cfId); + removedCfIds.add(cfId); + removedCfEntityIds.add(cf.getEntityId()); + log.debug("[{}] evict calculated field from cache on tenant deletion: {}", cfId, cf); + } } + removedCfEntityIds.forEach(entityId -> { + List cfs = entityIdCalculatedFields.get(entityId); + if (cfs != null) { + cfs.removeIf(cf -> removedCfIds.contains(cf.getId())); + if (cfs.isEmpty()) { + entityIdCalculatedFields.remove(entityId); + } + } + }); + removedLinkEntityIds.forEach(entityId -> { + List entityLinks = entityIdCalculatedFieldLinks.get(entityId); + if (entityLinks != null) { + entityLinks.removeIf(link -> removedCfIds.contains(link.calculatedFieldId())); + if (entityLinks.isEmpty()) { + entityIdCalculatedFieldLinks.remove(entityId); + } + } + }); } - private void evictEntity(EntityId entityId) { + private void evictEntityCfs(EntityId entityId) { List cfs = entityIdCalculatedFields.remove(entityId); if (cfs != null) { var cfIds = new HashSet(); @@ -393,7 +400,6 @@ public class DefaultCalculatedFieldCache implements CalculatedFieldCache { log.debug("[{}] evict calculated field from cache on entity deletion: {}", cf.getId(), cf); }); entityIdCalculatedFieldLinks.values().forEach(list -> list.removeIf(link -> cfIds.contains(link.calculatedFieldId()))); - cfIds.forEach(calculatedFieldFetchLocks::remove); } entityIdCalculatedFieldLinks.remove(entityId); } diff --git a/application/src/main/java/org/thingsboard/server/service/queue/DefaultTbCalculatedFieldConsumerService.java b/application/src/main/java/org/thingsboard/server/service/queue/DefaultTbCalculatedFieldConsumerService.java index d0f7bfa81c..df5683711a 100644 --- a/application/src/main/java/org/thingsboard/server/service/queue/DefaultTbCalculatedFieldConsumerService.java +++ b/application/src/main/java/org/thingsboard/server/service/queue/DefaultTbCalculatedFieldConsumerService.java @@ -51,7 +51,6 @@ import org.thingsboard.server.queue.discovery.event.PartitionChangeEvent; import org.thingsboard.server.queue.provider.TbRuleEngineQueueFactory; import org.thingsboard.server.queue.util.TbRuleEngineComponent; import org.thingsboard.server.service.apiusage.TbApiUsageStateService; -import org.thingsboard.server.service.cf.CalculatedFieldCache; import org.thingsboard.server.service.cf.CalculatedFieldStateService; import org.thingsboard.server.service.profile.TbAssetProfileCache; import org.thingsboard.server.service.profile.TbDeviceProfileCache; @@ -91,9 +90,8 @@ public class DefaultTbCalculatedFieldConsumerService extends AbstractPartitionBa PartitionService partitionService, ApplicationEventPublisher eventPublisher, JwtSettingsService jwtSettingsService, - CalculatedFieldCache calculatedFieldCache, CalculatedFieldStateService stateService) { - super(actorContext, tenantProfileCache, deviceProfileCache, assetProfileCache, tbResourceDataCache, calculatedFieldCache, apiUsageStateService, partitionService, + super(actorContext, tenantProfileCache, deviceProfileCache, assetProfileCache, tbResourceDataCache, apiUsageStateService, partitionService, eventPublisher, jwtSettingsService); this.queueFactory = tbQueueFactory; this.stateService = stateService; diff --git a/application/src/main/java/org/thingsboard/server/service/queue/DefaultTbCoreConsumerService.java b/application/src/main/java/org/thingsboard/server/service/queue/DefaultTbCoreConsumerService.java index 6399e55e03..bf53a8b401 100644 --- a/application/src/main/java/org/thingsboard/server/service/queue/DefaultTbCoreConsumerService.java +++ b/application/src/main/java/org/thingsboard/server/service/queue/DefaultTbCoreConsumerService.java @@ -86,7 +86,6 @@ import org.thingsboard.server.queue.discovery.event.PartitionChangeEvent; import org.thingsboard.server.queue.provider.TbCoreQueueFactory; import org.thingsboard.server.queue.util.TbCoreComponent; import org.thingsboard.server.service.apiusage.TbApiUsageStateService; -import org.thingsboard.server.service.cf.CalculatedFieldCache; import org.thingsboard.server.service.notification.NotificationSchedulerService; import org.thingsboard.server.service.ota.OtaPackageStateService; import org.thingsboard.server.service.profile.TbAssetProfileCache; @@ -179,9 +178,8 @@ public class DefaultTbCoreConsumerService extends AbstractConsumerService callCount.incrementAndGet(), null); + + cache.onComponentLifecycleEvent(new ComponentLifecycleMsg(tenant, tenant, ComponentLifecycleEvent.DELETED)); + + // Evicting a profile after tenant deletion should not trigger the removed listener + AssetProfileId profileId = new AssetProfileId(UUID.randomUUID()); + loadProfileIntoCache(tenant, profileId); + cache.evict(tenant, profileId); + + assertThat(callCount.get()).isZero(); + } + + @Test + public void onComponentLifecycleEvent_tenantUpdated_doesNotEvictProfiles() { + TenantId tenant = new TenantId(UUID.randomUUID()); + AssetProfileId profileId = new AssetProfileId(UUID.randomUUID()); + loadProfileIntoCache(tenant, profileId); + + cache.onComponentLifecycleEvent(new ComponentLifecycleMsg(tenant, tenant, ComponentLifecycleEvent.UPDATED)); + + // Profile should still be served from cache without hitting the service again + cache.get(tenant, profileId); + verify(assetProfileService, times(1)).findAssetProfileById(tenant, profileId); + } + + @Test + public void onComponentLifecycleEvent_differentTenantDeleted_keepsOtherTenantsProfiles() { + TenantId tenant1 = new TenantId(UUID.randomUUID()); + TenantId tenant2 = new TenantId(UUID.randomUUID()); + AssetProfileId profileId1 = new AssetProfileId(UUID.randomUUID()); + AssetProfileId profileId2 = new AssetProfileId(UUID.randomUUID()); + + AssetProfile profile1 = loadProfileIntoCache(tenant1, profileId1); + loadProfileIntoCache(tenant2, profileId2); + + cache.onComponentLifecycleEvent(new ComponentLifecycleMsg(tenant2, tenant2, ComponentLifecycleEvent.DELETED)); + + assertThat(cache.get(tenant1, profileId1)).isEqualTo(profile1); + verify(assetProfileService, times(1)).findAssetProfileById(tenant1, profileId1); + } + + // --- Helpers --- + + private AssetProfile loadProfileIntoCache(TenantId tenantId, AssetProfileId profileId) { + AssetProfile profile = new AssetProfile(); + profile.setId(profileId); + profile.setTenantId(tenantId); + when(assetProfileService.findAssetProfileById(tenantId, profileId)).thenReturn(profile); + cache.get(tenantId, profileId); + return profile; + } + + private void loadAssetMappingIntoCache(TenantId tenantId, AssetId assetId, AssetProfileId profileId) { + Asset asset = new Asset(); + asset.setId(assetId); + asset.setAssetProfileId(profileId); + when(assetService.findAssetById(tenantId, assetId)).thenReturn(asset); + cache.get(tenantId, assetId); + } + +} diff --git a/application/src/test/java/org/thingsboard/server/service/profile/DefaultTbDeviceProfileCacheTest.java b/application/src/test/java/org/thingsboard/server/service/profile/DefaultTbDeviceProfileCacheTest.java new file mode 100644 index 0000000000..a26413514c --- /dev/null +++ b/application/src/test/java/org/thingsboard/server/service/profile/DefaultTbDeviceProfileCacheTest.java @@ -0,0 +1,160 @@ +/** + * Copyright © 2016-2026 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.profile; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.thingsboard.server.common.data.Device; +import org.thingsboard.server.common.data.DeviceProfile; +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.plugin.ComponentLifecycleEvent; +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.UUID; +import java.util.concurrent.atomic.AtomicInteger; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +public class DefaultTbDeviceProfileCacheTest { + + @Mock + private DeviceProfileService deviceProfileService; + @Mock + private DeviceService deviceService; + + private DefaultTbDeviceProfileCache cache; + + @BeforeEach + public void setUp() { + cache = new DefaultTbDeviceProfileCache(deviceProfileService, deviceService); + } + + @Test + public void onComponentLifecycleEvent_tenantDeleted_evictsDeviceProfilesForThatTenant() { + TenantId tenant1 = new TenantId(UUID.randomUUID()); + TenantId tenant2 = new TenantId(UUID.randomUUID()); + DeviceProfileId profileId1 = new DeviceProfileId(UUID.randomUUID()); + DeviceProfileId profileId2 = new DeviceProfileId(UUID.randomUUID()); + + loadProfileIntoCache(tenant1, profileId1); + loadProfileIntoCache(tenant2, profileId2); + + cache.onComponentLifecycleEvent(new ComponentLifecycleMsg(tenant1, tenant1, ComponentLifecycleEvent.DELETED)); + + // After deletion tenant1 profile should be reloaded from service on next get + when(deviceProfileService.findDeviceProfileById(any(), any())).thenReturn(null); + assertThat(cache.get(tenant1, profileId1)).isNull(); + // tenant2 profile should still be served from cache (no extra service call) + verify(deviceProfileService, times(1)).findDeviceProfileById(tenant2, profileId2); + } + + @Test + public void onComponentLifecycleEvent_tenantDeleted_evictsDeviceMappingsForThatTenant() { + TenantId tenant = new TenantId(UUID.randomUUID()); + DeviceProfileId profileId = new DeviceProfileId(UUID.randomUUID()); + DeviceId deviceId = new DeviceId(UUID.randomUUID()); + + loadProfileIntoCache(tenant, profileId); + loadDeviceMappingIntoCache(tenant, deviceId, profileId); + + cache.onComponentLifecycleEvent(new ComponentLifecycleMsg(tenant, tenant, ComponentLifecycleEvent.DELETED)); + + // After tenant deletion, device-to-profile mapping should be gone; get() should try to reload + when(deviceService.findDeviceById(any(), any())).thenReturn(null); + assertThat(cache.get(tenant, deviceId)).isNull(); + verify(deviceService, times(2)).findDeviceById(tenant, deviceId); // once on load, once after eviction + } + + @Test + public void onComponentLifecycleEvent_tenantDeleted_removesListenersForThatTenant() { + TenantId tenant = new TenantId(UUID.randomUUID()); + EntityId listenerId = new DeviceId(UUID.randomUUID()); + AtomicInteger callCount = new AtomicInteger(); + + cache.addListener(tenant, listenerId, profile -> callCount.incrementAndGet(), null); + + cache.onComponentLifecycleEvent(new ComponentLifecycleMsg(tenant, tenant, ComponentLifecycleEvent.DELETED)); + + // Evicting a profile after tenant deletion should not trigger the removed listener + DeviceProfileId profileId = new DeviceProfileId(UUID.randomUUID()); + loadProfileIntoCache(tenant, profileId); + cache.evict(tenant, profileId); + + assertThat(callCount.get()).isZero(); + } + + @Test + public void onComponentLifecycleEvent_tenantUpdated_doesNotEvictProfiles() { + TenantId tenant = new TenantId(UUID.randomUUID()); + DeviceProfileId profileId = new DeviceProfileId(UUID.randomUUID()); + loadProfileIntoCache(tenant, profileId); + + cache.onComponentLifecycleEvent(new ComponentLifecycleMsg(tenant, tenant, ComponentLifecycleEvent.UPDATED)); + + // Profile should still be served from cache without hitting the service again + cache.get(tenant, profileId); + verify(deviceProfileService, times(1)).findDeviceProfileById(tenant, profileId); + } + + @Test + public void onComponentLifecycleEvent_differentTenantDeleted_keepsOtherTenantsProfiles() { + TenantId tenant1 = new TenantId(UUID.randomUUID()); + TenantId tenant2 = new TenantId(UUID.randomUUID()); + DeviceProfileId profileId1 = new DeviceProfileId(UUID.randomUUID()); + DeviceProfileId profileId2 = new DeviceProfileId(UUID.randomUUID()); + + DeviceProfile profile1 = loadProfileIntoCache(tenant1, profileId1); + loadProfileIntoCache(tenant2, profileId2); + + cache.onComponentLifecycleEvent(new ComponentLifecycleMsg(tenant2, tenant2, ComponentLifecycleEvent.DELETED)); + + assertThat(cache.get(tenant1, profileId1)).isEqualTo(profile1); + verify(deviceProfileService, times(1)).findDeviceProfileById(tenant1, profileId1); + } + + // --- Helpers --- + + private DeviceProfile loadProfileIntoCache(TenantId tenantId, DeviceProfileId profileId) { + DeviceProfile profile = new DeviceProfile(); + profile.setId(profileId); + profile.setTenantId(tenantId); + when(deviceProfileService.findDeviceProfileById(tenantId, profileId)).thenReturn(profile); + cache.get(tenantId, profileId); + return profile; + } + + private void loadDeviceMappingIntoCache(TenantId tenantId, DeviceId deviceId, DeviceProfileId profileId) { + Device device = new Device(); + device.setId(deviceId); + device.setDeviceProfileId(profileId); + when(deviceService.findDeviceById(tenantId, deviceId)).thenReturn(device); + cache.get(tenantId, deviceId); + } + +} From f0af6882821d149458f2df5b36588c188aa9dafb Mon Sep 17 00:00:00 2001 From: dashevchenko Date: Fri, 20 Mar 2026 16:57:41 +0200 Subject: [PATCH 05/10] refactoring, added tests --- .../cf/DefaultCalculatedFieldCache.java | 127 +++++---- ...faultTbCalculatedFieldConsumerService.java | 3 +- .../queue/DefaultTbCoreConsumerService.java | 3 +- .../queue/DefaultTbEdgeConsumerService.java | 2 +- .../DefaultTbRuleEngineConsumerService.java | 5 +- .../processing/AbstractConsumerService.java | 13 +- ...AbstractPartitionBasedConsumerService.java | 3 +- .../cf/DefaultCalculatedFieldCacheTest.java | 260 ++++++++++++++++++ .../DefaultTbAssetProfileCacheTest.java | 160 +++++++++++ .../DefaultTbDeviceProfileCacheTest.java | 160 +++++++++++ 10 files changed, 656 insertions(+), 80 deletions(-) create mode 100644 application/src/test/java/org/thingsboard/server/service/cf/DefaultCalculatedFieldCacheTest.java create mode 100644 application/src/test/java/org/thingsboard/server/service/profile/DefaultTbAssetProfileCacheTest.java create mode 100644 application/src/test/java/org/thingsboard/server/service/profile/DefaultTbDeviceProfileCacheTest.java 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 05c60e8f9a..5922551a29 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 @@ -190,71 +190,82 @@ public class DefaultCalculatedFieldCache implements CalculatedFieldCache { @EventListener(ComponentLifecycleMsg.class) public void onComponentLifecycleEvent(ComponentLifecycleMsg event) { - if (event.getEvent() != ComponentLifecycleEvent.DELETED) { - return; - } switch (event.getEntityId().getEntityType()) { case TENANT: - TenantId tenantId = event.getTenantId(); - 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); - } + if (event.getEvent() == ComponentLifecycleEvent.DELETED) { + evictTenantCfs(event.getTenantId()); } - removedCfEntityIds.forEach(entityId -> { - List cfs = entityIdCalculatedFields.get(entityId); - if (cfs != null) { - cfs.removeIf(cf -> removedCfIds.contains(cf.getId())); - if (cfs.isEmpty()) { - entityIdCalculatedFields.remove(entityId); - } - } - }); - removedLinkEntityIds.forEach(entityId -> { - List entityLinks = entityIdCalculatedFieldLinks.get(entityId); - if (entityLinks != null) { - entityLinks.removeIf(link -> removedCfIds.contains(link.getCalculatedFieldId())); - if (entityLinks.isEmpty()) { - entityIdCalculatedFieldLinks.remove(entityId); - } - } - }); - removedCfIds.forEach(calculatedFieldFetchLocks::remove); break; - case DEVICE: - case ASSET: - case DEVICE_PROFILE: - case ASSET_PROFILE: - EntityId entityId = event.getEntityId(); - List cfs = entityIdCalculatedFields.remove(entityId); - if (cfs != null) { - var cfIds = new HashSet(); - cfs.forEach(cf -> { - calculatedFields.remove(cf.getId()); - calculatedFieldLinks.remove(cf.getId()); - calculatedFieldsCtx.remove(cf.getId()); - cfIds.add(cf.getId()); - log.debug("[{}] evict calculated field from cache on entity deletion: {}", cf.getId(), cf); - }); - entityIdCalculatedFieldLinks.values().forEach(list -> list.removeIf(link -> cfIds.contains(link.getCalculatedFieldId()))); - cfIds.forEach(calculatedFieldFetchLocks::remove); + case DEVICE, ASSET, DEVICE_PROFILE, ASSET_PROFILE: + if (event.getEvent() == ComponentLifecycleEvent.DELETED) { + evictEntityCfs(event.getEntityId()); } - entityIdCalculatedFieldLinks.remove(entityId); break; + case CALCULATED_FIELD: + if (event.getEvent() == ComponentLifecycleEvent.CREATED) { + addCalculatedField(event.getTenantId(), (CalculatedFieldId) event.getEntityId()); + } else if (event.getEvent() == ComponentLifecycleEvent.UPDATED) { + updateCalculatedField(event.getTenantId(), (CalculatedFieldId) event.getEntityId()); + } else if (event.getEvent() == ComponentLifecycleEvent.DELETED) { + evict((CalculatedFieldId) event.getEntityId()); + } + break; + } + } + + 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); + } + } + removedCfEntityIds.forEach(entityId -> { + List cfs = entityIdCalculatedFields.get(entityId); + if (cfs != null) { + cfs.removeIf(cf -> removedCfIds.contains(cf.getId())); + if (cfs.isEmpty()) { + entityIdCalculatedFields.remove(entityId); + } + } + }); + removedLinkEntityIds.forEach(entityId -> { + List entityLinks = entityIdCalculatedFieldLinks.get(entityId); + if (entityLinks != null) { + entityLinks.removeIf(link -> removedCfIds.contains(link.getCalculatedFieldId())); + if (entityLinks.isEmpty()) { + entityIdCalculatedFieldLinks.remove(entityId); + } + } + }); + } + + private void evictEntityCfs(EntityId entityId) { + List cfs = entityIdCalculatedFields.remove(entityId); + if (cfs != null) { + var cfIds = new HashSet(); + cfs.forEach(cf -> { + calculatedFields.remove(cf.getId()); + calculatedFieldLinks.remove(cf.getId()); + calculatedFieldsCtx.remove(cf.getId()); + cfIds.add(cf.getId()); + log.debug("[{}] evict calculated field from cache on entity deletion: {}", cf.getId(), cf); + }); + entityIdCalculatedFieldLinks.values().forEach(list -> list.removeIf(link -> cfIds.contains(link.getCalculatedFieldId()))); } + entityIdCalculatedFieldLinks.remove(entityId); } private Lock getFetchLock(CalculatedFieldId id) { diff --git a/application/src/main/java/org/thingsboard/server/service/queue/DefaultTbCalculatedFieldConsumerService.java b/application/src/main/java/org/thingsboard/server/service/queue/DefaultTbCalculatedFieldConsumerService.java index 60e60b5444..ba4ab74f37 100644 --- a/application/src/main/java/org/thingsboard/server/service/queue/DefaultTbCalculatedFieldConsumerService.java +++ b/application/src/main/java/org/thingsboard/server/service/queue/DefaultTbCalculatedFieldConsumerService.java @@ -90,9 +90,8 @@ public class DefaultTbCalculatedFieldConsumerService extends AbstractPartitionBa PartitionService partitionService, ApplicationEventPublisher eventPublisher, JwtSettingsService jwtSettingsService, - CalculatedFieldCache calculatedFieldCache, CalculatedFieldStateService stateService) { - super(actorContext, tenantProfileCache, deviceProfileCache, assetProfileCache, tbResourceDataCache, calculatedFieldCache, apiUsageStateService, partitionService, + super(actorContext, tenantProfileCache, deviceProfileCache, assetProfileCache, tbResourceDataCache, apiUsageStateService, partitionService, eventPublisher, jwtSettingsService); this.queueFactory = tbQueueFactory; this.stateService = stateService; diff --git a/application/src/main/java/org/thingsboard/server/service/queue/DefaultTbCoreConsumerService.java b/application/src/main/java/org/thingsboard/server/service/queue/DefaultTbCoreConsumerService.java index 6399e55e03..de2e65723e 100644 --- a/application/src/main/java/org/thingsboard/server/service/queue/DefaultTbCoreConsumerService.java +++ b/application/src/main/java/org/thingsboard/server/service/queue/DefaultTbCoreConsumerService.java @@ -179,9 +179,8 @@ public class DefaultTbCoreConsumerService extends AbstractConsumerService callCount.incrementAndGet(), null); + + cache.onComponentLifecycleEvent(new ComponentLifecycleMsg(tenant, tenant, ComponentLifecycleEvent.DELETED)); + + // Evicting a profile after tenant deletion should not trigger the removed listener + AssetProfileId profileId = new AssetProfileId(UUID.randomUUID()); + loadProfileIntoCache(tenant, profileId); + cache.evict(tenant, profileId); + + assertThat(callCount.get()).isZero(); + } + + @Test + public void onComponentLifecycleEvent_tenantUpdated_doesNotEvictProfiles() { + TenantId tenant = new TenantId(UUID.randomUUID()); + AssetProfileId profileId = new AssetProfileId(UUID.randomUUID()); + loadProfileIntoCache(tenant, profileId); + + cache.onComponentLifecycleEvent(new ComponentLifecycleMsg(tenant, tenant, ComponentLifecycleEvent.UPDATED)); + + // Profile should still be served from cache without hitting the service again + cache.get(tenant, profileId); + verify(assetProfileService, times(1)).findAssetProfileById(tenant, profileId); + } + + @Test + public void onComponentLifecycleEvent_differentTenantDeleted_keepsOtherTenantsProfiles() { + TenantId tenant1 = new TenantId(UUID.randomUUID()); + TenantId tenant2 = new TenantId(UUID.randomUUID()); + AssetProfileId profileId1 = new AssetProfileId(UUID.randomUUID()); + AssetProfileId profileId2 = new AssetProfileId(UUID.randomUUID()); + + AssetProfile profile1 = loadProfileIntoCache(tenant1, profileId1); + loadProfileIntoCache(tenant2, profileId2); + + cache.onComponentLifecycleEvent(new ComponentLifecycleMsg(tenant2, tenant2, ComponentLifecycleEvent.DELETED)); + + assertThat(cache.get(tenant1, profileId1)).isEqualTo(profile1); + verify(assetProfileService, times(1)).findAssetProfileById(tenant1, profileId1); + } + + // --- Helpers --- + + private AssetProfile loadProfileIntoCache(TenantId tenantId, AssetProfileId profileId) { + AssetProfile profile = new AssetProfile(); + profile.setId(profileId); + profile.setTenantId(tenantId); + when(assetProfileService.findAssetProfileById(tenantId, profileId)).thenReturn(profile); + cache.get(tenantId, profileId); + return profile; + } + + private void loadAssetMappingIntoCache(TenantId tenantId, AssetId assetId, AssetProfileId profileId) { + Asset asset = new Asset(); + asset.setId(assetId); + asset.setAssetProfileId(profileId); + when(assetService.findAssetById(tenantId, assetId)).thenReturn(asset); + cache.get(tenantId, assetId); + } + +} diff --git a/application/src/test/java/org/thingsboard/server/service/profile/DefaultTbDeviceProfileCacheTest.java b/application/src/test/java/org/thingsboard/server/service/profile/DefaultTbDeviceProfileCacheTest.java new file mode 100644 index 0000000000..a26413514c --- /dev/null +++ b/application/src/test/java/org/thingsboard/server/service/profile/DefaultTbDeviceProfileCacheTest.java @@ -0,0 +1,160 @@ +/** + * Copyright © 2016-2026 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.profile; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.thingsboard.server.common.data.Device; +import org.thingsboard.server.common.data.DeviceProfile; +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.plugin.ComponentLifecycleEvent; +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.UUID; +import java.util.concurrent.atomic.AtomicInteger; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +public class DefaultTbDeviceProfileCacheTest { + + @Mock + private DeviceProfileService deviceProfileService; + @Mock + private DeviceService deviceService; + + private DefaultTbDeviceProfileCache cache; + + @BeforeEach + public void setUp() { + cache = new DefaultTbDeviceProfileCache(deviceProfileService, deviceService); + } + + @Test + public void onComponentLifecycleEvent_tenantDeleted_evictsDeviceProfilesForThatTenant() { + TenantId tenant1 = new TenantId(UUID.randomUUID()); + TenantId tenant2 = new TenantId(UUID.randomUUID()); + DeviceProfileId profileId1 = new DeviceProfileId(UUID.randomUUID()); + DeviceProfileId profileId2 = new DeviceProfileId(UUID.randomUUID()); + + loadProfileIntoCache(tenant1, profileId1); + loadProfileIntoCache(tenant2, profileId2); + + cache.onComponentLifecycleEvent(new ComponentLifecycleMsg(tenant1, tenant1, ComponentLifecycleEvent.DELETED)); + + // After deletion tenant1 profile should be reloaded from service on next get + when(deviceProfileService.findDeviceProfileById(any(), any())).thenReturn(null); + assertThat(cache.get(tenant1, profileId1)).isNull(); + // tenant2 profile should still be served from cache (no extra service call) + verify(deviceProfileService, times(1)).findDeviceProfileById(tenant2, profileId2); + } + + @Test + public void onComponentLifecycleEvent_tenantDeleted_evictsDeviceMappingsForThatTenant() { + TenantId tenant = new TenantId(UUID.randomUUID()); + DeviceProfileId profileId = new DeviceProfileId(UUID.randomUUID()); + DeviceId deviceId = new DeviceId(UUID.randomUUID()); + + loadProfileIntoCache(tenant, profileId); + loadDeviceMappingIntoCache(tenant, deviceId, profileId); + + cache.onComponentLifecycleEvent(new ComponentLifecycleMsg(tenant, tenant, ComponentLifecycleEvent.DELETED)); + + // After tenant deletion, device-to-profile mapping should be gone; get() should try to reload + when(deviceService.findDeviceById(any(), any())).thenReturn(null); + assertThat(cache.get(tenant, deviceId)).isNull(); + verify(deviceService, times(2)).findDeviceById(tenant, deviceId); // once on load, once after eviction + } + + @Test + public void onComponentLifecycleEvent_tenantDeleted_removesListenersForThatTenant() { + TenantId tenant = new TenantId(UUID.randomUUID()); + EntityId listenerId = new DeviceId(UUID.randomUUID()); + AtomicInteger callCount = new AtomicInteger(); + + cache.addListener(tenant, listenerId, profile -> callCount.incrementAndGet(), null); + + cache.onComponentLifecycleEvent(new ComponentLifecycleMsg(tenant, tenant, ComponentLifecycleEvent.DELETED)); + + // Evicting a profile after tenant deletion should not trigger the removed listener + DeviceProfileId profileId = new DeviceProfileId(UUID.randomUUID()); + loadProfileIntoCache(tenant, profileId); + cache.evict(tenant, profileId); + + assertThat(callCount.get()).isZero(); + } + + @Test + public void onComponentLifecycleEvent_tenantUpdated_doesNotEvictProfiles() { + TenantId tenant = new TenantId(UUID.randomUUID()); + DeviceProfileId profileId = new DeviceProfileId(UUID.randomUUID()); + loadProfileIntoCache(tenant, profileId); + + cache.onComponentLifecycleEvent(new ComponentLifecycleMsg(tenant, tenant, ComponentLifecycleEvent.UPDATED)); + + // Profile should still be served from cache without hitting the service again + cache.get(tenant, profileId); + verify(deviceProfileService, times(1)).findDeviceProfileById(tenant, profileId); + } + + @Test + public void onComponentLifecycleEvent_differentTenantDeleted_keepsOtherTenantsProfiles() { + TenantId tenant1 = new TenantId(UUID.randomUUID()); + TenantId tenant2 = new TenantId(UUID.randomUUID()); + DeviceProfileId profileId1 = new DeviceProfileId(UUID.randomUUID()); + DeviceProfileId profileId2 = new DeviceProfileId(UUID.randomUUID()); + + DeviceProfile profile1 = loadProfileIntoCache(tenant1, profileId1); + loadProfileIntoCache(tenant2, profileId2); + + cache.onComponentLifecycleEvent(new ComponentLifecycleMsg(tenant2, tenant2, ComponentLifecycleEvent.DELETED)); + + assertThat(cache.get(tenant1, profileId1)).isEqualTo(profile1); + verify(deviceProfileService, times(1)).findDeviceProfileById(tenant1, profileId1); + } + + // --- Helpers --- + + private DeviceProfile loadProfileIntoCache(TenantId tenantId, DeviceProfileId profileId) { + DeviceProfile profile = new DeviceProfile(); + profile.setId(profileId); + profile.setTenantId(tenantId); + when(deviceProfileService.findDeviceProfileById(tenantId, profileId)).thenReturn(profile); + cache.get(tenantId, profileId); + return profile; + } + + private void loadDeviceMappingIntoCache(TenantId tenantId, DeviceId deviceId, DeviceProfileId profileId) { + Device device = new Device(); + device.setId(deviceId); + device.setDeviceProfileId(profileId); + when(deviceService.findDeviceById(tenantId, deviceId)).thenReturn(device); + cache.get(tenantId, deviceId); + } + +} From 8204bc4601a39c6e1c89cf12339764feb1ab0cae Mon Sep 17 00:00:00 2001 From: dashevchenko Date: Mon, 23 Mar 2026 09:52:59 +0200 Subject: [PATCH 06/10] fixed Tenant profile not found handling during cf initialization --- .../server/service/cf/ctx/state/CalculatedFieldCtx.java | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) 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 index 373480e161..cd3a949aef 100644 --- 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 @@ -305,7 +305,8 @@ public class CalculatedFieldCtx implements Closeable { public void setTenantProfileProperties() { TenantProfile tenantProfile = systemContext.getTenantProfileCache().get(tenantId); if (tenantProfile == null) { - throw new IllegalStateException("Tenant Profile not found for tenant: " + tenantId); + log.warn("Tenant Profile not found for tenant: {}. Using default values for CF configuration.", tenantId); + return; } tenantProfile.getProfileConfiguration().ifPresent(config -> { this.maxStateSize = config.getMaxStateSizeInKBytes() * 1024L; From 6e2eb5dc34416332908033fec49bc9b00b987842 Mon Sep 17 00:00:00 2001 From: dashevchenko Date: Mon, 23 Mar 2026 10:09:21 +0200 Subject: [PATCH 07/10] refactoring --- .../service/cf/DefaultCalculatedFieldCache.java | 12 ++++++------ 1 file changed, 6 insertions(+), 6 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 05419348f1..7b206689de 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 @@ -313,9 +313,9 @@ public class DefaultCalculatedFieldCache implements CalculatedFieldCache { } break; case CUSTOMER: - if (event.getEvent().equals(ComponentLifecycleEvent.CREATED)) { + if (event.getEvent() == ComponentLifecycleEvent.CREATED) { addOwnerEntity(event.getTenantId(), event.getEntityId()); - } else if (event.getEvent().equals(ComponentLifecycleEvent.UPDATED) && event.isOwnerChanged()) { + } else if (event.getEvent() == ComponentLifecycleEvent.UPDATED && event.isOwnerChanged()) { updateOwnerEntity(event.getTenantId(), event.getEntityId()); } else if (event.getEvent() == ComponentLifecycleEvent.DELETED) { evictOwner(event.getEntityId()); @@ -323,17 +323,17 @@ public class DefaultCalculatedFieldCache implements CalculatedFieldCache { } break; case DEVICE, ASSET: - if (event.getEvent().equals(ComponentLifecycleEvent.CREATED)) { + if (event.getEvent() == ComponentLifecycleEvent.CREATED) { addOwnerEntity(event.getTenantId(), event.getEntityId()); - } else if (event.getEvent().equals(ComponentLifecycleEvent.UPDATED) && event.isOwnerChanged()) { + } else if (event.getEvent() == ComponentLifecycleEvent.UPDATED && event.isOwnerChanged()) { updateOwnerEntity(event.getTenantId(), event.getEntityId()); - } else if (event.getEvent().equals(ComponentLifecycleEvent.DELETED)) { + } else if (event.getEvent() == ComponentLifecycleEvent.DELETED) { evictOwnerEntity(event.getEntityId()); evictEntityCfs(event.getEntityId()); } break; case DEVICE_PROFILE, ASSET_PROFILE: - if (event.getEvent().equals(ComponentLifecycleEvent.DELETED)) { + if (event.getEvent() == ComponentLifecycleEvent.DELETED) { evictEntityCfs(event.getEntityId()); } break; From c7ae240d5a97c018ea9a72c23116e57c7a9d0b22 Mon Sep 17 00:00:00 2001 From: dashevchenko Date: Mon, 23 Mar 2026 10:46:12 +0200 Subject: [PATCH 08/10] fixed evictOwner method to delete customers/subcustomers entities as well --- .../server/service/cf/DefaultCalculatedFieldCache.java | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) 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 7b206689de..d3142fdac5 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 @@ -59,6 +59,7 @@ import java.util.concurrent.CopyOnWriteArrayList; import java.util.concurrent.locks.Lock; import java.util.concurrent.locks.ReentrantLock; import java.util.function.Predicate; +import java.util.stream.Collectors; import java.util.stream.Stream; @Service @@ -285,7 +286,14 @@ public class DefaultCalculatedFieldCache implements CalculatedFieldCache { @Override public void evictOwner(EntityId owner) { - ownerEntities.remove(owner); + Set removedEntities = ownerEntities.remove(owner); + if (removedEntities != null) { + Set removedCustomers = removedEntities + .stream() + .filter(entityId -> entityId.getEntityType() == EntityType.CUSTOMER) + .collect(Collectors.toSet()); + removedCustomers.forEach(this::evictOwner); + } } private Set getOwnedEntities(TenantId tenantId, EntityId ownerId) { From c033f4b5b3a248a27e7d871dfc13df04f1e83eb2 Mon Sep 17 00:00:00 2001 From: dashevchenko Date: Tue, 24 Mar 2026 18:08:28 +0200 Subject: [PATCH 09/10] 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 10/10] 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); }