Browse Source

Merge pull request #15744 from thingsboard/fix/transport-tenant-profile-lock-convoy

Fixed transport tenant-profile lock convoy under cold-cache reconnect storm
pull/15767/head
Viacheslav Klimov 2 months ago
committed by GitHub
parent
commit
68b4c01f9b
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 2
      application/src/main/resources/thingsboard.yml
  2. 115
      common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/limits/DefaultTransportRateLimitService.java
  3. 35
      common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/limits/TransportLimitsType.java
  4. 4
      common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/service/DefaultTransportService.java
  5. 76
      common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/service/DefaultTransportTenantProfileCache.java
  6. 202
      common/transport/transport-api/src/test/java/org/thingsboard/server/common/transport/limits/DefaultTransportRateLimitServiceTest.java
  7. 191
      common/transport/transport-api/src/test/java/org/thingsboard/server/common/transport/service/DefaultTransportTenantProfileCacheTest.java
  8. 2
      transport/coap/src/main/resources/tb-coap-transport.yml
  9. 2
      transport/http/src/main/resources/tb-http-transport.yml
  10. 2
      transport/lwm2m/src/main/resources/tb-lwm2m-transport.yml
  11. 2
      transport/mqtt/src/main/resources/tb-mqtt-transport.yml
  12. 2
      transport/snmp/src/main/resources/tb-snmp-transport.yml

2
application/src/main/resources/thingsboard.yml

@ -1138,6 +1138,8 @@ transport:
timeout: "${CLIENT_SIDE_RPC_TIMEOUT:60000}"
# Enable/disable http/mqtt/coap/lwm2m transport protocols (has higher priority than certain protocol's 'enabled' property)
api_enabled: "${TB_TRANSPORT_API_ENABLED:true}"
# Size of the thread pool that executes transport API callbacks (session registration, telemetry/attribute and RPC responses, entity update notifications, and the tenant profile fetch on a cache miss). Bounds how many such callbacks - including those that block on a backend round-trip - can run concurrently.
callback_thread_pool_size: "${TB_TRANSPORT_CALLBACK_THREAD_POOL_SIZE:20}"
log:
# Enable/Disable log of transport messages to telemetry. For example, logging of LwM2M registration update
enabled: "${TB_TRANSPORT_LOG_ENABLED:true}"

115
common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/limits/DefaultTransportRateLimitService.java

@ -107,11 +107,12 @@ public class DefaultTransportRateLimitService implements TransportRateLimitServi
@Override
public void update(TenantProfileUpdateResult update) {
log.info("Received tenant profile update: {}", update.getProfile());
EntityTransportRateLimits tenantRateLimitPrototype = createRateLimits(update.getProfile(), TENANT_LIMITS);
EntityTransportRateLimits deviceRateLimitPrototype = createRateLimits(update.getProfile(), DEVICE_LIMITS);
EntityTransportRateLimits gatewayRateLimitPrototype = createRateLimits(update.getProfile(), GATEWAY_LIMITS);
EntityTransportRateLimits gatewayDeviceRateLimitPrototype = createRateLimits(update.getProfile(), GATEWAY_DEVICE_LIMITS);
TenantProfile profile = update.getProfile();
log.info("Received tenant profile update: {}", profile);
EntityTransportRateLimits tenantRateLimitPrototype = createRateLimits(profile, TENANT_LIMITS);
EntityTransportRateLimits deviceRateLimitPrototype = createRateLimits(profile, DEVICE_LIMITS);
EntityTransportRateLimits gatewayRateLimitPrototype = createRateLimits(profile, GATEWAY_LIMITS);
EntityTransportRateLimits gatewayDeviceRateLimitPrototype = createRateLimits(profile, GATEWAY_DEVICE_LIMITS);
for (TenantId tenantId : update.getAffectedTenants()) {
update(tenantId, tenantRateLimitPrototype, deviceRateLimitPrototype, gatewayRateLimitPrototype, gatewayDeviceRateLimitPrototype);
}
@ -119,11 +120,13 @@ public class DefaultTransportRateLimitService implements TransportRateLimitServi
@Override
public void update(TenantId tenantId) {
EntityTransportRateLimits tenantRateLimitPrototype = createRateLimits(tenantProfileCache.get(tenantId), TENANT_LIMITS);
EntityTransportRateLimits deviceRateLimitPrototype = createRateLimits(tenantProfileCache.get(tenantId), DEVICE_LIMITS);
EntityTransportRateLimits gatewayRateLimitPrototype = createRateLimits(tenantProfileCache.get(tenantId), GATEWAY_LIMITS);
EntityTransportRateLimits gatewayDeviceRateLimitPrototype = createRateLimits(tenantProfileCache.get(tenantId), GATEWAY_DEVICE_LIMITS);
update(tenantId, tenantRateLimitPrototype, deviceRateLimitPrototype, gatewayRateLimitPrototype, gatewayDeviceRateLimitPrototype);
TenantProfile profile = tenantProfileCache.get(tenantId);
update(tenantId,
createRateLimits(profile, TENANT_LIMITS),
createRateLimits(profile, DEVICE_LIMITS),
createRateLimits(profile, GATEWAY_LIMITS),
createRateLimits(profile, GATEWAY_DEVICE_LIMITS)
);
}
private void update(TenantId tenantId, EntityTransportRateLimits tenantRateLimitPrototype, EntityTransportRateLimits deviceRateLimitPrototype,
@ -231,25 +234,26 @@ public class DefaultTransportRateLimitService implements TransportRateLimitServi
BiConsumer<T, EntityTransportRateLimits> putFunction) {
EntityTransportRateLimits oldRateLimits = getFunction.apply(entityId);
if (oldRateLimits == null) {
if (EntityType.TENANT.equals(entityId.getEntityType())) {
log.info("[{}] New rate limits: {}", entityId, newRateLimits);
} else {
log.debug("[{}] New rate limits: {}", entityId, newRateLimits);
}
logLimits(entityId, "New", newRateLimits);
putFunction.accept(entityId, newRateLimits);
} else {
EntityTransportRateLimits updated = merge(oldRateLimits, newRateLimits);
if (updated != null) {
if (EntityType.TENANT.equals(entityId.getEntityType())) {
log.info("[{}] Updated rate limits: {}", entityId, updated);
} else {
log.debug("[{}] Updated rate limits: {}", entityId, updated);
}
logLimits(entityId, "Updated", updated);
putFunction.accept(entityId, updated);
}
}
}
private void logLimits(EntityId entityId, String action, EntityTransportRateLimits limits) {
// Tenant-level changes are logged at INFO; the much noisier per-device/gateway ones at DEBUG.
if (EntityType.TENANT.equals(entityId.getEntityType())) {
log.info("[{}] {} rate limits: {}", entityId, action, limits);
} else {
log.debug("[{}] {} rate limits: {}", entityId, action, limits);
}
}
private EntityTransportRateLimits merge(EntityTransportRateLimits oldRateLimits, EntityTransportRateLimits newRateLimits) {
boolean regularUpdate = !oldRateLimits.getRegularMsgRateLimit().getConfiguration().equals(newRateLimits.getRegularMsgRateLimit().getConfiguration());
boolean telemetryMsgRateUpdate = !oldRateLimits.getTelemetryMsgRateLimit().getConfiguration().equals(newRateLimits.getTelemetryMsgRateLimit().getConfiguration());
@ -269,36 +273,12 @@ public class DefaultTransportRateLimitService implements TransportRateLimitServi
DefaultTenantProfileConfiguration profile = (DefaultTenantProfileConfiguration) profileData.getConfiguration();
if (profile == null) {
return new EntityTransportRateLimits(ALLOW, ALLOW, ALLOW);
} else {
TransportRateLimit regularMsgRateLimit;
TransportRateLimit telemetryMsgRateLimit;
TransportRateLimit telemetryDpRateLimit;
switch (limitsType) {
case TENANT_LIMITS -> {
regularMsgRateLimit = newLimit(profile.getTransportTenantMsgRateLimit());
telemetryMsgRateLimit = newLimit(profile.getTransportTenantTelemetryMsgRateLimit());
telemetryDpRateLimit = newLimit(profile.getTransportTenantTelemetryDataPointsRateLimit());
}
case DEVICE_LIMITS -> {
regularMsgRateLimit = newLimit(profile.getTransportDeviceMsgRateLimit());
telemetryMsgRateLimit = newLimit(profile.getTransportDeviceTelemetryMsgRateLimit());
telemetryDpRateLimit = newLimit(profile.getTransportDeviceTelemetryDataPointsRateLimit());
}
case GATEWAY_LIMITS -> {
regularMsgRateLimit = newLimit(profile.getTransportGatewayMsgRateLimit());
telemetryMsgRateLimit = newLimit(profile.getTransportGatewayTelemetryMsgRateLimit());
telemetryDpRateLimit = newLimit(profile.getTransportGatewayTelemetryDataPointsRateLimit());
}
case GATEWAY_DEVICE_LIMITS -> {
regularMsgRateLimit = newLimit(profile.getTransportGatewayDeviceMsgRateLimit());
telemetryMsgRateLimit = newLimit(profile.getTransportGatewayDeviceTelemetryMsgRateLimit());
telemetryDpRateLimit = newLimit(profile.getTransportGatewayDeviceTelemetryDataPointsRateLimit());
}
default -> throw new IllegalStateException("Unknown limits type: " + limitsType);
}
return new EntityTransportRateLimits(regularMsgRateLimit, telemetryMsgRateLimit, telemetryDpRateLimit);
}
return new EntityTransportRateLimits(
newLimit(limitsType.getRegularMsgRateLimit().apply(profile)),
newLimit(limitsType.getTelemetryMsgRateLimit().apply(profile)),
newLimit(limitsType.getTelemetryDataPointsRateLimit().apply(profile))
);
}
private static TransportRateLimit newLimit(String config) {
@ -306,31 +286,36 @@ public class DefaultTransportRateLimitService implements TransportRateLimitServi
}
private EntityTransportRateLimits getTenantRateLimits(TenantId tenantId) {
return perTenantLimits.computeIfAbsent(tenantId, k -> createRateLimits(tenantProfileCache.get(tenantId), TENANT_LIMITS));
return getRateLimits(perTenantLimits, tenantId, tenantId, TENANT_LIMITS, null);
}
private EntityTransportRateLimits getDeviceRateLimits(TenantId tenantId, DeviceId deviceId) {
return perDeviceLimits.computeIfAbsent(deviceId, k -> {
EntityTransportRateLimits limits = createRateLimits(tenantProfileCache.get(tenantId), DEVICE_LIMITS);
getTenantDevices(tenantId).add(deviceId);
return limits;
});
return getRateLimits(perDeviceLimits, tenantId, deviceId, DEVICE_LIMITS, () -> getTenantDevices(tenantId).add(deviceId));
}
private EntityTransportRateLimits getGatewayRateLimits(TenantId tenantId, DeviceId gatewayId) {
return perGatewayLimits.computeIfAbsent(gatewayId, k -> {
EntityTransportRateLimits limits = createRateLimits(tenantProfileCache.get(tenantId), GATEWAY_LIMITS);
getTenantGateways(tenantId).add(gatewayId);
return limits;
});
return getRateLimits(perGatewayLimits, tenantId, gatewayId, GATEWAY_LIMITS, () -> getTenantGateways(tenantId).add(gatewayId));
}
private EntityTransportRateLimits getGatewayDeviceRateLimits(TenantId tenantId, DeviceId gatewayId) {
return perGatewayDeviceLimits.computeIfAbsent(gatewayId, k -> {
EntityTransportRateLimits limits = createRateLimits(tenantProfileCache.get(tenantId), GATEWAY_DEVICE_LIMITS);
getTenantGatewayDevices(tenantId).add(gatewayId);
return limits;
});
return getRateLimits(perGatewayDeviceLimits, tenantId, gatewayId, GATEWAY_DEVICE_LIMITS, () -> getTenantGatewayDevices(tenantId).add(gatewayId));
}
private <T extends EntityId> EntityTransportRateLimits getRateLimits(ConcurrentMap<T, EntityTransportRateLimits> limitsMap, TenantId tenantId,
T entityId, TransportLimitsType limitsType, Runnable onMiss) {
EntityTransportRateLimits limits = limitsMap.get(entityId);
if (limits == null) {
// Resolve the tenant profile WITHOUT holding the ConcurrentHashMap bin lock: the fetch may
// block on a cross-service round-trip, so it must run before computeIfAbsent's mapping function.
TenantProfile tenantProfile = tenantProfileCache.get(tenantId);
limits = limitsMap.computeIfAbsent(entityId, k -> createRateLimits(tenantProfile, limitsType));
// Runs on every observed miss, including callers that lost the computeIfAbsent race and got an
// existing value back - NOT only on actual creation, so the callback must be idempotent.
if (onMiss != null) {
onMiss.run();
}
}
return limits;
}
private Set<DeviceId> getTenantDevices(TenantId tenantId) {

35
common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/limits/TransportLimitsType.java

@ -15,6 +15,39 @@
*/
package org.thingsboard.server.common.transport.limits;
import lombok.Getter;
import lombok.RequiredArgsConstructor;
import org.thingsboard.server.common.data.tenant.profile.DefaultTenantProfileConfiguration;
import java.util.function.Function;
@Getter
@RequiredArgsConstructor
public enum TransportLimitsType {
TENANT_LIMITS, DEVICE_LIMITS, GATEWAY_LIMITS, GATEWAY_DEVICE_LIMITS
TENANT_LIMITS(
DefaultTenantProfileConfiguration::getTransportTenantMsgRateLimit,
DefaultTenantProfileConfiguration::getTransportTenantTelemetryMsgRateLimit,
DefaultTenantProfileConfiguration::getTransportTenantTelemetryDataPointsRateLimit
),
DEVICE_LIMITS(
DefaultTenantProfileConfiguration::getTransportDeviceMsgRateLimit,
DefaultTenantProfileConfiguration::getTransportDeviceTelemetryMsgRateLimit,
DefaultTenantProfileConfiguration::getTransportDeviceTelemetryDataPointsRateLimit
),
GATEWAY_LIMITS(
DefaultTenantProfileConfiguration::getTransportGatewayMsgRateLimit,
DefaultTenantProfileConfiguration::getTransportGatewayTelemetryMsgRateLimit,
DefaultTenantProfileConfiguration::getTransportGatewayTelemetryDataPointsRateLimit
),
GATEWAY_DEVICE_LIMITS(
DefaultTenantProfileConfiguration::getTransportGatewayDeviceMsgRateLimit,
DefaultTenantProfileConfiguration::getTransportGatewayDeviceTelemetryMsgRateLimit,
DefaultTenantProfileConfiguration::getTransportGatewayDeviceTelemetryDataPointsRateLimit
);
private final Function<DefaultTenantProfileConfiguration, String> regularMsgRateLimit;
private final Function<DefaultTenantProfileConfiguration, String> telemetryMsgRateLimit;
private final Function<DefaultTenantProfileConfiguration, String> telemetryDataPointsRateLimit;
}

4
common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/service/DefaultTransportService.java

@ -153,6 +153,8 @@ public class DefaultTransportService extends TransportActivityManager implements
private int notificationsPollDuration;
@Value("${transport.stats.enabled:false}")
private boolean statsEnabled;
@Value("${transport.callback_thread_pool_size:20}")
private int callbackThreadPoolSize;
@Autowired
@Lazy
@ -198,7 +200,7 @@ public class DefaultTransportService extends TransportActivityManager implements
this.ruleEngineProducerStats = statsFactory.createMessagesStats(StatsType.RULE_ENGINE.getName() + ".producer");
this.tbCoreProducerStats = statsFactory.createMessagesStats(StatsType.CORE.getName() + ".producer");
this.transportApiStats = statsFactory.createMessagesStats(StatsType.TRANSPORT.getName() + ".producer");
this.transportCallbackExecutor = ThingsBoardExecutors.newWorkStealingPool(20, getClass());
this.transportCallbackExecutor = ThingsBoardExecutors.newWorkStealingPool(callbackThreadPoolSize, getClass());
this.scheduler.scheduleAtFixedRate(this::invalidateRateLimits, new Random().nextInt((int) sessionReportTimeout), sessionReportTimeout, TimeUnit.MILLISECONDS);
transportApiRequestTemplate = queueProvider.createTransportApiRequestTemplate();
transportApiRequestTemplate.setMessagesStats(transportApiStats);

76
common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/service/DefaultTransportTenantProfileCache.java

@ -15,6 +15,7 @@
*/
package org.thingsboard.server.common.transport.service;
import com.google.common.util.concurrent.Striped;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Lazy;
@ -37,14 +38,20 @@ 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;
@Component
@TbTransportComponent
@Slf4j
public class DefaultTransportTenantProfileCache implements TransportTenantProfileCache {
private final Lock tenantProfileFetchLock = new ReentrantLock();
// Number of stripes for the per-tenant fetch locks. Only contended during concurrent cold-cache
// misses (cached tenants never take the lock), and concurrent fetches are already bounded by the
// transport callback pool, so this comfortably over-provisions the realistic concurrency.
private static final int TENANT_PROFILE_FETCH_LOCK_STRIPES = 1024;
// Bounded set of per-tenant locks: de-duplicates concurrent misses for the same tenant while
// letting different tenants fetch concurrently (eager array - no weak-ref overhead at this size).
private final Striped<Lock> tenantProfileFetchLocks = Striped.lock(TENANT_PROFILE_FETCH_LOCK_STRIPES);
private final ConcurrentMap<TenantProfileId, TenantProfile> profiles = new ConcurrentHashMap<>();
private final ConcurrentMap<TenantId, TenantProfileId> tenantIds = new ConcurrentHashMap<>();
private final ConcurrentMap<TenantProfileId, Set<TenantId>> tenantProfileIds = new ConcurrentHashMap<>();
@ -103,43 +110,52 @@ public class DefaultTransportTenantProfileCache implements TransportTenantProfil
}
private TenantProfile getTenantProfile(TenantId tenantId) {
TenantProfile profile = null;
TenantProfileId tenantProfileId = tenantIds.get(tenantId);
if (tenantProfileId != null) {
profile = profiles.get(tenantProfileId);
}
TenantProfile profile = lookupCached(tenantId);
if (profile == null) {
tenantProfileFetchLock.lock();
// Per-tenant lock: de-duplicates concurrent misses for the SAME tenant while allowing
// different tenants to resolve their profiles concurrently.
Lock lock = tenantProfileFetchLocks.get(tenantId);
lock.lock();
try {
tenantProfileId = tenantIds.get(tenantId);
if (tenantProfileId != null) {
profile = profiles.get(tenantProfileId);
}
profile = lookupCached(tenantId);
if (profile == null) {
TransportProtos.GetEntityProfileRequestMsg msg = TransportProtos.GetEntityProfileRequestMsg.newBuilder()
.setEntityType(EntityType.TENANT.name())
.setEntityIdMSB(tenantId.getId().getMostSignificantBits())
.setEntityIdLSB(tenantId.getId().getLeastSignificantBits())
.build();
TransportProtos.GetEntityProfileResponseMsg entityProfileMsg = transportService.getEntityProfile(msg);
profile = ProtoUtils.fromProto(entityProfileMsg.getTenantProfile());
TenantProfile existingProfile = profiles.get(profile.getId());
if (existingProfile != null) {
profile = existingProfile;
} else {
profiles.put(profile.getId(), profile);
}
tenantProfileIds.computeIfAbsent(profile.getId(), id -> ConcurrentHashMap.newKeySet()).add(tenantId);
tenantIds.put(tenantId, profile.getId());
ApiUsageState apiUsageState = ProtoUtils.fromProto(entityProfileMsg.getApiState());
rateLimitService.update(tenantId, apiUsageState.isTransportEnabled());
profile = fetchAndCacheTenantProfile(tenantId);
}
} finally {
tenantProfileFetchLock.unlock();
lock.unlock();
}
}
return profile;
}
private TenantProfile lookupCached(TenantId tenantId) {
TenantProfileId tenantProfileId = tenantIds.get(tenantId);
if (tenantProfileId != null) {
return profiles.get(tenantProfileId);
}
return null;
}
private TenantProfile fetchAndCacheTenantProfile(TenantId tenantId) {
TransportProtos.GetEntityProfileRequestMsg msg = TransportProtos.GetEntityProfileRequestMsg.newBuilder()
.setEntityType(EntityType.TENANT.name())
.setEntityIdMSB(tenantId.getId().getMostSignificantBits())
.setEntityIdLSB(tenantId.getId().getLeastSignificantBits())
.build();
TransportProtos.GetEntityProfileResponseMsg entityProfileMsg = transportService.getEntityProfile(msg);
TenantProfile profile = ProtoUtils.fromProto(entityProfileMsg.getTenantProfile());
TenantProfile existingProfile = profiles.get(profile.getId());
if (existingProfile != null) {
profile = existingProfile;
} else {
profiles.put(profile.getId(), profile);
}
tenantProfileIds.computeIfAbsent(profile.getId(), id -> ConcurrentHashMap.newKeySet()).add(tenantId);
tenantIds.put(tenantId, profile.getId());
ApiUsageState apiUsageState = ProtoUtils.fromProto(entityProfileMsg.getApiState());
rateLimitService.update(tenantId, apiUsageState.isTransportEnabled());
return profile;
}
}

202
common/transport/transport-api/src/test/java/org/thingsboard/server/common/transport/limits/DefaultTransportRateLimitServiceTest.java

@ -0,0 +1,202 @@
/**
* 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.common.transport.limits;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.EnumSource;
import org.thingsboard.server.common.data.TenantProfile;
import org.thingsboard.server.common.data.id.DeviceId;
import org.thingsboard.server.common.data.id.TenantId;
import org.thingsboard.server.common.data.id.TenantProfileId;
import org.thingsboard.server.common.data.tenant.profile.DefaultTenantProfileConfiguration;
import org.thingsboard.server.common.data.tenant.profile.TenantProfileData;
import org.thingsboard.server.common.transport.TransportTenantProfileCache;
import org.thingsboard.server.common.transport.profile.TenantProfileUpdateResult;
import java.util.Set;
import java.util.UUID;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
class DefaultTransportRateLimitServiceTest {
private TransportTenantProfileCache tenantProfileCache;
private ExecutorService executor;
private final TenantId tenant = TenantId.fromUUID(UUID.randomUUID());
@BeforeEach
void setUp() {
tenantProfileCache = mock(TransportTenantProfileCache.class);
executor = Executors.newCachedThreadPool();
}
@AfterEach
void tearDown() {
executor.shutdownNow();
}
@Test
void checkLimitsDoesNotHoldMapBinLockAcrossProfileFetch() throws Exception {
// Two concurrent rate-limit checks for the SAME tenant must both be able to reach
// the (blocking) tenant-profile fetch concurrently. If the blocking fetch runs inside
// ConcurrentHashMap.computeIfAbsent, the second caller is stuck on the bin reservation
// node and never reaches the fetch -> the latch never reaches zero.
CountDownLatch bothCallersReachedFetch = new CountDownLatch(2);
CountDownLatch releaseFetch = new CountDownLatch(1);
when(tenantProfileCache.get(tenant)).thenAnswer(invocation -> {
bothCallersReachedFetch.countDown();
releaseFetch.await(5, TimeUnit.SECONDS);
return tenantProfile();
});
DefaultTransportRateLimitService service = new DefaultTransportRateLimitService(tenantProfileCache);
Runnable check = () -> service.checkLimits(tenant, null, null, 1, false);
executor.submit(check);
executor.submit(check);
boolean bothReached = bothCallersReachedFetch.await(3, TimeUnit.SECONDS);
releaseFetch.countDown();
assertThat(bothReached)
.as("both checkLimits calls should reach the profile fetch concurrently (no bin lock across I/O)")
.isTrue();
}
@ParameterizedTest
@EnumSource(TransportLimitsType.class)
void eachLimitsTypeReadsItsOwnProfileFields(TransportLimitsType type) {
// Distinct sentinel per profile field so a transposed method reference (e.g. GATEWAY_DEVICE_LIMITS
// wired to the plain gateway getters) resolves to the wrong value and fails the assertion.
DefaultTenantProfileConfiguration config = new DefaultTenantProfileConfiguration();
config.setTransportTenantMsgRateLimit("tenant-msg");
config.setTransportTenantTelemetryMsgRateLimit("tenant-tele-msg");
config.setTransportTenantTelemetryDataPointsRateLimit("tenant-tele-dp");
config.setTransportDeviceMsgRateLimit("device-msg");
config.setTransportDeviceTelemetryMsgRateLimit("device-tele-msg");
config.setTransportDeviceTelemetryDataPointsRateLimit("device-tele-dp");
config.setTransportGatewayMsgRateLimit("gateway-msg");
config.setTransportGatewayTelemetryMsgRateLimit("gateway-tele-msg");
config.setTransportGatewayTelemetryDataPointsRateLimit("gateway-tele-dp");
config.setTransportGatewayDeviceMsgRateLimit("gateway-device-msg");
config.setTransportGatewayDeviceTelemetryMsgRateLimit("gateway-device-tele-msg");
config.setTransportGatewayDeviceTelemetryDataPointsRateLimit("gateway-device-tele-dp");
String prefix = switch (type) {
case TENANT_LIMITS -> "tenant";
case DEVICE_LIMITS -> "device";
case GATEWAY_LIMITS -> "gateway";
case GATEWAY_DEVICE_LIMITS -> "gateway-device";
};
assertThat(type.getRegularMsgRateLimit().apply(config)).isEqualTo(prefix + "-msg");
assertThat(type.getTelemetryMsgRateLimit().apply(config)).isEqualTo(prefix + "-tele-msg");
assertThat(type.getTelemetryDataPointsRateLimit().apply(config)).isEqualTo(prefix + "-tele-dp");
}
@ParameterizedTest
@EnumSource(EntityLevel.class)
void profileUpdateReachesEntityTrackedDuringFirstCheck(EntityLevel level) {
DeviceId entity = new DeviceId(UUID.randomUUID());
when(tenantProfileCache.get(tenant)).thenReturn(profileWithRegularMsgLimit(level, "100:600"));
DefaultTransportRateLimitService service = new DefaultTransportRateLimitService(tenantProfileCache);
// First check resolves the (permissive) limit and must register the entity into the per-tenant
// tracking set via the onMiss callback - otherwise a later update(tenantId) can't reach it.
assertThat(level.check(service, tenant, entity))
.as("permissive limit should allow the first %s check", level).isNull();
// Tighten the limit to a single message and push a profile update for this tenant.
service.update(new TenantProfileUpdateResult(profileWithRegularMsgLimit(level, "1:600"), Set.of(tenant)));
// The freshly merged "1:600" bucket allows exactly one message...
assertThat(level.check(service, tenant, entity)).isNull();
// ...and blocks the next one. This only happens if update(tenantId) reached the tracked entity.
assertThat(level.check(service, tenant, entity))
.as("update(tenantId) must reach the tracked %s so the tightened limit applies", level).isNotNull();
}
private TenantProfile tenantProfile() {
return profileWith(new DefaultTenantProfileConfiguration());
}
private TenantProfile profileWithRegularMsgLimit(EntityLevel level, String regularMsgRateLimit) {
DefaultTenantProfileConfiguration config = new DefaultTenantProfileConfiguration();
level.setRegularMsgRateLimit(config, regularMsgRateLimit);
return profileWith(config);
}
private TenantProfile profileWith(DefaultTenantProfileConfiguration config) {
TenantProfile profile = new TenantProfile(new TenantProfileId(UUID.randomUUID()));
profile.setName("test-profile");
TenantProfileData profileData = new TenantProfileData();
profileData.setConfiguration(config);
profile.setProfileData(profileData);
return profile;
}
private enum EntityLevel {
DEVICE {
@Override
void setRegularMsgRateLimit(DefaultTenantProfileConfiguration config, String value) {
config.setTransportDeviceMsgRateLimit(value);
}
@Override
Object check(DefaultTransportRateLimitService service, TenantId tenantId, DeviceId entityId) {
return service.checkLimits(tenantId, null, entityId, 0, false);
}
},
GATEWAY {
@Override
void setRegularMsgRateLimit(DefaultTenantProfileConfiguration config, String value) {
config.setTransportGatewayMsgRateLimit(value);
}
@Override
Object check(DefaultTransportRateLimitService service, TenantId tenantId, DeviceId entityId) {
return service.checkLimits(tenantId, entityId, null, 0, false);
}
},
GATEWAY_DEVICE {
@Override
void setRegularMsgRateLimit(DefaultTenantProfileConfiguration config, String value) {
config.setTransportGatewayDeviceMsgRateLimit(value);
}
@Override
Object check(DefaultTransportRateLimitService service, TenantId tenantId, DeviceId entityId) {
return service.checkLimits(tenantId, null, entityId, 0, true);
}
};
abstract void setRegularMsgRateLimit(DefaultTenantProfileConfiguration config, String value);
abstract Object check(DefaultTransportRateLimitService service, TenantId tenantId, DeviceId entityId);
}
}

191
common/transport/transport-api/src/test/java/org/thingsboard/server/common/transport/service/DefaultTransportTenantProfileCacheTest.java

@ -0,0 +1,191 @@
/**
* 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.common.transport.service;
import com.google.common.util.concurrent.Striped;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.thingsboard.server.common.data.ApiUsageState;
import org.thingsboard.server.common.data.ApiUsageStateValue;
import org.thingsboard.server.common.data.EntityType;
import org.thingsboard.server.common.data.TenantProfile;
import org.thingsboard.server.common.data.id.ApiUsageStateId;
import org.thingsboard.server.common.data.id.TenantId;
import org.thingsboard.server.common.data.id.TenantProfileId;
import org.thingsboard.server.common.transport.TransportService;
import org.thingsboard.server.common.transport.limits.TransportRateLimitService;
import org.thingsboard.server.common.util.ProtoUtils;
import org.thingsboard.server.gen.transport.TransportProtos.GetEntityProfileRequestMsg;
import org.thingsboard.server.gen.transport.TransportProtos.GetEntityProfileResponseMsg;
import java.util.ArrayList;
import java.util.List;
import java.util.UUID;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.locks.Lock;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyBoolean;
import static org.mockito.Mockito.doNothing;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
class DefaultTransportTenantProfileCacheTest {
private DefaultTransportTenantProfileCache cache;
private TransportService transportService;
private TransportRateLimitService rateLimitService;
private ExecutorService executor;
// Must match DefaultTransportTenantProfileCache.TENANT_PROFILE_FETCH_LOCK_STRIPES.
private static final int STRIPE_COUNT = 1024;
private final TenantId tenantA = TenantId.fromUUID(UUID.randomUUID());
// Deterministically pick a tenant that maps to a DIFFERENT stripe than tenantA, so the cross-tenant
// test below cannot flake on the ~1/1024 chance two random UUIDs hash to the same stripe.
private final TenantId tenantB = differentStripeFrom(tenantA);
private static TenantId differentStripeFrom(TenantId other) {
Striped<Lock> probe = Striped.lock(STRIPE_COUNT);
TenantId candidate = TenantId.fromUUID(UUID.randomUUID());
while (probe.get(candidate) == probe.get(other)) {
candidate = TenantId.fromUUID(UUID.randomUUID());
}
return candidate;
}
@BeforeEach
void setUp() {
cache = new DefaultTransportTenantProfileCache();
transportService = mock(TransportService.class);
rateLimitService = mock(TransportRateLimitService.class);
doNothing().when(rateLimitService).update(any(TenantId.class), anyBoolean());
cache.setTransportService(transportService);
cache.setRateLimitService(rateLimitService);
executor = Executors.newCachedThreadPool();
}
@AfterEach
void tearDown() {
executor.shutdownNow();
}
@Test
void fetchForOneTenantDoesNotBlockResolutionOfAnotherTenant() throws Exception {
CountDownLatch tenantAFetchStarted = new CountDownLatch(1);
CountDownLatch releaseTenantA = new CountDownLatch(1);
GetEntityProfileResponseMsg responseA = responseFor(tenantA);
GetEntityProfileResponseMsg responseB = responseFor(tenantB);
when(transportService.getEntityProfile(any())).thenAnswer(invocation -> {
GetEntityProfileRequestMsg msg = invocation.getArgument(0);
TenantId requested = TenantId.fromUUID(new UUID(msg.getEntityIdMSB(), msg.getEntityIdLSB()));
if (requested.equals(tenantA)) {
tenantAFetchStarted.countDown();
releaseTenantA.await(5, TimeUnit.SECONDS);
return responseA;
}
return responseB;
});
// T1 starts fetching tenantA's profile and blocks inside the cross-service round-trip.
Future<TenantProfile> tenantAResult = executor.submit(() -> cache.get(tenantA));
assertThat(tenantAFetchStarted.await(5, TimeUnit.SECONDS))
.as("tenantA fetch should have started").isTrue();
// T2 resolves a different tenant - it must NOT wait for tenantA's in-flight fetch.
// Fails today (single global lock); passes once locking is per-tenant.
TenantProfile tenantBProfile = CompletableFuture
.supplyAsync(() -> cache.get(tenantB), executor)
.get(2, TimeUnit.SECONDS);
assertThat(tenantBProfile).isNotNull();
releaseTenantA.countDown();
assertThat(tenantAResult.get(5, TimeUnit.SECONDS)).isNotNull();
}
@Test
void concurrentMissesForSameTenantDedupeToSingleFetch() throws Exception {
// The per-tenant lock exists precisely so that concurrent cold misses for the SAME tenant collapse
// into a single cross-service fetch (the rest are served from cache). Assert that contract directly.
int callers = 8;
CountDownLatch fetchStarted = new CountDownLatch(1);
CountDownLatch releaseFetch = new CountDownLatch(1);
when(transportService.getEntityProfile(any())).thenAnswer(invocation -> {
fetchStarted.countDown();
// Hold the (single) in-flight fetch open while the other callers pile up on the per-tenant lock.
releaseFetch.await(5, TimeUnit.SECONDS);
return responseFor(tenantA);
});
CountDownLatch allSubmitted = new CountDownLatch(callers);
List<Future<TenantProfile>> results = new ArrayList<>();
for (int i = 0; i < callers; i++) {
results.add(executor.submit(() -> {
allSubmitted.countDown();
return cache.get(tenantA);
}));
}
assertThat(allSubmitted.await(5, TimeUnit.SECONDS)).as("all callers should start").isTrue();
assertThat(fetchStarted.await(5, TimeUnit.SECONDS)).as("the first fetch should start").isTrue();
releaseFetch.countDown();
for (Future<TenantProfile> result : results) {
assertThat(result.get(5, TimeUnit.SECONDS)).isNotNull();
}
// All 8 callers resolved the same tenant, but only one of them hit the backend.
verify(transportService, times(1)).getEntityProfile(any());
}
private GetEntityProfileResponseMsg responseFor(TenantId tenantId) {
TenantProfile profile = new TenantProfile(new TenantProfileId(UUID.randomUUID()));
profile.setName("profile-" + tenantId.getId());
return GetEntityProfileResponseMsg.newBuilder()
.setEntityType(EntityType.TENANT.name())
.setTenantProfile(ProtoUtils.toProto(profile))
.setApiState(ProtoUtils.toProto(enabledApiUsageState(tenantId)))
.build();
}
private ApiUsageState enabledApiUsageState(TenantId tenantId) {
ApiUsageState state = new ApiUsageState(new ApiUsageStateId(UUID.randomUUID()));
state.setTenantId(tenantId);
state.setEntityId(tenantId);
state.setTransportState(ApiUsageStateValue.ENABLED);
state.setDbStorageState(ApiUsageStateValue.ENABLED);
state.setReExecState(ApiUsageStateValue.ENABLED);
state.setJsExecState(ApiUsageStateValue.ENABLED);
state.setTbelExecState(ApiUsageStateValue.ENABLED);
state.setEmailExecState(ApiUsageStateValue.ENABLED);
state.setSmsExecState(ApiUsageStateValue.ENABLED);
state.setAlarmExecState(ApiUsageStateValue.ENABLED);
state.setVersion(1L);
return state;
}
}

2
transport/coap/src/main/resources/tb-coap-transport.yml

@ -133,6 +133,8 @@ redis:
blockWhenExhausted: "${REDIS_POOL_CONFIG_BLOCK_WHEN_EXHAUSTED:true}"
transport:
# Size of the thread pool that executes transport API callbacks (session registration, telemetry/attribute and RPC responses, entity update notifications, and the tenant profile fetch on a cache miss). Bounds how many such callbacks - including those that block on a backend round-trip - can run concurrently.
callback_thread_pool_size: "${TB_TRANSPORT_CALLBACK_THREAD_POOL_SIZE:20}"
# Local CoAP transport parameters
coap:
# CoaP processing timeout in milliseconds

2
transport/http/src/main/resources/tb-http-transport.yml

@ -167,6 +167,8 @@ redis:
# HTTP server parameters
transport:
# Size of the thread pool that executes transport API callbacks (session registration, telemetry/attribute and RPC responses, entity update notifications, and the tenant profile fetch on a cache miss). Bounds how many such callbacks - including those that block on a backend round-trip - can run concurrently.
callback_thread_pool_size: "${TB_TRANSPORT_CALLBACK_THREAD_POOL_SIZE:20}"
http:
# HTTP request processing timeout in milliseconds
request_timeout: "${HTTP_REQUEST_TIMEOUT:60000}"

2
transport/lwm2m/src/main/resources/tb-lwm2m-transport.yml

@ -134,6 +134,8 @@ redis:
# LWM2M server parameters
transport:
# Size of the thread pool that executes transport API callbacks (session registration, telemetry/attribute and RPC responses, entity update notifications, and the tenant profile fetch on a cache miss). Bounds how many such callbacks - including those that block on a backend round-trip - can run concurrently.
callback_thread_pool_size: "${TB_TRANSPORT_CALLBACK_THREAD_POOL_SIZE:20}"
sessions:
# Session inactivity timeout is a global configuration parameter that defines how long the device transport session will be opened after the last message arrives from the device.
# The parameter value is in milliseconds.

2
transport/mqtt/src/main/resources/tb-mqtt-transport.yml

@ -135,6 +135,8 @@ redis:
# MQTT server parameters
transport:
# Size of the thread pool that executes transport API callbacks (session registration, telemetry/attribute and RPC responses, entity update notifications, and the tenant profile fetch on a cache miss). Bounds how many such callbacks - including those that block on a backend round-trip - can run concurrently.
callback_thread_pool_size: "${TB_TRANSPORT_CALLBACK_THREAD_POOL_SIZE:20}"
mqtt:
# MQTT bind-address
bind_address: "${MQTT_BIND_ADDRESS:0.0.0.0}"

2
transport/snmp/src/main/resources/tb-snmp-transport.yml

@ -134,6 +134,8 @@ redis:
# Snmp server parameters
transport:
# Size of the thread pool that executes transport API callbacks (session registration, telemetry/attribute and RPC responses, entity update notifications, and the tenant profile fetch on a cache miss). Bounds how many such callbacks - including those that block on a backend round-trip - can run concurrently.
callback_thread_pool_size: "${TB_TRANSPORT_CALLBACK_THREAD_POOL_SIZE:20}"
snmp:
# Enable/disable SNMP transport protocol
enabled: "${SNMP_ENABLED:true}"

Loading…
Cancel
Save