fix: gate mqtt retries until startup cleanup

This commit is contained in:
yuhaiming
2026-07-17 17:11:52 +08:00
parent 3728f9a009
commit a1b12f4241
5 changed files with 147 additions and 19 deletions

View File

@@ -23,13 +23,16 @@ import org.springframework.context.support.GenericApplicationContext;
import org.springframework.test.util.ReflectionTestUtils;
import java.time.Duration;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.TimeUnit;
import java.util.function.Supplier;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.*;
@@ -99,35 +102,48 @@ class MqttCommandAckServiceTest {
}
@Test
void clearPendingCommandsDeletesPendingObjectsAndClearsIds() {
void clearPendingCommandsRemovesSnapshotBeforeDeletingPendingObjects() {
MqttProperties properties = new MqttProperties();
AppDeviceMapper appDeviceMapper = mock(AppDeviceMapper.class);
RedissonClient redissonClient = mock(RedissonClient.class);
RSet<String> pendingIds = mock(RSet.class);
Set<String> snapshot = new LinkedHashSet<>(List.of("cmd-1", "cmd-2"));
AtomicBoolean snapshotRemoved = new AtomicBoolean();
when(redissonClient.<String>getSet("mqtt:command:pending:ids")).thenReturn(pendingIds);
when(pendingIds.readAll()).thenReturn(Set.of("cmd-1", "cmd-2"));
when(pendingIds.readAll()).thenReturn(snapshot);
doAnswer(invocation -> {
snapshotRemoved.set(true);
return true;
}).when(pendingIds).removeAll(snapshot);
MqttCommandAckService service = new MqttCommandAckService(properties, appDeviceMapper);
try (MockedStatic<RedisUtils> redis = mockStatic(RedisUtils.class)) {
redis.when(RedisUtils::getClient).thenReturn(redissonClient);
redis.when(() -> RedisUtils.deleteObject(any(String.class))).thenAnswer(invocation -> {
assertThat(snapshotRemoved).isTrue();
return true;
});
int cleared = service.clearPendingCommands();
assertThat(cleared).isEqualTo(2);
assertThat(service.isStartupCleanupReady()).isTrue();
redis.verify(() -> RedisUtils.deleteObject("mqtt:command:pending:cmd-1"));
redis.verify(() -> RedisUtils.deleteObject("mqtt:command:pending:cmd-2"));
verify(pendingIds).clear();
verify(pendingIds).removeAll(snapshot);
verify(pendingIds, never()).clear();
}
}
@Test
void clearPendingCommandsReturnsZeroForEmptySet() {
void clearPendingCommandsOpensRetryGateForEmptySet() {
MqttProperties properties = new MqttProperties();
AppDeviceMapper appDeviceMapper = mock(AppDeviceMapper.class);
RedissonClient redissonClient = mock(RedissonClient.class);
RSet<String> pendingIds = mock(RSet.class);
Set<String> snapshot = Set.of();
when(redissonClient.<String>getSet("mqtt:command:pending:ids")).thenReturn(pendingIds);
when(pendingIds.readAll()).thenReturn(Set.of());
when(pendingIds.readAll()).thenReturn(snapshot);
MqttCommandAckService service = new MqttCommandAckService(properties, appDeviceMapper);
try (MockedStatic<RedisUtils> redis = mockStatic(RedisUtils.class)) {
@@ -136,7 +152,64 @@ class MqttCommandAckServiceTest {
int cleared = service.clearPendingCommands();
assertThat(cleared).isZero();
verify(pendingIds).clear();
assertThat(service.isStartupCleanupReady()).isTrue();
verify(pendingIds).removeAll(snapshot);
verify(pendingIds, never()).clear();
}
}
@Test
void clearPendingCommandsKeepsRetryGateClosedWhenSnapshotRemovalFails() {
MqttProperties properties = new MqttProperties();
AppDeviceMapper appDeviceMapper = mock(AppDeviceMapper.class);
RedissonClient redissonClient = mock(RedissonClient.class);
RSet<String> pendingIds = mock(RSet.class);
Set<String> snapshot = Set.of("cmd-1");
when(redissonClient.<String>getSet("mqtt:command:pending:ids")).thenReturn(pendingIds);
when(pendingIds.readAll()).thenReturn(snapshot);
doThrow(new IllegalStateException("redis unavailable")).when(pendingIds).removeAll(snapshot);
MqttCommandAckService service = new MqttCommandAckService(properties, appDeviceMapper);
try (MockedStatic<RedisUtils> redis = mockStatic(RedisUtils.class)) {
redis.when(RedisUtils::getClient).thenReturn(redissonClient);
assertThat(service.isStartupCleanupReady()).isFalse();
assertThatThrownBy(service::clearPendingCommands)
.isInstanceOf(IllegalStateException.class)
.hasMessage("redis unavailable");
assertThat(service.isStartupCleanupReady()).isFalse();
redis.verify(() -> RedisUtils.deleteObject(any(String.class)), never());
verify(pendingIds, never()).clear();
}
}
@Test
void clearPendingCommandsContinuesDeletingObjectsAndReportsFailures() {
MqttProperties properties = new MqttProperties();
AppDeviceMapper appDeviceMapper = mock(AppDeviceMapper.class);
RedissonClient redissonClient = mock(RedissonClient.class);
RSet<String> pendingIds = mock(RSet.class);
Set<String> snapshot = new LinkedHashSet<>(List.of("cmd-1", "cmd-2"));
when(redissonClient.<String>getSet("mqtt:command:pending:ids")).thenReturn(pendingIds);
when(pendingIds.readAll()).thenReturn(snapshot);
MqttCommandAckService service = new MqttCommandAckService(properties, appDeviceMapper);
try (MockedStatic<RedisUtils> redis = mockStatic(RedisUtils.class)) {
redis.when(RedisUtils::getClient).thenReturn(redissonClient);
redis.when(() -> RedisUtils.deleteObject("mqtt:command:pending:cmd-1"))
.thenThrow(new IllegalStateException("delete failed"));
assertThatThrownBy(service::clearPendingCommands)
.isInstanceOf(RuntimeException.class)
.hasMessageContaining("cmd-1")
.hasCauseInstanceOf(IllegalStateException.class);
verify(pendingIds).removeAll(snapshot);
verify(pendingIds, never()).clear();
redis.verify(() -> RedisUtils.deleteObject("mqtt:command:pending:cmd-1"));
redis.verify(() -> RedisUtils.deleteObject("mqtt:command:pending:cmd-2"));
assertThat(service.isStartupCleanupReady()).isTrue();
}
}

View File

@@ -0,0 +1,36 @@
package org.dromara.mqtt;
import org.dromara.mqtt.config.properties.MqttProperties;
import org.junit.jupiter.api.Tag;
import org.junit.jupiter.api.Test;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@Tag("dev")
class MqttCommandRetryTaskTest {
@Test
void retryExpiredCommandsSkipsRetryBeforeStartupCleanupIsReady() {
MqttCommandAckService ackService = mock(MqttCommandAckService.class);
when(ackService.isStartupCleanupReady()).thenReturn(false);
MqttCommandRetryTask task = new MqttCommandRetryTask(ackService, new MqttProperties());
task.retryExpiredCommands();
verify(ackService, never()).retryExpiredCommands();
}
@Test
void retryExpiredCommandsRetriesAfterStartupCleanupIsReady() {
MqttCommandAckService ackService = mock(MqttCommandAckService.class);
when(ackService.isStartupCleanupReady()).thenReturn(true);
MqttCommandRetryTask task = new MqttCommandRetryTask(ackService, new MqttProperties());
task.retryExpiredCommands();
verify(ackService).retryExpiredCommands();
}
}