This is an automated email from the ASF dual-hosted git repository.
lizhimins pushed a commit to branch rocketmq-studio
in repository https://gitbox.apache.org/repos/asf/rocketmq-dashboard.git
The following commit(s) were added to refs/heads/rocketmq-studio by this push:
new 5f2566531 fix(instance): bound resource count job lifetime (#3044)
5f2566531 is described below
commit 5f256653114c56fca489e851f751becd3b3b2308
Author: xdz997 <[email protected]>
AuthorDate: Fri Sep 4 15:46:33 2026 +0800
fix(instance): bound resource count job lifetime (#3044)
---
.../instance/InstanceResourceCountRunner.java | 251 +++++++++++++++++++++
.../rocketmq/studio/instance/InstanceService.java | 121 +++++-----
.../instance/InstanceResourceCountRunnerTest.java | 251 +++++++++++++++++++++
.../studio/instance/InstanceServiceTest.java | 77 +++++++
4 files changed, 645 insertions(+), 55 deletions(-)
diff --git
a/server/src/main/java/org/apache/rocketmq/studio/instance/InstanceResourceCountRunner.java
b/server/src/main/java/org/apache/rocketmq/studio/instance/InstanceResourceCountRunner.java
new file mode 100644
index 000000000..319b01e58
--- /dev/null
+++
b/server/src/main/java/org/apache/rocketmq/studio/instance/InstanceResourceCountRunner.java
@@ -0,0 +1,251 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You 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.apache.rocketmq.studio.instance;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Objects;
+import java.util.concurrent.ArrayBlockingQueue;
+import java.util.concurrent.CancellationException;
+import java.util.concurrent.ExecutionException;
+import java.util.concurrent.Future;
+import java.util.concurrent.RejectedExecutionException;
+import java.util.concurrent.ThreadFactory;
+import java.util.concurrent.ThreadPoolExecutor;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.TimeoutException;
+import java.util.concurrent.atomic.AtomicInteger;
+
+/**
+ * Runs instance resource-count lookups without exposing response objects to
worker threads.
+ *
+ * <p>The executor and its queue are both bounded because vendor SDK calls may
ignore thread
+ * interruption while an HTTP request is in flight. Each batch has one
absolute deadline. A
+ * result is usable only when the worker completed it before that deadline;
late results remain
+ * detached and therefore cannot mutate a response that has already been
returned.</p>
+ */
+final class InstanceResourceCountRunner implements AutoCloseable {
+
+ private static final AtomicInteger THREAD_SEQUENCE = new AtomicInteger();
+
+ private final ThreadPoolExecutor executor;
+ private final long timeoutNanos;
+
+ InstanceResourceCountRunner(int parallelism, int queueCapacity, long
timeout, TimeUnit timeoutUnit) {
+ if (parallelism <= 0) {
+ throw new IllegalArgumentException("parallelism must be positive");
+ }
+ if (queueCapacity <= 0) {
+ throw new IllegalArgumentException("queueCapacity must be
positive");
+ }
+ if (timeout <= 0) {
+ throw new IllegalArgumentException("timeout must be positive");
+ }
+ Objects.requireNonNull(timeoutUnit, "timeoutUnit");
+
+ this.timeoutNanos = timeoutUnit.toNanos(timeout);
+ ThreadFactory threadFactory = runnable -> {
+ Thread thread = new Thread(runnable,
+ "instance-resource-counts-" +
THREAD_SEQUENCE.incrementAndGet());
+ thread.setDaemon(true);
+ return thread;
+ };
+ this.executor = new ThreadPoolExecutor(
+ parallelism,
+ parallelism,
+ 0L,
+ TimeUnit.MILLISECONDS,
+ new ArrayBlockingQueue<>(queueCapacity),
+ threadFactory,
+ new ThreadPoolExecutor.AbortPolicy());
+ }
+
+ @FunctionalInterface
+ interface CountFunction {
+ ResourceCounts count(InstanceVO instance) throws Exception;
+ }
+
+ record ResourceCounts(int topicCount, int consumerGroupCount) {
+ }
+
+ enum OutcomeStatus {
+ SUCCESS,
+ FAILED,
+ TIMED_OUT,
+ REJECTED,
+ INTERRUPTED
+ }
+
+ record CountOutcome(OutcomeStatus status, ResourceCounts counts, Throwable
failure) {
+
+ static CountOutcome success(ResourceCounts counts) {
+ return new CountOutcome(OutcomeStatus.SUCCESS, counts, null);
+ }
+
+ static CountOutcome failed(Throwable failure) {
+ return new CountOutcome(OutcomeStatus.FAILED, null, failure);
+ }
+
+ static CountOutcome unavailable(OutcomeStatus status) {
+ return new CountOutcome(status, null, null);
+ }
+
+ boolean available() {
+ return status == OutcomeStatus.SUCCESS;
+ }
+ }
+
+ List<CountOutcome> countAll(List<InstanceVO> instances, CountFunction
function) {
+ Objects.requireNonNull(instances, "instances");
+ Objects.requireNonNull(function, "function");
+ if (instances.isEmpty()) {
+ return List.of();
+ }
+
+ long startedNanos = System.nanoTime();
+ List<SubmittedCount> submitted = submitAll(instances, function);
+ List<CountOutcome> outcomes = new ArrayList<>(submitted.size());
+ boolean interrupted = false;
+
+ for (SubmittedCount count : submitted) {
+ if (count.rejected()) {
+ outcomes.add(CountOutcome.unavailable(OutcomeStatus.REJECTED));
+ continue;
+ }
+ if (interrupted) {
+ count.future().cancel(true);
+
outcomes.add(CountOutcome.unavailable(OutcomeStatus.INTERRUPTED));
+ continue;
+ }
+
+ try {
+ CompletedCount completed = await(count.future(), startedNanos);
+ if (elapsedNanos(startedNanos, completed.completedNanos()) >
timeoutNanos) {
+
outcomes.add(CountOutcome.unavailable(OutcomeStatus.TIMED_OUT));
+ } else if (completed.failure() != null) {
+ outcomes.add(CountOutcome.failed(completed.failure()));
+ } else if (completed.counts() == null) {
+ outcomes.add(CountOutcome.failed(
+ new IllegalStateException("resource count provider
returned null")));
+ } else {
+ outcomes.add(CountOutcome.success(completed.counts()));
+ }
+ } catch (TimeoutException exception) {
+ count.future().cancel(true);
+
outcomes.add(CountOutcome.unavailable(OutcomeStatus.TIMED_OUT));
+ } catch (InterruptedException exception) {
+ count.future().cancel(true);
+ interrupted = true;
+
outcomes.add(CountOutcome.unavailable(OutcomeStatus.INTERRUPTED));
+ } catch (CancellationException exception) {
+
outcomes.add(CountOutcome.unavailable(OutcomeStatus.INTERRUPTED));
+ } catch (ExecutionException exception) {
+ outcomes.add(CountOutcome.failed(rootCause(exception)));
+ }
+ }
+
+ cancelOutstanding(submitted);
+ if (interrupted) {
+ Thread.currentThread().interrupt();
+ }
+ // Future.cancel does not remove queued FutureTasks from a
ThreadPoolExecutor queue.
+ // Purging prevents timed-out requests from leaving a cancelled
backlog behind.
+ executor.purge();
+ return List.copyOf(outcomes);
+ }
+
+ private List<SubmittedCount> submitAll(List<InstanceVO> instances,
CountFunction function) {
+ List<SubmittedCount> submitted = new ArrayList<>(instances.size());
+ for (InstanceVO instance : instances) {
+ try {
+ Future<CompletedCount> future = executor.submit(() ->
execute(instance, function));
+ submitted.add(SubmittedCount.accepted(future));
+ } catch (RejectedExecutionException exception) {
+ submitted.add(SubmittedCount.rejectedSubmission());
+ }
+ }
+ return submitted;
+ }
+
+ private static CompletedCount execute(InstanceVO instance, CountFunction
function) {
+ try {
+ return new CompletedCount(function.count(instance), null,
System.nanoTime());
+ } catch (Exception exception) {
+ return new CompletedCount(null, exception, System.nanoTime());
+ }
+ }
+
+ private CompletedCount await(Future<CompletedCount> future, long
startedNanos)
+ throws InterruptedException, ExecutionException, TimeoutException {
+ if (future.isDone()) {
+ return future.get();
+ }
+ long remainingNanos = timeoutNanos - elapsedNanos(startedNanos,
System.nanoTime());
+ if (remainingNanos <= 0L) {
+ throw new TimeoutException("resource count batch deadline
reached");
+ }
+ return future.get(remainingNanos, TimeUnit.NANOSECONDS);
+ }
+
+ private static void cancelOutstanding(List<SubmittedCount> submitted) {
+ for (SubmittedCount count : submitted) {
+ if (!count.rejected() && !count.future().isDone()) {
+ count.future().cancel(true);
+ }
+ }
+ }
+
+ int activeCount() {
+ return executor.getActiveCount();
+ }
+
+ int queuedCount() {
+ return executor.getQueue().size();
+ }
+
+ @Override
+ public void close() {
+ executor.shutdownNow();
+ executor.purge();
+ }
+
+ private static long elapsedNanos(long startedNanos, long completedNanos) {
+ return completedNanos - startedNanos;
+ }
+
+ private static Throwable rootCause(Throwable error) {
+ Throwable current = error;
+ while (current.getCause() != null && current.getCause() != current) {
+ current = current.getCause();
+ }
+ return current;
+ }
+
+ private record CompletedCount(ResourceCounts counts, Throwable failure,
long completedNanos) {
+ }
+
+ private record SubmittedCount(Future<CompletedCount> future, boolean
rejected) {
+
+ static SubmittedCount accepted(Future<CompletedCount> future) {
+ return new SubmittedCount(future, false);
+ }
+
+ static SubmittedCount rejectedSubmission() {
+ return new SubmittedCount(null, true);
+ }
+ }
+}
diff --git
a/server/src/main/java/org/apache/rocketmq/studio/instance/InstanceService.java
b/server/src/main/java/org/apache/rocketmq/studio/instance/InstanceService.java
index e1a2faec1..f8de5b996 100644
---
a/server/src/main/java/org/apache/rocketmq/studio/instance/InstanceService.java
+++
b/server/src/main/java/org/apache/rocketmq/studio/instance/InstanceService.java
@@ -52,11 +52,6 @@ import java.util.List;
import java.util.Locale;
import java.util.Objects;
import java.util.Set;
-import java.util.concurrent.Callable;
-import java.util.concurrent.ExecutionException;
-import java.util.concurrent.ExecutorService;
-import java.util.concurrent.Executors;
-import java.util.concurrent.Future;
import java.util.concurrent.TimeUnit;
@Slf4j
@@ -74,20 +69,18 @@ public class InstanceService {
private final RegionNames regionNames;
static final int COUNT_PARALLELISM = 8;
+ static final int COUNT_QUEUE_CAPACITY = 128;
static final long COUNT_TIMEOUT_SECONDS = 3;
private static final int MAX_BATCH_FAILURE_MESSAGE_LENGTH = 500;
static final int MAX_CLOUD_IMPORT_FAILURE_DETAILS = 100;
static final int MAX_CLOUD_IMPORT_FAILURE_MESSAGE_LENGTH = 500;
- private final ExecutorService countExecutor =
Executors.newFixedThreadPool(COUNT_PARALLELISM, runnable -> {
- Thread thread = new Thread(runnable, "instance-resource-counts");
- thread.setDaemon(true);
- return thread;
- });
+ private final InstanceResourceCountRunner countRunner = new
InstanceResourceCountRunner(
+ COUNT_PARALLELISM, COUNT_QUEUE_CAPACITY, COUNT_TIMEOUT_SECONDS,
TimeUnit.SECONDS);
@PreDestroy
- void shutdownCountExecutor() {
- countExecutor.shutdownNow();
+ void shutdownCountRunner() {
+ countRunner.close();
}
public List<InstanceVO> listInstances(InstanceType type, String search) {
@@ -125,61 +118,79 @@ public class InstanceService {
if (instances.isEmpty()) {
return;
}
- List<Callable<Void>> tasks = instances.stream()
- .<Callable<Void>>map(instance -> () -> {
- fillCounts(instance);
- return null;
- })
- .toList();
- List<Future<Void>> futures;
- try {
- // A single deadline for the whole batch. Waiting per future would
let one hung
- // vendor add COUNT_TIMEOUT_SECONDS to the response for every
instance.
- futures = countExecutor.invokeAll(tasks, COUNT_TIMEOUT_SECONDS,
TimeUnit.SECONDS);
- } catch (InterruptedException ex) {
- Thread.currentThread().interrupt();
- instances.forEach(instance ->
instance.setResourceCountsAvailable(false));
- return;
- }
- for (int i = 0; i < futures.size(); i++) {
- InstanceVO instance = instances.get(i);
- Future<Void> future = futures.get(i);
- if (future.isCancelled()) {
- // Missed the shared deadline; invokeAll already interrupted
the task.
- instance.setResourceCountsAvailable(false);
- log.warn("Resource counts timed out after {}s for instance {}",
- COUNT_TIMEOUT_SECONDS, instance.getId());
- } else {
- try {
- future.get();
- } catch (InterruptedException ex) {
- Thread.currentThread().interrupt();
- instance.setResourceCountsAvailable(false);
- } catch (ExecutionException ex) {
- instance.setResourceCountsAvailable(false);
+ List<InstanceResourceCountRunner.CountOutcome> outcomes =
+ countRunner.countAll(instances, this::loadCounts);
+ int rejected = 0;
+ int timedOut = 0;
+ int failed = 0;
+ for (int index = 0; index < instances.size(); index++) {
+ InstanceVO instance = instances.get(index);
+ InstanceResourceCountRunner.CountOutcome outcome =
outcomes.get(index);
+ if (outcome.available()) {
+ applyCounts(instance, outcome.counts());
+ continue;
+ }
+
+ clearCounts(instance);
+ switch (outcome.status()) {
+ case REJECTED -> rejected++;
+ case TIMED_OUT -> timedOut++;
+ case FAILED -> {
+ failed++;
log.warn("Failed to load resource counts for instance {}:
{}",
- instance.getId(), ex.getMessage());
+ instance.getId(), rootMessage(outcome.failure()));
}
+ case INTERRUPTED -> log.debug(
+ "Resource count lookup interrupted for instance {}",
instance.getId());
+ case SUCCESS -> throw new IllegalStateException("available
count outcome has no values");
}
}
+ if (timedOut > 0) {
+ log.warn("Resource count deadline reached after {}s for {}
instance(s)",
+ COUNT_TIMEOUT_SECONDS, timedOut);
+ }
+ if (rejected > 0) {
+ log.warn("Resource count executor saturated; {} instance(s) marked
unavailable", rejected);
+ }
+ if (failed > 0) {
+ log.debug("Resource count lookup failed for {} instance(s)",
failed);
+ }
}
/**
* Resource counts live on the vendor side (cloud APIs) or in the local
tables (Apache),
* so resolve them uniformly through the vendor provider.
*/
- private void fillCounts(InstanceVO instance) {
+ private InstanceResourceCountRunner.ResourceCounts loadCounts(InstanceVO
instance) {
InstanceVendor vendor = instance.getVendor() == null ?
InstanceVendor.APACHE : instance.getVendor();
- try {
- InstanceProvider provider = providerRegistry.forVendor(vendor);
-
instance.setTopicCount(provider.countTopics(String.valueOf(instance.getId())));
-
instance.setConsumerGroupCount(provider.countGroups(String.valueOf(instance.getId())));
- instance.setResourceCountsAvailable(true);
- } catch (RuntimeException ex) {
- instance.setResourceCountsAvailable(false);
- log.warn("Failed to load resource counts for instance {}: {}",
- instance.getId(), ex.getMessage());
+ InstanceProvider provider = providerRegistry.forVendor(vendor);
+ int topicCount =
provider.countTopics(String.valueOf(instance.getId()));
+ int consumerGroupCount =
provider.countGroups(String.valueOf(instance.getId()));
+ return new InstanceResourceCountRunner.ResourceCounts(topicCount,
consumerGroupCount);
+ }
+
+ private static void applyCounts(InstanceVO instance,
InstanceResourceCountRunner.ResourceCounts counts) {
+ instance.setTopicCount(counts.topicCount());
+ instance.setConsumerGroupCount(counts.consumerGroupCount());
+ instance.setResourceCountsAvailable(true);
+ }
+
+ private static void clearCounts(InstanceVO instance) {
+ instance.setTopicCount(0);
+ instance.setConsumerGroupCount(0);
+ instance.setResourceCountsAvailable(false);
+ }
+
+ private static String rootMessage(Throwable failure) {
+ if (failure == null) {
+ return "unknown failure";
+ }
+ Throwable current = failure;
+ while (current.getCause() != null && current.getCause() != current) {
+ current = current.getCause();
}
+ String message = current.getMessage();
+ return StringUtils.hasText(message) ? message :
current.getClass().getSimpleName();
}
public InstanceVO createInstance(InstanceVO instance) {
diff --git
a/server/src/test/java/org/apache/rocketmq/studio/instance/InstanceResourceCountRunnerTest.java
b/server/src/test/java/org/apache/rocketmq/studio/instance/InstanceResourceCountRunnerTest.java
new file mode 100644
index 000000000..4fab0ebc5
--- /dev/null
+++
b/server/src/test/java/org/apache/rocketmq/studio/instance/InstanceResourceCountRunnerTest.java
@@ -0,0 +1,251 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You 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.apache.rocketmq.studio.instance;
+
+import org.junit.jupiter.api.Test;
+
+import java.util.List;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicBoolean;
+import java.util.concurrent.atomic.AtomicReference;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+class InstanceResourceCountRunnerTest {
+
+ @Test
+ void
countAllShouldRunIndependentLookupsConcurrentlyAndKeepInputOrderTest() throws
Exception {
+ InstanceVO first = instance(1L);
+ InstanceVO second = instance(2L);
+ CountDownLatch bothStarted = new CountDownLatch(2);
+ CountDownLatch releaseFirst = new CountDownLatch(1);
+
+ try (InstanceResourceCountRunner runner =
+ new InstanceResourceCountRunner(2, 2, 2,
TimeUnit.SECONDS)) {
+ CompletableFuture<List<InstanceResourceCountRunner.CountOutcome>>
call =
+ CompletableFuture.supplyAsync(() ->
runner.countAll(List.of(first, second), instance -> {
+ bothStarted.countDown();
+ if (instance.getId().equals(1L)) {
+ releaseFirst.await();
+ }
+ int id = instance.getId().intValue();
+ return new
InstanceResourceCountRunner.ResourceCounts(id * 10, id * 100);
+ }));
+
+ assertThat(bothStarted.await(1, TimeUnit.SECONDS)).isTrue();
+ releaseFirst.countDown();
+
+ List<InstanceResourceCountRunner.CountOutcome> outcomes =
call.get(1, TimeUnit.SECONDS);
+
assertThat(outcomes).extracting(InstanceResourceCountRunner.CountOutcome::status)
+ .containsExactly(
+ InstanceResourceCountRunner.OutcomeStatus.SUCCESS,
+ InstanceResourceCountRunner.OutcomeStatus.SUCCESS);
+ assertThat(outcomes).extracting(outcome ->
outcome.counts().topicCount())
+ .containsExactly(10, 20);
+ } finally {
+ releaseFirst.countDown();
+ }
+ }
+
+ @Test
+ void countAllShouldDiscardAResultThatFinishesAfterTheBatchDeadlineTest()
throws Exception {
+ CountDownLatch started = new CountDownLatch(1);
+ CountDownLatch release = new CountDownLatch(1);
+ CountDownLatch finished = new CountDownLatch(1);
+
+ try (InstanceResourceCountRunner runner =
+ new InstanceResourceCountRunner(1, 1, 100,
TimeUnit.MILLISECONDS)) {
+ long startedAt = System.nanoTime();
+ List<InstanceResourceCountRunner.CountOutcome> outcomes =
runner.countAll(
+ List.of(instance(1L)), ignored -> {
+ started.countDown();
+ awaitIgnoringInterrupt(release);
+ finished.countDown();
+ return new
InstanceResourceCountRunner.ResourceCounts(7, 5);
+ });
+ long elapsedMillis =
TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - startedAt);
+
+ assertThat(started.await(1, TimeUnit.SECONDS)).isTrue();
+ assertThat(elapsedMillis).isLessThan(1_000);
+ assertThat(outcomes).singleElement().satisfies(outcome -> {
+
assertThat(outcome.status()).isEqualTo(InstanceResourceCountRunner.OutcomeStatus.TIMED_OUT);
+ assertThat(outcome.counts()).isNull();
+ });
+
+ release.countDown();
+ assertThat(finished.await(1, TimeUnit.SECONDS)).isTrue();
+ assertThat(outcomes).singleElement().satisfies(outcome ->
+ assertThat(outcome.status()).isEqualTo(
+
InstanceResourceCountRunner.OutcomeStatus.TIMED_OUT));
+ } finally {
+ release.countDown();
+ }
+ }
+
+ @Test
+ void countAllShouldRejectRowsBeyondTheBoundedQueueTest() throws Exception {
+ List<InstanceVO> instances = List.of(instance(1L), instance(2L),
instance(3L));
+ CountDownLatch firstStarted = new CountDownLatch(1);
+ CountDownLatch release = new CountDownLatch(1);
+
+ try (InstanceResourceCountRunner runner =
+ new InstanceResourceCountRunner(1, 1, 2,
TimeUnit.SECONDS)) {
+ CompletableFuture<List<InstanceResourceCountRunner.CountOutcome>>
call =
+ CompletableFuture.supplyAsync(() ->
runner.countAll(instances, instance -> {
+ firstStarted.countDown();
+ release.await();
+ int id = instance.getId().intValue();
+ return new
InstanceResourceCountRunner.ResourceCounts(id, id);
+ }));
+
+ assertThat(firstStarted.await(1, TimeUnit.SECONDS)).isTrue();
+ assertThat(waitForQueueSize(runner, 1, 1,
TimeUnit.SECONDS)).isTrue();
+ assertThat(runner.queuedCount()).isLessThanOrEqualTo(1);
+ release.countDown();
+
+ List<InstanceResourceCountRunner.CountOutcome> outcomes =
call.get(1, TimeUnit.SECONDS);
+
assertThat(outcomes).extracting(InstanceResourceCountRunner.CountOutcome::status)
+ .containsExactly(
+ InstanceResourceCountRunner.OutcomeStatus.SUCCESS,
+ InstanceResourceCountRunner.OutcomeStatus.SUCCESS,
+
InstanceResourceCountRunner.OutcomeStatus.REJECTED);
+ } finally {
+ release.countDown();
+ }
+ }
+
+ @Test
+ void countAllShouldUseOneDeadlineForTheWholeBatchTest() {
+ CountDownLatch release = new CountDownLatch(1);
+
+ try (InstanceResourceCountRunner runner =
+ new InstanceResourceCountRunner(2, 2, 150,
TimeUnit.MILLISECONDS)) {
+ long startedAt = System.nanoTime();
+ List<InstanceResourceCountRunner.CountOutcome> outcomes =
runner.countAll(
+ List.of(instance(1L), instance(2L)), ignored -> {
+ release.await();
+ return new
InstanceResourceCountRunner.ResourceCounts(1, 1);
+ });
+ long elapsedMillis =
TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - startedAt);
+
+ assertThat(elapsedMillis).isLessThan(1_000);
+
assertThat(outcomes).extracting(InstanceResourceCountRunner.CountOutcome::status)
+
.containsOnly(InstanceResourceCountRunner.OutcomeStatus.TIMED_OUT);
+ } finally {
+ release.countDown();
+ }
+ }
+
+ @Test
+ void countAllShouldKeepFailuresAndNullResultsLocalToTheirRowsTest() {
+ try (InstanceResourceCountRunner runner =
+ new InstanceResourceCountRunner(3, 3, 1,
TimeUnit.SECONDS)) {
+ List<InstanceResourceCountRunner.CountOutcome> outcomes =
runner.countAll(
+ List.of(instance(1L), instance(2L), instance(3L)),
instance -> {
+ if (instance.getId().equals(1L)) {
+ throw new IllegalStateException("provider
unavailable");
+ }
+ if (instance.getId().equals(2L)) {
+ return null;
+ }
+ return new
InstanceResourceCountRunner.ResourceCounts(3, 4);
+ });
+
+
assertThat(outcomes).extracting(InstanceResourceCountRunner.CountOutcome::status)
+ .containsExactly(
+ InstanceResourceCountRunner.OutcomeStatus.FAILED,
+ InstanceResourceCountRunner.OutcomeStatus.FAILED,
+ InstanceResourceCountRunner.OutcomeStatus.SUCCESS);
+ assertThat(outcomes.get(0).failure()).hasMessage("provider
unavailable");
+ assertThat(outcomes.get(1).failure()).hasMessage("resource count
provider returned null");
+ assertThat(outcomes.get(2).counts())
+ .isEqualTo(new
InstanceResourceCountRunner.ResourceCounts(3, 4));
+ }
+ }
+
+ @Test
+ void countAllShouldCancelOutstandingWorkAndRestoreCallerInterruptTest()
throws Exception {
+ CountDownLatch workerStarted = new CountDownLatch(1);
+ CountDownLatch workerInterrupted = new CountDownLatch(1);
+ AtomicReference<List<InstanceResourceCountRunner.CountOutcome>>
outcomes = new AtomicReference<>();
+ AtomicBoolean interruptRestored = new AtomicBoolean();
+
+ try (InstanceResourceCountRunner runner =
+ new InstanceResourceCountRunner(1, 2, 10,
TimeUnit.SECONDS)) {
+ Thread caller = new Thread(() -> {
+ outcomes.set(runner.countAll(List.of(instance(1L),
instance(2L)), ignored -> {
+ workerStarted.countDown();
+ try {
+ Thread.sleep(10_000);
+ } catch (InterruptedException exception) {
+ workerInterrupted.countDown();
+ throw exception;
+ }
+ return new InstanceResourceCountRunner.ResourceCounts(1,
1);
+ }));
+ interruptRestored.set(Thread.currentThread().isInterrupted());
+ });
+ caller.start();
+
+ assertThat(workerStarted.await(1, TimeUnit.SECONDS)).isTrue();
+ caller.interrupt();
+ caller.join(1_000);
+
+ assertThat(caller.isAlive()).isFalse();
+ assertThat(workerInterrupted.await(1, TimeUnit.SECONDS)).isTrue();
+ assertThat(interruptRestored).isTrue();
+
assertThat(outcomes.get()).extracting(InstanceResourceCountRunner.CountOutcome::status)
+
.containsOnly(InstanceResourceCountRunner.OutcomeStatus.INTERRUPTED);
+ assertThat(runner.queuedCount()).isZero();
+ }
+ }
+
+ private static InstanceVO instance(long id) {
+ InstanceVO instance = InstanceVO.builder().name("instance-" +
id).build();
+ instance.setId(id);
+ return instance;
+ }
+
+ private static void awaitIgnoringInterrupt(CountDownLatch latch) {
+ boolean interrupted = false;
+ while (true) {
+ try {
+ latch.await();
+ break;
+ } catch (InterruptedException exception) {
+ interrupted = true;
+ }
+ }
+ if (interrupted) {
+ Thread.currentThread().interrupt();
+ }
+ }
+
+ private static boolean waitForQueueSize(InstanceResourceCountRunner
runner, int expected,
+ long timeout, TimeUnit unit)
throws InterruptedException {
+ long deadline = System.nanoTime() + unit.toNanos(timeout);
+ while (System.nanoTime() < deadline) {
+ if (runner.queuedCount() == expected) {
+ return true;
+ }
+ Thread.sleep(5);
+ }
+ return runner.queuedCount() == expected;
+ }
+}
diff --git
a/server/src/test/java/org/apache/rocketmq/studio/instance/InstanceServiceTest.java
b/server/src/test/java/org/apache/rocketmq/studio/instance/InstanceServiceTest.java
index 9367ab4c9..6ef5a5277 100644
---
a/server/src/test/java/org/apache/rocketmq/studio/instance/InstanceServiceTest.java
+++
b/server/src/test/java/org/apache/rocketmq/studio/instance/InstanceServiceTest.java
@@ -48,6 +48,8 @@ import java.util.Arrays;
import java.util.List;
import java.util.Locale;
import java.util.Optional;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.TimeUnit;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
@@ -297,6 +299,25 @@ class InstanceServiceTest {
assertThat(result.get(0).getConsumerGroupCount()).isZero();
}
+ @Test
+ void listInstancesShouldClearPreviouslyLoadedCountsWhenRefreshFailsTest() {
+ InstanceVO instance =
InstanceVO.builder().name("stale-counts").build();
+ instance.setId(6L);
+ instance.setTopicCount(12);
+ instance.setConsumerGroupCount(8);
+ instance.setResourceCountsAvailable(true);
+ when(instanceRepository.findAll()).thenReturn(List.of(instance));
+
when(providerRegistry.forVendor(InstanceVendor.APACHE)).thenReturn(instanceProvider);
+ when(instanceProvider.countTopics("6"))
+ .thenThrow(new IllegalStateException("provider unavailable"));
+
+ InstanceVO result = instanceService.listInstances(null, null).get(0);
+
+ assertThat(result.isResourceCountsAvailable()).isFalse();
+ assertThat(result.getTopicCount()).isZero();
+ assertThat(result.getConsumerGroupCount()).isZero();
+ }
+
@Test
void listInstancesShouldApplyASingleDeadlineToSlowCountsTest() throws
InterruptedException {
// Three hung providers: waiting per future would cost 3s each (9s
total); a shared
@@ -324,6 +345,62 @@ class InstanceServiceTest {
assertThat(result).allSatisfy(vo ->
assertThat(vo.isResourceCountsAvailable()).isFalse());
}
+ @Test
+ void
timedOutCountTaskShouldNotMutateReturnedInstanceAfterRequestCompletesTest()
throws Exception {
+ InstanceVO slow = InstanceVO.builder().name("slow").build();
+ slow.setId(24L);
+ when(instanceRepository.findAll()).thenReturn(List.of(slow));
+
when(providerRegistry.forVendor(InstanceVendor.APACHE)).thenReturn(instanceProvider);
+
+ CountDownLatch topicCountStarted = new CountDownLatch(1);
+ CountDownLatch releaseProvider = new CountDownLatch(1);
+ CountDownLatch providerFinished = new CountDownLatch(1);
+ when(instanceProvider.countTopics("24")).thenAnswer(invocation -> {
+ topicCountStarted.countDown();
+ boolean interrupted = false;
+ while (true) {
+ try {
+ releaseProvider.await();
+ break;
+ } catch (InterruptedException exception) {
+ // Simulate a vendor SDK that does not abort an in-flight
HTTP request
+ // when Future.cancel(true) interrupts the worker.
+ interrupted = true;
+ }
+ }
+ if (interrupted) {
+ Thread.currentThread().interrupt();
+ }
+ return 7;
+ });
+ when(instanceProvider.countGroups("24")).thenAnswer(invocation -> {
+ providerFinished.countDown();
+ return 5;
+ });
+
+ try {
+ List<InstanceVO> result = instanceService.listInstances(null,
null);
+
+ assertThat(topicCountStarted.await(1, TimeUnit.SECONDS)).isTrue();
+ assertThat(result).singleElement().satisfies(instance -> {
+ assertThat(instance.isResourceCountsAvailable()).isFalse();
+ assertThat(instance.getTopicCount()).isZero();
+ assertThat(instance.getConsumerGroupCount()).isZero();
+ });
+
+ releaseProvider.countDown();
+ assertThat(providerFinished.await(1, TimeUnit.SECONDS)).isTrue();
+
+ assertThat(result).singleElement().satisfies(instance -> {
+ assertThat(instance.isResourceCountsAvailable()).isFalse();
+ assertThat(instance.getTopicCount()).isZero();
+ assertThat(instance.getConsumerGroupCount()).isZero();
+ });
+ } finally {
+ releaseProvider.countDown();
+ }
+ }
+
@Test
void createInstanceShouldThrowWhenRequestIsNull() {
assertThatThrownBy(() -> instanceService.createInstance(null))