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 7f2dfd3ed fix(metrics,litetopic): stop cancelled jobs after 
checkpoints (#7188)
7f2dfd3ed is described below

commit 7f2dfd3ed7fd9536b1b46f0efb35a84aadc451a6
Author: Yexi Xiang <[email protected]>
AuthorDate: Wed Oct 28 01:08:40 2026 +0800

    fix(metrics,litetopic): stop cancelled jobs after checkpoints (#7188)
    
    * fix(metrics): stop cancelled jobs after checkpoints (#7188)
    A cancelled collection pass was only checked at the persist entry point, so 
a job
    cancelled while it was blocked in a checkpoint went on to write its samples 
and
    run the alert side effects afterwards. Re-check cancellation and the 
heartbeat
    lease at each of the three points - before the lease refresh, before the 
snapshot
    write, and before alert processing - so a late result from a cancelled or
    de-leased pass is discarded instead of committed. The checks are 
deliberately
    redundant: each one covers a different interleaving.
    * fix(litetopic): exclude unrelated group clients from summaries (#7203)
    buildSummary counted every client connected to the consumer group, including
    clients that had no LiteTopic under the parent topic, so the consumer count 
was
    larger than the number of groups actually consuming lite topics. Resolve the
    client once and skip it when it has no lite topics; the same resolved 
record also
    supplies lastAccessTime, which lets the separate clientLastAccess lookup go 
away.
    
    Batch integration of two fixes by the same contributor; #7203 is closed as 
merged
    into this one. Verified on the combined branch: 65 backend tests across the
    affected classes plus the full suite, and both changes are 
mutation-sensitive -
    removing the post-checkpoint cancellation check fails 2 of the 3 cases in
    CollectorCancellationCheckpointTest, and removing the lite-topic filter 
fails
    listLiteTopicsShouldCountOnlyClientsSubscribedToTheParentTopicTest.
---
 .../studio/cluster/metrics/CollectorScheduler.java |  14 +-
 .../provider/apache/RocketMQLiteTopicProvider.java |  18 +-
 .../CollectorCancellationCheckpointTest.java       | 241 +++++++++++++++++++++
 .../apache/RocketMQLiteTopicProviderTest.java      |  37 +++-
 4 files changed, 293 insertions(+), 17 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 ba4d2e18d..ee83592c9 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
@@ -339,14 +339,26 @@ public class CollectorScheduler {
         MetricCollectionScope actualScope = new MetricCollectionScope(domain, 
instance.getName(), scopeMetricKeys);
         // Refresh immediately before persisting so a slow remote collection 
cannot write after lease loss.
         // This second ownership check is defense-in-depth against a heartbeat 
tick racing this write.
-        if (!heartbeat.isHeld() || !collectionLease.tryAcquire() || 
!heartbeat.isHeld()) {
+        if (!heartbeat.isHeld() || cancelled.get()) {
+            log.debug("Discarding native metric samples before lease refresh");
+            return;
+        }
+        if (!collectionLease.tryAcquire() || !heartbeat.isHeld()) {
             heartbeat.markLost();
             log.debug("Discarding native metric samples because the collection 
lease was lost");
             return;
         }
+        if (cancelled.get()) {
+            log.debug("Discarding native metric samples after cancellation 
during lease refresh");
+            return;
+        }
         if (!collected.isEmpty()) {
             snapshotRepository.saveAll(collected);
         }
+        if (!heartbeat.isHeld() || cancelled.get()) {
+            log.debug("Discarding native metric samples after cancellation or 
lease loss");
+            return;
+        }
         alertProcessor.processSuccessfulCollection(actualScope, collected);
     }
 
diff --git 
a/server/src/main/java/org/apache/rocketmq/studio/provider/apache/RocketMQLiteTopicProvider.java
 
b/server/src/main/java/org/apache/rocketmq/studio/provider/apache/RocketMQLiteTopicProvider.java
index 82cf494be..9be5f5473 100644
--- 
a/server/src/main/java/org/apache/rocketmq/studio/provider/apache/RocketMQLiteTopicProvider.java
+++ 
b/server/src/main/java/org/apache/rocketmq/studio/provider/apache/RocketMQLiteTopicProvider.java
@@ -237,10 +237,15 @@ public class RocketMQLiteTopicProvider implements 
LiteTopicProvider {
                             parent.parentTopic, MAX_LITE_SESSION_SCAN);
                     break;
                 }
+                LocatedClient located = locateClient(admin, masters, 
parent.parentTopic, group,
+                        connection.getClientId());
+                if (located == null || located.body.getLiteTopicCount() <= 0) {
+                    continue;
+                }
                 consumerCount++;
                 sessionIds.add(encodeSessionId(parent.parentTopic, group, 
connection.getClientId()));
-                Long active = clientLastAccess(admin, masters, 
parent.parentTopic, group,
-                        connection.getClientId());
+                Long active = located.body.getLastAccessTime() > 0
+                        ? located.body.getLastAccessTime() : null;
                 if (active != null && (lastActive == null || active > 
lastActive)) {
                     lastActive = active;
                 }
@@ -597,15 +602,6 @@ public class RocketMQLiteTopicProvider implements 
LiteTopicProvider {
         return null;
     }
 
-    private Long clientLastAccess(MQAdminExt admin, List<String> masters, 
String parentTopic,
-                                  String group, String clientId) {
-        LocatedClient located = locateClient(admin, masters, parentTopic, 
group, clientId);
-        if (located == null || located.body.getLastAccessTime() <= 0) {
-            return null;
-        }
-        return located.body.getLastAccessTime();
-    }
-
     private Set<Connection> consumerConnections(MQAdminExt admin, String 
group) {
         try {
             ConsumerConnection connection = 
admin.examineConsumerConnectionInfo(group);
diff --git 
a/server/src/test/java/org/apache/rocketmq/studio/cluster/metrics/CollectorCancellationCheckpointTest.java
 
b/server/src/test/java/org/apache/rocketmq/studio/cluster/metrics/CollectorCancellationCheckpointTest.java
new file mode 100644
index 000000000..bf219497c
--- /dev/null
+++ 
b/server/src/test/java/org/apache/rocketmq/studio/cluster/metrics/CollectorCancellationCheckpointTest.java
@@ -0,0 +1,241 @@
+/*
+ * 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.cluster.metrics;
+
+import org.apache.rocketmq.studio.instance.InstanceRepository;
+import org.apache.rocketmq.studio.instance.InstanceVO;
+import org.apache.rocketmq.studio.ops.alert.AlertDomain;
+import org.apache.rocketmq.studio.ops.alert.NativeAlertProcessor;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.EnumSource;
+
+import java.time.Instant;
+import java.util.List;
+import java.util.Set;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.ScheduledExecutorService;
+import java.util.concurrent.ScheduledFuture;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.ThreadPoolExecutor;
+import java.util.concurrent.LinkedBlockingQueue;
+import java.util.concurrent.atomic.AtomicReference;
+
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyLong;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.doAnswer;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+class CollectorCancellationCheckpointTest {
+    enum Checkpoint {
+        LEASE,
+        SNAPSHOT
+    }
+
+    @ParameterizedTest
+    @EnumSource(Checkpoint.class)
+    void 
cancellationDuringBlockingCheckpointStopsSubsequentSideEffectsTest(Checkpoint 
checkpoint) throws Exception {
+        AlertingProperties properties = new AlertingProperties();
+        properties.setCollectionParallelism(3);
+        properties.setCollectionTimeout("PT1S");
+        InstanceVO slow = instance("slow");
+        InstanceVO fastA = instance("fast-a");
+        InstanceVO fastB = instance("fast-b");
+        InstanceVO blockerA = instance("blocker-a");
+        InstanceVO blockerB = instance("blocker-b");
+        InstanceRepository instances = mock(InstanceRepository.class);
+        when(instances.findAll()).thenReturn(List.of(slow, fastA, fastB, 
blockerA, blockerB));
+
+        CountDownLatch blockersStarted = new CountDownLatch(2);
+        CountDownLatch releaseBlockers = new CountDownLatch(1);
+        CountDownLatch slowFinished = new CountDownLatch(1);
+        BlockingCheckpoint checkpointGate = new BlockingCheckpoint();
+        AtomicReference<Thread> slowThread = new AtomicReference<>();
+        MetricSample slowSample = sample(slow);
+        ClusterMetricsCollector collector = 
mock(ClusterMetricsCollector.class);
+        when(collector.supports(any())).thenReturn(true);
+        
when(collector.metricKeys()).thenReturn(Set.of("nameserver.availability"));
+        when(collector.collect(slow)).thenAnswer(invocation -> {
+            slowThread.set(Thread.currentThread());
+            return List.of(slowSample);
+        });
+        when(collector.collect(fastA)).thenReturn(List.of());
+        when(collector.collect(fastB)).thenReturn(List.of());
+        when(collector.collect(blockerA)).thenAnswer(invocation -> {
+            blockersStarted.countDown();
+            awaitUninterruptibly(releaseBlockers);
+            return List.of();
+        });
+        when(collector.collect(blockerB)).thenAnswer(invocation -> {
+            blockersStarted.countDown();
+            awaitUninterruptibly(releaseBlockers);
+            return List.of();
+        });
+
+        AlertCollectionLease lease = mock(AlertCollectionLease.class);
+        when(lease.tryAcquire()).thenAnswer(invocation -> {
+            if (checkpoint == Checkpoint.LEASE && Thread.currentThread() == 
slowThread.get()) {
+                checkpointGate.awaitReleaseAbsorbingCancellation();
+            }
+            return true;
+        });
+        MetricSnapshotRepository snapshots = 
mock(MetricSnapshotRepository.class);
+        doAnswer(invocation -> {
+            if (checkpoint == Checkpoint.SNAPSHOT && Thread.currentThread() == 
slowThread.get()) {
+                checkpointGate.awaitReleaseAbsorbingCancellation();
+            }
+            return null;
+        }).when(snapshots).saveAll(any());
+        NativeAlertProcessor processor = mock(NativeAlertProcessor.class);
+        ExecutorService executor = new ThreadPoolExecutor(3, 3, 0, 
TimeUnit.MILLISECONDS,
+                new LinkedBlockingQueue<>()) {
+            @Override
+            protected void afterExecute(Runnable task, Throwable error) {
+                super.afterExecute(task, error);
+                if (Thread.currentThread() == slowThread.get()) {
+                    slowFinished.countDown();
+                }
+            }
+        };
+        CollectorScheduler scheduler = new CollectorScheduler(properties, 
instances, List.of(collector), List.of(),
+                snapshots, processor, lease, executor);
+        AtomicReference<Throwable> passFailure = new AtomicReference<>();
+        Thread pass = new Thread(() -> {
+            try {
+                scheduler.collect();
+            } catch (Throwable error) {
+                passFailure.set(error);
+            }
+        });
+        try {
+            pass.start();
+            assertTrue(checkpointGate.entered.await(3, TimeUnit.SECONDS),
+                    "slow job must reach the blocking checkpoint");
+            assertTrue(blockersStarted.await(3, TimeUnit.SECONDS),
+                    "younger jobs must keep the collection pass alive");
+            assertTrue(checkpointGate.cancelled.await(5, TimeUnit.SECONDS),
+                    "the scheduler must cancel the slow job after its 
timeout");
+            checkpointGate.release.countDown();
+            assertTrue(slowFinished.await(3, TimeUnit.SECONDS), "slow worker 
must finish after release");
+            releaseBlockers.countDown();
+            pass.join(TimeUnit.SECONDS.toMillis(3));
+
+            if (checkpoint == Checkpoint.LEASE) {
+                verify(snapshots, never()).saveAll(List.of(slowSample));
+            } else {
+                verify(snapshots).saveAll(List.of(slowSample));
+            }
+            verify(processor, never()).processSuccessfulCollection(any(),
+                    org.mockito.ArgumentMatchers.argThat(rows -> 
rows.contains(slowSample)));
+        } finally {
+            checkpointGate.release.countDown();
+            releaseBlockers.countDown();
+            pass.join(TimeUnit.SECONDS.toMillis(3));
+            scheduler.stopCollectionExecutor();
+        }
+        assertNull(passFailure.get(), "collection pass must not fail");
+    }
+
+    @Test
+    void leaseLossDuringSnapshotStopsAlertProcessingTest() throws Exception {
+        AlertingProperties properties = new AlertingProperties();
+        InstanceVO instance = instance("lease-lost-during-snapshot");
+        InstanceRepository instances = mock(InstanceRepository.class);
+        when(instances.findAll()).thenReturn(List.of(instance));
+        MetricSample sample = sample(instance);
+        ClusterMetricsCollector collector = 
mock(ClusterMetricsCollector.class);
+        when(collector.supports(instance)).thenReturn(true);
+        
when(collector.metricKeys()).thenReturn(Set.of("nameserver.availability"));
+        when(collector.collect(instance)).thenReturn(List.of(sample));
+
+        AlertCollectionLease lease = mock(AlertCollectionLease.class);
+        when(lease.tryAcquire()).thenReturn(true);
+        when(lease.renew()).thenReturn(false);
+        ScheduledExecutorService renewals = 
mock(ScheduledExecutorService.class);
+        ScheduledFuture<?> renewalTask = mock(ScheduledFuture.class);
+        AtomicReference<Runnable> renewal = new AtomicReference<>();
+        when(renewals.scheduleAtFixedRate(any(Runnable.class), anyLong(), 
anyLong(), eq(TimeUnit.MILLISECONDS)))
+                .thenAnswer(invocation -> {
+                    renewal.set(invocation.getArgument(0));
+                    return renewalTask;
+                });
+        MetricSnapshotRepository snapshots = 
mock(MetricSnapshotRepository.class);
+        doAnswer(invocation -> {
+            renewal.get().run();
+            return null;
+        }).when(snapshots).saveAll(List.of(sample));
+        NativeAlertProcessor processor = mock(NativeAlertProcessor.class);
+        CollectorScheduler scheduler = new CollectorScheduler(properties, 
instances, List.of(collector), List.of(),
+                snapshots, processor, lease, newSingleThreadExecutor(), 
renewals);
+        try {
+            scheduler.collect();
+            verify(snapshots).saveAll(List.of(sample));
+            verify(processor, never()).processSuccessfulCollection(any(), 
any());
+        } finally {
+            scheduler.stopCollectionExecutor();
+        }
+    }
+
+    private static InstanceVO instance(String name) {
+        return InstanceVO.builder().name(name).build();
+    }
+
+    private static MetricSample sample(InstanceVO instance) {
+        return new MetricSample("nameserver.availability", 
AlertDomain.CLUSTER, instance.getName(), null,
+                null, 1D, MetricAvailability.AVAILABLE, Instant.now());
+    }
+
+    private static ExecutorService newSingleThreadExecutor() {
+        return new ThreadPoolExecutor(1, 1, 0, TimeUnit.MILLISECONDS, new 
LinkedBlockingQueue<>());
+    }
+
+    private static void awaitUninterruptibly(CountDownLatch release) {
+        while (true) {
+            try {
+                release.await();
+                return;
+            } catch (InterruptedException ignored) {
+                // A sibling cancellation must not abort the healthy blocker.
+            }
+        }
+    }
+
+    private static final class BlockingCheckpoint {
+        private final CountDownLatch entered = new CountDownLatch(1);
+        private final CountDownLatch cancelled = new CountDownLatch(1);
+        private final CountDownLatch release = new CountDownLatch(1);
+
+        private void awaitReleaseAbsorbingCancellation() {
+            entered.countDown();
+            while (true) {
+                try {
+                    release.await();
+                    return;
+                } catch (InterruptedException interrupted) {
+                    cancelled.countDown();
+                }
+            }
+        }
+    }
+}
diff --git 
a/server/src/test/java/org/apache/rocketmq/studio/provider/apache/RocketMQLiteTopicProviderTest.java
 
b/server/src/test/java/org/apache/rocketmq/studio/provider/apache/RocketMQLiteTopicProviderTest.java
index f0b9bcf56..2a5bb8162 100644
--- 
a/server/src/test/java/org/apache/rocketmq/studio/provider/apache/RocketMQLiteTopicProviderTest.java
+++ 
b/server/src/test/java/org/apache/rocketmq/studio/provider/apache/RocketMQLiteTopicProviderTest.java
@@ -206,6 +206,29 @@ class RocketMQLiteTopicProviderTest {
         });
     }
 
+    @Test
+    void listLiteTopicsShouldCountOnlyClientsSubscribedToTheParentTopicTest() 
throws Exception {
+        when(admin.examineBrokerClusterInfo()).thenReturn(cluster(BROKER_A));
+        
when(admin.getBrokerLiteInfo(BROKER_A)).thenReturn(brokerLiteInfo(PARENT, 30, 
2, GROUP));
+        when(admin.getParentTopicInfo(BROKER_A, 
PARENT)).thenReturn(parentTopicInfo(PARENT, 30, 2));
+        when(admin.getLiteGroupInfo(BROKER_A, GROUP, null, 
1)).thenReturn(lag(7));
+        when(admin.examineConsumerConnectionInfo(GROUP))
+                .thenReturn(consumerConnection("c1", "10.0.0.1:1234", "c2", 
"10.0.0.2:1234"));
+        when(admin.getLiteClientInfo(BROKER_A, PARENT, GROUP, "c1"))
+                .thenReturn(clientInfo(1, System.currentTimeMillis(), 
LiteUtil.toLmqName(PARENT, "bob")));
+        // The group also has a client, but it has no LiteTopic under this 
parent.
+        when(admin.getLiteClientInfo(BROKER_A, PARENT, GROUP, "c2"))
+                .thenReturn(clientInfo(0, System.currentTimeMillis()));
+
+        List<LiteTopicSummary> summaries = provider.listLiteTopics(null, null);
+
+        assertThat(summaries).singleElement().satisfies(summary -> {
+            assertThat(summary.getConsumerCount()).isEqualTo(1);
+            assertThat(summary.getSessionIds())
+                    
.containsExactly(RocketMQLiteTopicProvider.encodeSessionId(PARENT, GROUP, 
"c1"));
+        });
+    }
+
     @Test
     void listLiteTopicsFiltersByPatternCaseInsensitively() throws Exception {
         when(admin.examineBrokerClusterInfo()).thenReturn(cluster(BROKER_A));
@@ -635,12 +658,16 @@ class RocketMQLiteTopicProviderTest {
         return body;
     }
 
-    private static ConsumerConnection consumerConnection(String clientId, 
String clientAddr) {
-        Connection connection = new Connection();
-        connection.setClientId(clientId);
-        connection.setClientAddr(clientAddr);
+    private static ConsumerConnection consumerConnection(String... 
clientPairs) {
+        HashSet<Connection> connections = new HashSet<>();
+        for (int i = 0; i < clientPairs.length; i += 2) {
+            Connection connection = new Connection();
+            connection.setClientId(clientPairs[i]);
+            connection.setClientAddr(clientPairs[i + 1]);
+            connections.add(connection);
+        }
         ConsumerConnection consumerConnection = new ConsumerConnection();
-        consumerConnection.setConnectionSet(new HashSet<>(Set.of(connection)));
+        consumerConnection.setConnectionSet(connections);
         return consumerConnection;
     }
 

Reply via email to