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 6604841c5 fix(cluster): harden proxy discovery, nameserver reload and 
metrics collection (#2807)
6604841c5 is described below

commit 6604841c5f00eb7e1d089883dc8239bf3346d206
Author: yyqdbngt <[email protected]>
AuthorDate: Mon Aug 31 19:21:24 2026 +0800

    fix(cluster): harden proxy discovery, nameserver reload and metrics 
collection (#2807)
    
    * fix(proxy): retry discovery after transient failures
    
    * fix(proxy): reject malformed bracketed IPv6 addresses
    
    * fix(nameserver): handle create reload races
    
    * fix(metrics): bound the complete collection pass
    
    * fix(metrics): isolate unscoped snapshot history
    
    ---------
    
    Co-authored-by: Yue Wang <[email protected]>
---
 .../studio/cluster/metrics/CollectorScheduler.java | 11 +++-
 .../MybatisPlusMetricSnapshotRepository.java       |  1 +
 .../nameserver/NameserverRegistryService.java      |  6 ++-
 .../studio/cluster/proxy/ProxyAddressService.java  |  7 +++
 .../provider/apache/ProxyConsumerResolver.java     |  2 +
 .../cluster/metrics/CollectorSchedulerTest.java    | 34 ++++++++++++
 .../MybatisPlusMetricSnapshotRepositoryTest.java   | 61 ++++++++++++++++++++++
 .../nameserver/NameserverRegistryServiceTest.java  | 18 +++++++
 .../cluster/proxy/ProxyAddressServiceTest.java     |  5 +-
 .../provider/apache/ProxyConsumerResolverTest.java | 18 +++++++
 10 files changed, 159 insertions(+), 4 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 e20c717f6..4cdfe880a 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
@@ -84,12 +84,19 @@ public class CollectorScheduler {
                 .filter(java.util.Objects::nonNull)
                 .toList();
         Duration timeout = 
parsePositiveDuration(properties.getCollectionTimeout(), 
Duration.ofSeconds(15));
+        long passStartedAt = System.nanoTime();
+        long timeoutNanos = timeout.toNanos();
         for (Future<?> job : jobs) {
             try {
-                job.get(timeout.toMillis(), TimeUnit.MILLISECONDS);
+                long remainingNanos = timeoutNanos - (System.nanoTime() - 
passStartedAt);
+                if (remainingNanos <= 0) {
+                    job.cancel(true);
+                    continue;
+                }
+                job.get(remainingNanos, TimeUnit.NANOSECONDS);
             } catch (java.util.concurrent.TimeoutException error) {
                 job.cancel(true);
-                log.warn("Native metric collection exceeded {} for one 
instance and was cancelled", timeout);
+                log.warn("Native metric collection pass exceeded {} and 
unfinished work was cancelled", timeout);
             } catch (InterruptedException error) {
                 Thread.currentThread().interrupt();
                 return;
diff --git 
a/server/src/main/java/org/apache/rocketmq/studio/cluster/metrics/MybatisPlusMetricSnapshotRepository.java
 
b/server/src/main/java/org/apache/rocketmq/studio/cluster/metrics/MybatisPlusMetricSnapshotRepository.java
index 18baf8105..8cdd35480 100644
--- 
a/server/src/main/java/org/apache/rocketmq/studio/cluster/metrics/MybatisPlusMetricSnapshotRepository.java
+++ 
b/server/src/main/java/org/apache/rocketmq/studio/cluster/metrics/MybatisPlusMetricSnapshotRepository.java
@@ -60,6 +60,7 @@ public class MybatisPlusMetricSnapshotRepository implements 
MetricSnapshotReposi
         return mapper.selectList(new QueryWrapper<RmqMetricSnapshot>()
                         .eq("instance_id", 
scope.instanceId()).eq("metric_key", scope.metricKey())
                         .eq("domain", scope.domain().name()).eq("labels_hash", 
sha256(labelsJson))
+                        .isNull(scope.clusterId() == null, "cluster_id")
                         .eq(scope.clusterId() != null, "cluster_id", 
scope.clusterId())
                         .eq("availability", 
MetricAvailability.AVAILABLE.name())
                         .ge("collected_at", LocalDateTime.ofInstant(since, 
ZoneOffset.UTC))
diff --git 
a/server/src/main/java/org/apache/rocketmq/studio/cluster/nameserver/NameserverRegistryService.java
 
b/server/src/main/java/org/apache/rocketmq/studio/cluster/nameserver/NameserverRegistryService.java
index 2d2c6e750..615499813 100644
--- 
a/server/src/main/java/org/apache/rocketmq/studio/cluster/nameserver/NameserverRegistryService.java
+++ 
b/server/src/main/java/org/apache/rocketmq/studio/cluster/nameserver/NameserverRegistryService.java
@@ -57,7 +57,11 @@ public class NameserverRegistryService {
             // The unique index is the final guard against concurrent 
duplicate creates.
             throw duplicateName(name);
         }
-        return toVO(nameserverMapper.selectById(entity.getId()));
+        RmqNameserver stored = nameserverMapper.selectById(entity.getId());
+        if (stored == null) {
+            throw concurrentlyDeleted(entity.getId());
+        }
+        return toVO(stored);
     }
 
     public NameserverRegistryVO update(UpdateNameserverRegistryDTO command) {
diff --git 
a/server/src/main/java/org/apache/rocketmq/studio/cluster/proxy/ProxyAddressService.java
 
b/server/src/main/java/org/apache/rocketmq/studio/cluster/proxy/ProxyAddressService.java
index 14a27f9cd..145f67355 100644
--- 
a/server/src/main/java/org/apache/rocketmq/studio/cluster/proxy/ProxyAddressService.java
+++ 
b/server/src/main/java/org/apache/rocketmq/studio/cluster/proxy/ProxyAddressService.java
@@ -17,6 +17,7 @@
 
 package org.apache.rocketmq.studio.cluster.proxy;
 
+import org.apache.commons.validator.routines.InetAddressValidator;
 import org.apache.rocketmq.studio.cluster.broker.ClusterService;
 import org.apache.rocketmq.studio.common.exception.BusinessException;
 import 
org.apache.rocketmq.studio.common.util.NoRedirectClientHttpRequestFactory;
@@ -53,6 +54,7 @@ public class ProxyAddressService {
 
     private static final Pattern PROXY_ADDR_PATTERN =
             
Pattern.compile("^(\\[[0-9a-fA-F:.]+]|[A-Za-z0-9._-]+):(\\d{1,5})$");
+    private static final InetAddressValidator INET_ADDRESS_VALIDATOR = 
InetAddressValidator.getInstance();
     private static final int MIN_PORT = 1;
     private static final int MAX_PORT = 65535;
 
@@ -311,6 +313,11 @@ public class ProxyAddressService {
         if (!matcher.matches()) {
             throw new BusinessException(400, fieldName + " must be in 
host:port or [ipv6]:port format");
         }
+        String host = matcher.group(1);
+        if (host.startsWith("[")
+                && 
!INET_ADDRESS_VALIDATOR.isValidInet6Address(host.substring(1, host.length() - 
1))) {
+            throw new BusinessException(400, fieldName + " contains a 
malformed IPv6 address");
+        }
         int port = Integer.parseInt(matcher.group(2));
         if (port < MIN_PORT || port > MAX_PORT) {
             throw new BusinessException(400, fieldName + " port must be 
between 1 and 65535");
diff --git 
a/server/src/main/java/org/apache/rocketmq/studio/provider/apache/ProxyConsumerResolver.java
 
b/server/src/main/java/org/apache/rocketmq/studio/provider/apache/ProxyConsumerResolver.java
index 192c55c75..15d5e2ac0 100644
--- 
a/server/src/main/java/org/apache/rocketmq/studio/provider/apache/ProxyConsumerResolver.java
+++ 
b/server/src/main/java/org/apache/rocketmq/studio/provider/apache/ProxyConsumerResolver.java
@@ -173,6 +173,8 @@ public class ProxyConsumerResolver {
         } catch (Exception e) {
             log.debug("Proxy discovery via heartbeat syncer failed for 
instance {}: {}",
                     instanceId, e.getMessage());
+            // A failed lookup is transient and must not suppress discovery 
for the full cache TTL.
+            return List.of();
         }
         List<String> addresses = ips.stream()
                 .map(ip -> ip + ":" + PROXY_REMOTING_PORT)
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 be7283447..ea4c8fdad 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
@@ -83,6 +83,40 @@ class CollectorSchedulerTest {
         scheduler.stopCollectionExecutor();
     }
 
+    @Test
+    void appliesCollectionTimeoutToTheWholePassInsteadOfEachInstanceTest() {
+        AlertingProperties properties = new AlertingProperties();
+        properties.setCollectionTimeout("PT0.25S");
+        InstanceRepository instances = mock(InstanceRepository.class);
+        when(instances.findAll()).thenReturn(List.of(
+                InstanceVO.builder().name("slow-a").build(),
+                InstanceVO.builder().name("slow-b").build(),
+                InstanceVO.builder().name("slow-c").build()));
+        ClusterMetricsCollector collector = 
mock(ClusterMetricsCollector.class);
+        when(collector.supports(any(InstanceVO.class))).thenReturn(true);
+        when(collector.collect(any(InstanceVO.class))).thenAnswer(invocation 
-> {
+            try {
+                Thread.sleep(TimeUnit.SECONDS.toMillis(5));
+            } catch (InterruptedException ignored) {
+                Thread.currentThread().interrupt();
+            }
+            return List.of();
+        });
+        AlertCollectionLease lease = mock(AlertCollectionLease.class);
+        when(lease.tryAcquire()).thenReturn(true);
+        ExecutorService executor = Executors.newSingleThreadExecutor();
+        CollectorScheduler scheduler = new CollectorScheduler(properties, 
instances, List.of(collector), List.of(),
+                mock(MetricSnapshotRepository.class), 
mock(NativeAlertProcessor.class), lease, executor);
+
+        long startedAt = System.nanoTime();
+        scheduler.collect();
+        long elapsedMillis = TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - 
startedAt);
+        scheduler.stopCollectionExecutor();
+
+        assertTrue(elapsedMillis < 600,
+                "the configured timeout should cap the complete pass, 
elapsed=" + elapsedMillis + "ms");
+    }
+
     @Test
     void collectsAndPersistsSupportedSamplesTest() {
         AlertingProperties properties = new AlertingProperties();
diff --git 
a/server/src/test/java/org/apache/rocketmq/studio/cluster/metrics/MybatisPlusMetricSnapshotRepositoryTest.java
 
b/server/src/test/java/org/apache/rocketmq/studio/cluster/metrics/MybatisPlusMetricSnapshotRepositoryTest.java
new file mode 100644
index 000000000..7d89fa996
--- /dev/null
+++ 
b/server/src/test/java/org/apache/rocketmq/studio/cluster/metrics/MybatisPlusMetricSnapshotRepositoryTest.java
@@ -0,0 +1,61 @@
+/*
+ * 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 com.baomidou.mybatisplus.core.conditions.Wrapper;
+import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import org.apache.rocketmq.studio.ops.alert.AlertDomain;
+import org.apache.rocketmq.studio.persistence.entity.RmqMetricSnapshot;
+import org.apache.rocketmq.studio.persistence.mapper.RmqMetricSnapshotMapper;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.ArgumentCaptor;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+
+import java.time.Instant;
+import java.util.List;
+import java.util.Map;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+@ExtendWith(MockitoExtension.class)
+class MybatisPlusMetricSnapshotRepositoryTest {
+
+    @Mock
+    private RmqMetricSnapshotMapper mapper;
+
+    @Test
+    void nullClusterScopeShouldOnlyReadUnscopedSnapshotsTest() {
+        when(mapper.selectList(any(Wrapper.class))).thenReturn(List.of());
+        MybatisPlusMetricSnapshotRepository repository =
+                new MybatisPlusMetricSnapshotRepository(mapper, new 
ObjectMapper());
+        MetricSample scope = new MetricSample("broker.availability", 
AlertDomain.CLUSTER,
+                "local", null, Map.of(), 1D, MetricAvailability.AVAILABLE, 
Instant.now());
+
+        repository.findRecent(scope, Instant.EPOCH);
+
+        ArgumentCaptor<Wrapper<RmqMetricSnapshot>> queryCaptor = 
ArgumentCaptor.forClass(Wrapper.class);
+        verify(mapper).selectList(queryCaptor.capture());
+        QueryWrapper<RmqMetricSnapshot> query = 
(QueryWrapper<RmqMetricSnapshot>) queryCaptor.getValue();
+        assertThat(query.getSqlSegment()).contains("cluster_id IS NULL");
+    }
+}
diff --git 
a/server/src/test/java/org/apache/rocketmq/studio/cluster/nameserver/NameserverRegistryServiceTest.java
 
b/server/src/test/java/org/apache/rocketmq/studio/cluster/nameserver/NameserverRegistryServiceTest.java
index d1bc1e83e..e253677b1 100644
--- 
a/server/src/test/java/org/apache/rocketmq/studio/cluster/nameserver/NameserverRegistryServiceTest.java
+++ 
b/server/src/test/java/org/apache/rocketmq/studio/cluster/nameserver/NameserverRegistryServiceTest.java
@@ -200,6 +200,24 @@ class NameserverRegistryServiceTest {
                 .hasMessageContaining("already exists");
     }
 
+    @Test
+    void createShouldThrowWhenEntryVanishesBeforeReloadTest() {
+        when(nameserverMapper.selectCount(any())).thenReturn(0L);
+        
when(nameserverMapper.insert(any(RmqNameserver.class))).thenAnswer(invocation 
-> {
+            RmqNameserver entity = invocation.getArgument(0);
+            entity.setId(12L);
+            return 1;
+        });
+        when(nameserverMapper.selectById(12L)).thenReturn(null);
+
+        assertThatThrownBy(() -> 
service.create(CreateNameserverRegistryDTO.builder()
+                .name("prod")
+                .namesrvAddr("10.0.0.1:9876")
+                .build()))
+                .isInstanceOf(BusinessException.class)
+                .hasMessageContaining("deleted concurrently");
+    }
+
     @Test
     void updateShouldPersistAndReturnStoredEntryTest() {
         RmqNameserver existing = new RmqNameserver();
diff --git 
a/server/src/test/java/org/apache/rocketmq/studio/cluster/proxy/ProxyAddressServiceTest.java
 
b/server/src/test/java/org/apache/rocketmq/studio/cluster/proxy/ProxyAddressServiceTest.java
index 0b727eba7..cada952fd 100644
--- 
a/server/src/test/java/org/apache/rocketmq/studio/cluster/proxy/ProxyAddressServiceTest.java
+++ 
b/server/src/test/java/org/apache/rocketmq/studio/cluster/proxy/ProxyAddressServiceTest.java
@@ -104,7 +104,10 @@ class ProxyAddressServiceTest {
                 "10.0.0.1:0",
                 "10.0.0.1:65536",
                 "http://10.0.0.1:8081";,
-                "10.0.0.1:8081/path"
+                "10.0.0.1:8081/path",
+                "[:::]:8081",
+                "[2001:db8::1::2]:8081",
+                "[127.0.0.1]:8081"
         );
 
         for (String invalidProxyAddr : invalidProxyAddrs) {
diff --git 
a/server/src/test/java/org/apache/rocketmq/studio/provider/apache/ProxyConsumerResolverTest.java
 
b/server/src/test/java/org/apache/rocketmq/studio/provider/apache/ProxyConsumerResolverTest.java
index 6957a1512..137269cf9 100644
--- 
a/server/src/test/java/org/apache/rocketmq/studio/provider/apache/ProxyConsumerResolverTest.java
+++ 
b/server/src/test/java/org/apache/rocketmq/studio/provider/apache/ProxyConsumerResolverTest.java
@@ -94,6 +94,24 @@ class ProxyConsumerResolverTest {
                 
.examineConsumerConnectionInfo("CID_DefaultHeartBeatSyncerTopic");
     }
 
+    @Test
+    void discoverProxyAddressesShouldRetryAfterATransientFailureTest() throws 
Exception {
+        ConsumerConnection syncer = new ConsumerConnection();
+        Connection proxy = new Connection();
+        proxy.setClientId("proxy-a");
+        proxy.setClientAddr("10.0.4.66:10911");
+        syncer.setConnectionSet(new HashSet<>(List.of(proxy)));
+        
when(adminExt.examineConsumerConnectionInfo("CID_DefaultHeartBeatSyncerTopic"))
+                .thenThrow(new IllegalStateException("nameserver unavailable"))
+                .thenReturn(syncer);
+
+        assertThat(resolver.discoverProxyAddresses("instance-a")).isEmpty();
+        
assertThat(resolver.discoverProxyAddresses("instance-a")).containsExactly("10.0.4.66:8080");
+
+        org.mockito.Mockito.verify(adminExt, org.mockito.Mockito.times(2))
+                
.examineConsumerConnectionInfo("CID_DefaultHeartBeatSyncerTopic");
+    }
+
     @Test
     void resolveConsumerConnectionShouldReturnNullWhenNoProxyDiscoveredTest() 
throws Exception {
         
when(adminExt.examineConsumerConnectionInfo("CID_DefaultHeartBeatSyncerTopic"))

Reply via email to