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 d03f5d144 fix(metrics): discard results from cancelled collection jobs 
(#6690)
d03f5d144 is described below

commit d03f5d1441e78e61af3a02bf888d9aff047f4cfb
Author: 健康宝 <[email protected]>
AuthorDate: Thu Oct 15 18:19:11 2026 +0800

    fix(metrics): discard results from cancelled collection jobs (#6690)
---
 .../studio/cluster/metrics/CollectorScheduler.java | 55 +++++++++----
 .../cluster/metrics/CollectorSchedulerTest.java    | 96 ++++++++++++++++++++++
 2 files changed, 136 insertions(+), 15 deletions(-)

diff --git 
a/server/src/main/java/org/apache/rocketmq/studio/cluster/metrics/CollectorScheduler.java
 
b/server/src/main/java/org/apache/rocketmq/studio/cluster/metrics/CollectorScheduler.java
index d5e982569..ba4d2e18d 100644
--- 
a/server/src/main/java/org/apache/rocketmq/studio/cluster/metrics/CollectorScheduler.java
+++ 
b/server/src/main/java/org/apache/rocketmq/studio/cluster/metrics/CollectorScheduler.java
@@ -174,15 +174,21 @@ public class CollectorScheduler {
             }
             InstanceVO instance = pending.removeFirst();
             try {
+                // Per-job cancellation state: Future.cancel(true) only sets 
the
+                // interrupt bit, which a collector can absorb and keep going,
+                // and the pass-level heartbeat stays held while sibling jobs
+                // run. Without this flag a cancelled job's late results could
+                // still pass the lease check and reach the repository.
+                java.util.concurrent.atomic.AtomicBoolean cancelled = new 
java.util.concurrent.atomic.AtomicBoolean(false);
                 Future<InstanceVO> future = completionService.submit(() -> {
-                    if (heartbeat.isHeld()) {
-                        collectClusterMetrics(instance, heartbeat);
-                        collectBusinessMetrics(instance, heartbeat);
+                    if (heartbeat.isHeld() && !cancelled.get()) {
+                        collectClusterMetrics(instance, heartbeat, cancelled);
+                        collectBusinessMetrics(instance, heartbeat, cancelled);
                     }
                     return instance;
                 });
                 leaseJobs.add(future);
-                running.add(new CollectionJob(instance, future, 
now.plus(timeout)));
+                running.add(new CollectionJob(instance, future, 
now.plus(timeout), cancelled));
             } catch (RejectedExecutionException error) {
                 pending.addFirst(instance);
                 throw error;
@@ -231,12 +237,12 @@ public class CollectorScheduler {
         while (iterator.hasNext()) {
             CollectionJob job = iterator.next();
             if (!passDeadline.isAfter(now)) {
-                job.future().cancel(true);
+                job.cancel();
                 iterator.remove();
                 log.warn("Native metric collection pass exceeded {}; 
cancelling instance {}", passTimeout,
                         job.instance().getName());
             } else if (!job.deadline().isAfter(now)) {
-                job.future().cancel(true);
+                job.cancel();
                 iterator.remove();
                 log.warn("Native metric collection exceeded {} for instance {} 
and was cancelled", timeout,
                         job.instance().getName());
@@ -246,7 +252,7 @@ public class CollectorScheduler {
 
     private void cancelRunningJobs(List<CollectionJob> running) {
         for (CollectionJob job : running) {
-            job.future().cancel(true);
+            job.cancel();
         }
         running.clear();
     }
@@ -279,15 +285,16 @@ public class CollectorScheduler {
         snapshotRepository.deleteBefore(Instant.now().minus(retention));
     }
 
-    private void collectClusterMetrics(InstanceVO instance, LeaseHeartbeat 
heartbeat) {
+    private void collectClusterMetrics(InstanceVO instance, LeaseHeartbeat 
heartbeat,
+            java.util.concurrent.atomic.AtomicBoolean cancelled) {
         for (ClusterMetricsCollector collector : clusterCollectors) {
-            if (!heartbeat.isHeld()) {
+            if (!heartbeat.isHeld() || cancelled.get()) {
                 return;
             }
             try {
                 if (collector.supports(instance)) {
                     List<MetricSample> samples = collector.collect(instance);
-                    persist(AlertDomain.CLUSTER, instance, 
collector.metricKeys(), samples, heartbeat);
+                    persist(AlertDomain.CLUSTER, instance, 
collector.metricKeys(), samples, heartbeat, cancelled);
                 }
             } catch (RuntimeException error) {
                 log.warn("Native metric collector failed for instance {}: {}", 
instance.getName(), error.getMessage());
@@ -295,15 +302,16 @@ public class CollectorScheduler {
         }
     }
 
-    private void collectBusinessMetrics(InstanceVO instance, LeaseHeartbeat 
heartbeat) {
+    private void collectBusinessMetrics(InstanceVO instance, LeaseHeartbeat 
heartbeat,
+            java.util.concurrent.atomic.AtomicBoolean cancelled) {
         for (BusinessMetricsCollector collector : businessCollectors) {
-            if (!heartbeat.isHeld()) {
+            if (!heartbeat.isHeld() || cancelled.get()) {
                 return;
             }
             try {
                 if (collector.supports(instance)) {
                     List<MetricSample> samples = collector.collect(instance);
-                    persist(AlertDomain.BUSINESS, instance, 
collector.metricKeys(), samples, heartbeat);
+                    persist(AlertDomain.BUSINESS, instance, 
collector.metricKeys(), samples, heartbeat, cancelled);
                 }
             } catch (RuntimeException error) {
                 log.warn("Native metric collector failed for instance {}: {}", 
instance.getName(), error.getMessage());
@@ -312,7 +320,17 @@ public class CollectorScheduler {
     }
 
     private void persist(AlertDomain domain, InstanceVO instance, Set<String> 
declaredMetricKeys,
-            List<MetricSample> samples, LeaseHeartbeat heartbeat) {
+            List<MetricSample> samples, LeaseHeartbeat heartbeat,
+            java.util.concurrent.atomic.AtomicBoolean cancelled) {
+        // A job that was cancelled or expired must not start persisting: its
+        // samples predate the cancellation decision and feeding them (and the
+        // alert processor) after the scheduler gave up on the instance
+        // contradicts that decision. The interrupt bit alone cannot enforce
+        // this — collectors may absorb it.
+        if (cancelled.get()) {
+            log.debug("Discarding native metric samples from a cancelled 
collection job");
+            return;
+        }
         List<MetricSample> collected = samples == null ? List.of() : samples;
         Set<String> scopeMetricKeys = metricKeys(declaredMetricKeys, 
collected);
         if (scopeMetricKeys.isEmpty()) {
@@ -442,7 +460,14 @@ public class CollectorScheduler {
         }
     }
 
-    private record CollectionJob(InstanceVO instance, Future<InstanceVO> 
future, Instant deadline) {
+    private record CollectionJob(InstanceVO instance, Future<InstanceVO> 
future, Instant deadline,
+            java.util.concurrent.atomic.AtomicBoolean cancelled) {
+
+        /** Marks the job cancelled for its collection/persist guards, then 
interrupts the worker. */
+        void cancel() {
+            cancelled.set(true);
+            future.cancel(true);
+        }
     }
 
     private static Duration parsePositiveDuration(String value, Duration 
fallback) {
diff --git 
a/server/src/test/java/org/apache/rocketmq/studio/cluster/metrics/CollectorSchedulerTest.java
 
b/server/src/test/java/org/apache/rocketmq/studio/cluster/metrics/CollectorSchedulerTest.java
index 8273169d3..9847a642c 100644
--- 
a/server/src/test/java/org/apache/rocketmq/studio/cluster/metrics/CollectorSchedulerTest.java
+++ 
b/server/src/test/java/org/apache/rocketmq/studio/cluster/metrics/CollectorSchedulerTest.java
@@ -190,6 +190,102 @@ class CollectorSchedulerTest {
         scheduler.stopCollectionExecutor();
     }
 
+    @Test
+    void discardsLateResultsFromACancelledJobTest() throws Exception {
+        AlertingProperties properties = new AlertingProperties();
+        // Four instances, parallelism 2, staggered by a gated quick job: the
+        // slow job's deadline expires while two later-submitted jobs are
+        // still running, so the pass — and its lease heartbeat — stay alive
+        // when the cancelled job's late result arrives.
+        properties.setCollectionParallelism(2);
+        properties.setCollectionTimeout("PT0.5S");
+        InstanceRepository instances = mock(InstanceRepository.class);
+        InstanceVO slow = 
InstanceVO.builder().name("slow").endpoint("slow:9876").build();
+        InstanceVO quick = 
InstanceVO.builder().name("quick").endpoint("quick:9876").build();
+        InstanceVO blocker1 = 
InstanceVO.builder().name("blocker1").endpoint("blocker1:9876").build();
+        InstanceVO blocker2 = 
InstanceVO.builder().name("blocker2").endpoint("blocker2:9876").build();
+        when(instances.findAll()).thenReturn(List.of(slow, quick, blocker1, 
blocker2));
+
+        CountDownLatch releaseQuick = new CountDownLatch(1);
+        CountDownLatch releaseBlocker1 = new CountDownLatch(1);
+        CountDownLatch releaseBlocker2 = new CountDownLatch(1);
+        CountDownLatch releaseSlow = new CountDownLatch(1);
+        MetricSample slowSample = new MetricSample("nameserver.availability", 
AlertDomain.CLUSTER, "slow", null,
+                null, 1D, MetricAvailability.AVAILABLE, Instant.now());
+        MetricSample blockerSample = new 
MetricSample("nameserver.availability", AlertDomain.CLUSTER, "blocker1",
+                null, null, 1D, MetricAvailability.AVAILABLE, Instant.now());
+        ClusterMetricsCollector collector = 
mock(ClusterMetricsCollector.class);
+        when(collector.supports(any())).thenReturn(true);
+        
when(collector.metricKeys()).thenReturn(java.util.Set.of("nameserver.availability"));
+        when(collector.collect(slow)).thenAnswer(invocation -> {
+            // Absorbs the cancellation interrupt and keeps waiting, like a
+            // collector that only checks the interrupt at safe points: the
+            // late result must still be discarded. The flag is deliberately
+            // not re-set — re-setting it would make await() spin instead of
+            // parking again.
+            while (true) {
+                try {
+                    releaseSlow.await();
+                    break;
+                } catch (InterruptedException absorbed) {
+                    // swallowed on purpose
+                }
+            }
+            return List.of(slowSample);
+        });
+        when(collector.collect(quick)).thenAnswer(invocation -> {
+            releaseQuick.await();
+            return List.of();
+        });
+        when(collector.collect(blocker1)).thenAnswer(invocation -> {
+            releaseBlocker1.await();
+            return List.of(blockerSample);
+        });
+        when(collector.collect(blocker2)).thenAnswer(invocation -> {
+            releaseBlocker2.await();
+            return List.of();
+        });
+
+        MetricSnapshotRepository snapshots = 
mock(MetricSnapshotRepository.class);
+        CountDownLatch persisted = new CountDownLatch(1);
+        org.mockito.Mockito.doAnswer(invocation -> {
+            if (invocation.getArgument(0).toString().contains("blocker1")) {
+                persisted.countDown();
+            }
+            return null;
+        }).when(snapshots).saveAll(any());
+        NativeAlertProcessor processor = mock(NativeAlertProcessor.class);
+        AlertCollectionLease lease = mock(AlertCollectionLease.class);
+        when(lease.tryAcquire()).thenReturn(true);
+        ExecutorService executor = Executors.newFixedThreadPool(2);
+        CollectorScheduler scheduler = new CollectorScheduler(properties, 
instances, List.of(collector), List.of(),
+                snapshots, processor, lease, executor);
+
+        Thread pass = new Thread(scheduler::collect);
+        pass.start();
+        // t=0: slow + quick start. t=400ms: quick finishes, blocker1 starts
+        // with a fresh deadline. t=500ms: slow's deadline cancels it while
+        // blocker1/blocker2 keep the pass alive; blocker2 takes the freed
+        // slot with another fresh deadline.
+        TimeUnit.MILLISECONDS.sleep(400);
+        releaseQuick.countDown();
+        TimeUnit.MILLISECONDS.sleep(200);
+        releaseBlocker1.countDown();
+        assertTrue(persisted.await(1, TimeUnit.SECONDS), "the healthy instance 
still persists");
+        // The cancelled job's late result lands while the pass is alive and
+        // the lease heartbeat is held; only afterwards is the last blocker
+        // released to let the pass finish.
+        releaseSlow.countDown();
+        TimeUnit.MILLISECONDS.sleep(200);
+        releaseBlocker2.countDown();
+        pass.join(TimeUnit.SECONDS.toMillis(3));
+        assertTrue(!pass.isAlive(), "the collection pass should finish");
+        verify(snapshots, never()).saveAll(List.of(slowSample));
+        verify(processor, never()).processSuccessfulCollection(any(),
+                argThat(list -> list.contains(slowSample)));
+        scheduler.stopCollectionExecutor();
+    }
+
     @Test
     void appliesCollectionTimeoutToTheWholePassInsteadOfEachInstanceTest() {
         AlertingProperties properties = new AlertingProperties();

Reply via email to