3 changed files with 308 additions and 6 deletions
@ -0,0 +1,251 @@ |
|||||
|
/** |
||||
|
* 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.queue.common.consumer; |
||||
|
|
||||
|
import lombok.extern.slf4j.Slf4j; |
||||
|
import org.junit.jupiter.api.AfterEach; |
||||
|
import org.junit.jupiter.api.Test; |
||||
|
import org.thingsboard.server.common.msg.queue.TopicPartitionInfo; |
||||
|
import org.thingsboard.server.queue.TbQueueConsumer; |
||||
|
import org.thingsboard.server.queue.TbQueueMsg; |
||||
|
|
||||
|
import java.util.Collections; |
||||
|
import java.util.List; |
||||
|
import java.util.Queue; |
||||
|
import java.util.Set; |
||||
|
import java.util.concurrent.ConcurrentLinkedQueue; |
||||
|
import java.util.concurrent.CopyOnWriteArrayList; |
||||
|
import java.util.concurrent.ExecutorService; |
||||
|
import java.util.concurrent.Executors; |
||||
|
import java.util.concurrent.TimeUnit; |
||||
|
import java.util.concurrent.atomic.AtomicBoolean; |
||||
|
import java.util.concurrent.atomic.AtomicInteger; |
||||
|
import java.util.function.BooleanSupplier; |
||||
|
|
||||
|
import static org.assertj.core.api.Assertions.assertThat; |
||||
|
import static org.awaitility.Awaitility.await; |
||||
|
import static org.mockito.Mockito.mock; |
||||
|
|
||||
|
@Slf4j |
||||
|
class QueueConsumerManagerTest { |
||||
|
|
||||
|
private static final long POLL_INTERVAL_MS = 20L; |
||||
|
// Before asserting the consumer never polled we wait until the loop has evaluated the readiness gate at least
|
||||
|
// this many times. That proves the consumer thread is actually running and deciding not to poll, rather than the
|
||||
|
// assertion passing vacuously because the thread simply has not started yet.
|
||||
|
private static final int MIN_READINESS_CHECKS = 3; |
||||
|
|
||||
|
private final AtomicBoolean readyToProcess = new AtomicBoolean(false); |
||||
|
private final AtomicInteger readinessChecks = new AtomicInteger(); |
||||
|
private final TestQueueConsumer consumer = new TestQueueConsumer(); |
||||
|
private ExecutorService consumerExecutor; |
||||
|
private QueueConsumerManager<TbQueueMsg> manager; |
||||
|
|
||||
|
@AfterEach |
||||
|
void tearDown() { |
||||
|
if (manager != null) { |
||||
|
manager.stop(); |
||||
|
} |
||||
|
if (consumerExecutor != null) { |
||||
|
consumerExecutor.shutdownNow(); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
@Test |
||||
|
void eventQueuedWhileNotReadyIsDeliveredAfterReadinessGateOpensInsteadOfBeingDropped() { |
||||
|
List<TbQueueMsg> delivered = new CopyOnWriteArrayList<>(); |
||||
|
|
||||
|
consumer.enqueue(List.of(mock(TbQueueMsg.class))); |
||||
|
|
||||
|
// The processor is unconditional: only the readiness gate may hold the event back, so delivery proves the
|
||||
|
// gate (not the processor) is what kept the event queued while not ready.
|
||||
|
manager = launchManager(consumer, countingReadiness(readyToProcess, readinessChecks), (msgs, c) -> { |
||||
|
delivered.addAll(msgs); |
||||
|
c.commit(); |
||||
|
}); |
||||
|
|
||||
|
// The loop is running and repeatedly evaluating the gate during the not-ready (sync) window...
|
||||
|
awaitReadinessGateEvaluated(readinessChecks); |
||||
|
// ...yet the queued event is neither polled nor delivered - it stays in the queue rather than being dropped.
|
||||
|
assertThat(consumer.getPollCount()) |
||||
|
.as("consumer must not poll while not ready") |
||||
|
.isZero(); |
||||
|
assertThat(delivered) |
||||
|
.as("event must not be delivered while not ready") |
||||
|
.isEmpty(); |
||||
|
|
||||
|
// Sync completes - the processor becomes ready.
|
||||
|
readyToProcess.set(true); |
||||
|
|
||||
|
await().atMost(5, TimeUnit.SECONDS) |
||||
|
.untilAsserted(() -> assertThat(delivered) |
||||
|
.as("event queued during the not-ready window must be delivered, not dropped") |
||||
|
.hasSize(1)); |
||||
|
} |
||||
|
|
||||
|
@Test |
||||
|
void consumerIsNotPolledWhileNotReadyToProcess() { |
||||
|
manager = launchManager(consumer, countingReadiness(readyToProcess, readinessChecks), (msgs, c) -> c.commit()); |
||||
|
|
||||
|
awaitReadinessGateEvaluated(readinessChecks); |
||||
|
assertThat(consumer.getPollCount()) |
||||
|
.as("consumer must not be polled while not ready to process") |
||||
|
.isZero(); |
||||
|
|
||||
|
readyToProcess.set(true); |
||||
|
await().atMost(5, TimeUnit.SECONDS) |
||||
|
.untilAsserted(() -> assertThat(consumer.getPollCount()) |
||||
|
.as("consumer resumes polling once ready") |
||||
|
.isPositive()); |
||||
|
} |
||||
|
|
||||
|
@Test |
||||
|
void consumerWithoutReadinessCheckPollsAndDeliversImmediately() { |
||||
|
List<TbQueueMsg> delivered = new CopyOnWriteArrayList<>(); |
||||
|
|
||||
|
consumer.enqueue(List.of(mock(TbQueueMsg.class))); |
||||
|
|
||||
|
// No readiness gate configured - the consumer must default to "always ready", preserving the behaviour every
|
||||
|
// consumer that does not opt in relies on.
|
||||
|
manager = launchManager(consumer, null, (msgs, c) -> { |
||||
|
delivered.addAll(msgs); |
||||
|
c.commit(); |
||||
|
}); |
||||
|
|
||||
|
await().atMost(5, TimeUnit.SECONDS) |
||||
|
.untilAsserted(() -> assertThat(delivered) |
||||
|
.as("consumer without a readiness gate must poll and deliver immediately") |
||||
|
.hasSize(1)); |
||||
|
} |
||||
|
|
||||
|
@Test |
||||
|
void consumerLoopExitsWhenInterruptedWhileNotReady() throws Exception { |
||||
|
manager = launchManager(consumer, countingReadiness(readyToProcess, readinessChecks), (msgs, c) -> c.commit()); |
||||
|
|
||||
|
// The loop is parked in the not-ready wait...
|
||||
|
awaitReadinessGateEvaluated(readinessChecks); |
||||
|
|
||||
|
// ...interrupting the worker (as shutdownNow does on stop) must end the loop, not spin or hang.
|
||||
|
consumerExecutor.shutdownNow(); |
||||
|
assertThat(consumerExecutor.awaitTermination(5, TimeUnit.SECONDS)) |
||||
|
.as("consumer loop must exit when interrupted while waiting to become ready") |
||||
|
.isTrue(); |
||||
|
} |
||||
|
|
||||
|
private static void awaitReadinessGateEvaluated(AtomicInteger readinessChecks) { |
||||
|
await().atMost(5, TimeUnit.SECONDS) |
||||
|
.untilAsserted(() -> assertThat(readinessChecks.get()) |
||||
|
.as("consumer loop must be running and repeatedly evaluating the readiness gate") |
||||
|
.isGreaterThanOrEqualTo(MIN_READINESS_CHECKS)); |
||||
|
} |
||||
|
|
||||
|
private static BooleanSupplier countingReadiness(AtomicBoolean ready, AtomicInteger readinessChecks) { |
||||
|
return () -> { |
||||
|
readinessChecks.incrementAndGet(); |
||||
|
return ready.get(); |
||||
|
}; |
||||
|
} |
||||
|
|
||||
|
private QueueConsumerManager<TbQueueMsg> launchManager(TestQueueConsumer consumer, BooleanSupplier readinessCheck, |
||||
|
QueueConsumerManager.MsgPackProcessor<TbQueueMsg> processor) { |
||||
|
consumerExecutor = Executors.newSingleThreadExecutor(); |
||||
|
QueueConsumerManager<TbQueueMsg> queueConsumerManager = QueueConsumerManager.<TbQueueMsg>builder() |
||||
|
.name("test-consumer") |
||||
|
.pollInterval(POLL_INTERVAL_MS) |
||||
|
.consumerCreator(() -> consumer) |
||||
|
.consumerExecutor(consumerExecutor) |
||||
|
.readinessCheck(readinessCheck) |
||||
|
.msgPackProcessor(processor) |
||||
|
.build(); |
||||
|
queueConsumerManager.subscribe(); |
||||
|
queueConsumerManager.launch(); |
||||
|
return queueConsumerManager; |
||||
|
} |
||||
|
|
||||
|
private static class TestQueueConsumer implements TbQueueConsumer<TbQueueMsg> { |
||||
|
|
||||
|
private final Queue<List<TbQueueMsg>> batches = new ConcurrentLinkedQueue<>(); |
||||
|
private final AtomicInteger pollCount = new AtomicInteger(); |
||||
|
private volatile boolean stopped; |
||||
|
|
||||
|
void enqueue(List<TbQueueMsg> batch) { |
||||
|
batches.add(batch); |
||||
|
} |
||||
|
|
||||
|
int getPollCount() { |
||||
|
return pollCount.get(); |
||||
|
} |
||||
|
|
||||
|
@Override |
||||
|
public List<TbQueueMsg> poll(long durationInMillis) { |
||||
|
pollCount.incrementAndGet(); |
||||
|
List<TbQueueMsg> batch = batches.poll(); |
||||
|
if (batch != null) { |
||||
|
return batch; |
||||
|
} |
||||
|
try { |
||||
|
Thread.sleep(durationInMillis); |
||||
|
} catch (InterruptedException e) { |
||||
|
Thread.currentThread().interrupt(); |
||||
|
} |
||||
|
return Collections.emptyList(); |
||||
|
} |
||||
|
|
||||
|
@Override |
||||
|
public String getTopic() { |
||||
|
return "test-topic"; |
||||
|
} |
||||
|
|
||||
|
@Override |
||||
|
public void subscribe() { |
||||
|
} |
||||
|
|
||||
|
@Override |
||||
|
public void subscribe(Set<TopicPartitionInfo> partitions) { |
||||
|
} |
||||
|
|
||||
|
@Override |
||||
|
public void stop() { |
||||
|
stopped = true; |
||||
|
} |
||||
|
|
||||
|
@Override |
||||
|
public void unsubscribe() { |
||||
|
stopped = true; |
||||
|
} |
||||
|
|
||||
|
@Override |
||||
|
public void commit() { |
||||
|
} |
||||
|
|
||||
|
@Override |
||||
|
public boolean isStopped() { |
||||
|
return stopped; |
||||
|
} |
||||
|
|
||||
|
@Override |
||||
|
public Set<TopicPartitionInfo> getPartitions() { |
||||
|
return Collections.emptySet(); |
||||
|
} |
||||
|
|
||||
|
@Override |
||||
|
public List<String> getFullTopicNames() { |
||||
|
return Collections.emptyList(); |
||||
|
} |
||||
|
|
||||
|
} |
||||
|
|
||||
|
} |
||||
Loading…
Reference in new issue