From 7e3f2d72bf8566ffc5b9ca47d45fd37b264d6494 Mon Sep 17 00:00:00 2001 From: Oleksandra Matviienko Date: Wed, 10 Jun 2026 12:40:57 +0200 Subject: [PATCH] Propagate inactivity timeout on scheduled executor and enforce whole-second profile timeout - Run profile inactivity timeout propagation on the single-threaded scheduled executor instead of the notifications consumer thread, so it stays off the consumer thread and serializes with the periodic state checker; a supersede guard drops stale tasks - Reject device profile inactivity timeout values that are not a whole number of seconds - Remove @Lazy on the device profile cache field - Set inactivityTimeoutMs directly in the device profile form instead of behind a redundant profileData guard --- .../state/DefaultDeviceStateService.java | 30 ++++++++++++------- .../state/DefaultDeviceStateServiceTest.java | 8 +++++ .../validator/DeviceProfileDataValidator.java | 3 ++ .../DeviceProfileDataValidatorTest.java | 10 ++++++- .../profile/device-profile.component.ts | 6 ++-- 5 files changed, 41 insertions(+), 16 deletions(-) diff --git a/application/src/main/java/org/thingsboard/server/service/state/DefaultDeviceStateService.java b/application/src/main/java/org/thingsboard/server/service/state/DefaultDeviceStateService.java index e50e5c7e17..6f65607957 100644 --- a/application/src/main/java/org/thingsboard/server/service/state/DefaultDeviceStateService.java +++ b/application/src/main/java/org/thingsboard/server/service/state/DefaultDeviceStateService.java @@ -175,7 +175,6 @@ public class DefaultDeviceStateService extends AbstractPartitionBasedService { - if (!profileId.equals(stateData.getDeviceProfileId())) { - return; - } - if (stateData.isInactivityTimeoutOverridden()) { - return; - } - if (stateData.getState().getInactivityTimeout() == resolvedNew) { + // Run the per-device propagation on the single-threaded scheduledExecutor instead of the notifications + // consumer thread: it keeps the consumer responsive, serializes with checkStates() so the iteration never + // races the periodic checker over the same DeviceState, and preserves event order so devices converge on + // the latest resolved timeout. The guard below drops a task whose value was already superseded. + scheduledExecutor.submit(() -> { + if (!Objects.equals(profileResolvedInactivityTimeoutMs.get(profileId), resolvedNew)) { return; } - stateData.getState().setInactivityTimeout(resolvedNew); - checkAndUpdateState(deviceId, stateData); + deviceStates.forEach((deviceId, stateData) -> { + if (!profileId.equals(stateData.getDeviceProfileId())) { + return; + } + if (stateData.isInactivityTimeoutOverridden()) { + return; + } + if (stateData.getState().getInactivityTimeout() == resolvedNew) { + return; + } + stateData.getState().setInactivityTimeout(resolvedNew); + checkAndUpdateState(deviceId, stateData); + }); }); } diff --git a/application/src/test/java/org/thingsboard/server/service/state/DefaultDeviceStateServiceTest.java b/application/src/test/java/org/thingsboard/server/service/state/DefaultDeviceStateServiceTest.java index cb787ab2d1..e326eb603e 100644 --- a/application/src/test/java/org/thingsboard/server/service/state/DefaultDeviceStateServiceTest.java +++ b/application/src/test/java/org/thingsboard/server/service/state/DefaultDeviceStateServiceTest.java @@ -17,6 +17,7 @@ package org.thingsboard.server.service.state; import com.google.common.util.concurrent.Futures; import com.google.common.util.concurrent.ListeningExecutorService; +import com.google.common.util.concurrent.ListeningScheduledExecutorService; import com.google.common.util.concurrent.MoreExecutors; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; @@ -159,6 +160,13 @@ class DefaultDeviceStateServiceTest { deviceStateCallbackExecutor = MoreExecutors.newDirectExecutorService(); ReflectionTestUtils.setField(service, "deviceStateCallbackExecutor", deviceStateCallbackExecutor); + ListeningScheduledExecutorService scheduledExecutor = mock(ListeningScheduledExecutorService.class); + lenient().doAnswer(inv -> { + ((Runnable) inv.getArgument(0)).run(); + return Futures.immediateVoidFuture(); + }).when(scheduledExecutor).submit(any(Runnable.class)); + ReflectionTestUtils.setField(service, "scheduledExecutor", scheduledExecutor); + lenient().when(partitionService.resolve(ServiceType.TB_CORE, tenantId, deviceId)).thenReturn(tpi); ConcurrentMap> partitionedEntities = new ConcurrentHashMap<>(); diff --git a/dao/src/main/java/org/thingsboard/server/dao/service/validator/DeviceProfileDataValidator.java b/dao/src/main/java/org/thingsboard/server/dao/service/validator/DeviceProfileDataValidator.java index 0916053041..906b3bb5ca 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/service/validator/DeviceProfileDataValidator.java +++ b/dao/src/main/java/org/thingsboard/server/dao/service/validator/DeviceProfileDataValidator.java @@ -210,6 +210,9 @@ public class DeviceProfileDataValidator extends AbstractHasOtaPackageValidator validator.validateDataImpl(tenantId, deviceProfile)) + .hasMessageContaining("Device profile inactivity timeout must be specified in whole seconds"); + } + @Test void testValidate_InactivityTimeoutMs_Zero_Error() { DeviceProfile deviceProfile = baseDefaultProfile(); diff --git a/ui-ngx/src/app/modules/home/components/profile/device-profile.component.ts b/ui-ngx/src/app/modules/home/components/profile/device-profile.component.ts index 64f3ac9b30..09a9904385 100644 --- a/ui-ngx/src/app/modules/home/components/profile/device-profile.component.ts +++ b/ui-ngx/src/app/modules/home/components/profile/device-profile.component.ts @@ -232,10 +232,8 @@ export class DeviceProfileComponent extends EntityComponent { if (formValue.defaultEdgeRuleChainId) { formValue.defaultEdgeRuleChainId = new RuleChainId(formValue.defaultEdgeRuleChainId); } - if (formValue.profileData) { - const sec = formValue.inactivityTimeoutSec; - formValue.profileData.inactivityTimeoutMs = sec && sec > 0 ? sec * SECOND : null; - } + const sec = formValue.inactivityTimeoutSec; + formValue.profileData.inactivityTimeoutMs = sec && sec > 0 ? sec * SECOND : null; delete formValue.inactivityTimeoutSec; const deviceProvisionConfiguration: DeviceProvisionConfiguration = formValue.profileData.provisionConfiguration; formValue.provisionType = deviceProvisionConfiguration.type;