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 3693dab57 fix(alert): renew collection lease during long passes (#2642)
3693dab57 is described below

commit 3693dab57c4a25de2a258c778763f152a07beeeb
Author: xdz997 <[email protected]>
AuthorDate: Fri Sep 4 15:48:47 2026 +0800

    fix(alert): renew collection lease during long passes (#2642)
    
    * fix: renew native alert collection lease during long passes
    
    * docs: clarify native alert lease heartbeat safeguards
---
 docs/studio-native-alerting-design.md              |   4 +
 .../cluster/metrics/AlertCollectionLease.java      |   8 +
 .../studio/cluster/metrics/AlertingProperties.java |   2 +
 .../studio/cluster/metrics/CollectorScheduler.java | 162 +++++++++++++++++++--
 .../metrics/MybatisPlusAlertCollectionLease.java   |   9 ++
 .../mapper/RmqAlertCollectionLeaseMapper.java      |   5 +
 server/src/main/resources/application.yml          |   2 +
 .../cluster/metrics/CollectorSchedulerTest.java    |  68 +++++++++
 .../MybatisPlusAlertCollectionLeaseTest.java       |  56 +++++++
 9 files changed, 301 insertions(+), 15 deletions(-)

diff --git a/docs/studio-native-alerting-design.md 
b/docs/studio-native-alerting-design.md
index 06c0e69a6..344714231 100644
--- a/docs/studio-native-alerting-design.md
+++ b/docs/studio-native-alerting-design.md
@@ -239,6 +239,10 @@ Snapshots have short retention, initially 24 hours. Studio 
is not a replacement
 
 `CollectorScheduler` creates per-instance jobs at a default 30-second 
interval. Each job has a bounded timeout and the scheduler uses bounded 
concurrency, so one slow remote instance does not indefinitely delay every 
other instance. Collection failure records an unavailable sample and health 
diagnostic; it does not block other instances.
 
+The database lease is held for the entire collection pass, including passes in 
which every collector returns an empty sample list. The active holder renews it 
periodically (by default every 15 seconds, clamped to no more than one third of 
the configured lease duration). Renewal only succeeds for the current holder 
while its previous lease is still unexpired. If renewal fails, the scheduler 
cancels outstanding collection jobs and refuses to persist later samples, so an 
expired replica can [...]
+
+The lease duration and heartbeat interval can be configured with 
`STUDIO_ALERTING_COLLECTION_LEASE_DURATION` and 
`STUDIO_ALERTING_COLLECTION_LEASE_RENEWAL_INTERVAL`. The interval should be 
comfortably shorter than the duration to tolerate transient database latency.
+
 For multi-replica Studio deployment, the scheduler uses a database lease:
 
 ```text
diff --git 
a/server/src/main/java/org/apache/rocketmq/studio/cluster/metrics/AlertCollectionLease.java
 
b/server/src/main/java/org/apache/rocketmq/studio/cluster/metrics/AlertCollectionLease.java
index 2669a92b3..1d0a23d31 100644
--- 
a/server/src/main/java/org/apache/rocketmq/studio/cluster/metrics/AlertCollectionLease.java
+++ 
b/server/src/main/java/org/apache/rocketmq/studio/cluster/metrics/AlertCollectionLease.java
@@ -17,5 +17,13 @@
 package org.apache.rocketmq.studio.cluster.metrics;
 
 public interface AlertCollectionLease {
+    /** Claims the lease, or refreshes it when this replica already owns it. */
     boolean tryAcquire();
+
+    /**
+     * Extends the current holder's lease without allowing an expired holder 
to reclaim it.
+     *
+     * @return {@code true} when the current holder still owns the lease
+     */
+    boolean renew();
 }
diff --git 
a/server/src/main/java/org/apache/rocketmq/studio/cluster/metrics/AlertingProperties.java
 
b/server/src/main/java/org/apache/rocketmq/studio/cluster/metrics/AlertingProperties.java
index a7773976c..c81854d2e 100644
--- 
a/server/src/main/java/org/apache/rocketmq/studio/cluster/metrics/AlertingProperties.java
+++ 
b/server/src/main/java/org/apache/rocketmq/studio/cluster/metrics/AlertingProperties.java
@@ -32,6 +32,8 @@ public class AlertingProperties {
      * native samples and emitting duplicate alert events.
      */
     private String collectionLeaseDuration = "PT1M";
+    /** Maximum time between lease heartbeats; the scheduler clamps this below 
the lease duration. */
+    private String collectionLeaseRenewalInterval = "PT15S";
     /** Retain short-lived diagnostic samples without allowing the snapshot 
table to grow indefinitely. */
     private String snapshotRetention = "PT24H";
     /** Retain terminal notification deliveries before deleting them from the 
outbox. */
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 22fc26270..44301997b 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
@@ -34,14 +34,18 @@ import java.util.List;
 import java.util.concurrent.ArrayBlockingQueue;
 import java.util.concurrent.CancellationException;
 import java.util.concurrent.CompletionService;
+import java.util.concurrent.CopyOnWriteArrayList;
 import java.util.concurrent.ExecutionException;
 import java.util.concurrent.ExecutorService;
 import java.util.concurrent.ExecutorCompletionService;
 import java.util.concurrent.Future;
 import java.util.concurrent.RejectedExecutionException;
+import java.util.concurrent.ScheduledExecutorService;
+import java.util.concurrent.ScheduledFuture;
 import java.util.concurrent.ThreadFactory;
 import java.util.concurrent.ThreadPoolExecutor;
 import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicBoolean;
 import java.time.Duration;
 import java.time.Instant;
 import java.util.Set;
@@ -59,6 +63,7 @@ public class CollectorScheduler {
     private final NativeAlertProcessor alertProcessor;
     private final AlertCollectionLease collectionLease;
     private final ExecutorService collectionExecutor;
+    private final ScheduledExecutorService leaseRenewalExecutor;
 
     @Autowired
     public CollectorScheduler(AlertingProperties properties, 
InstanceRepository instanceRepository,
@@ -66,13 +71,22 @@ public class CollectorScheduler {
             MetricSnapshotRepository snapshotRepository, NativeAlertProcessor 
alertProcessor,
             AlertCollectionLease collectionLease) {
         this(properties, instanceRepository, clusterCollectors, 
businessCollectors, snapshotRepository, alertProcessor,
-                collectionLease, newCollectionExecutor(properties));
+                collectionLease, newCollectionExecutor(properties), 
newLeaseRenewalExecutor());
     }
 
     CollectorScheduler(AlertingProperties properties, InstanceRepository 
instanceRepository,
             List<ClusterMetricsCollector> clusterCollectors, 
List<BusinessMetricsCollector> businessCollectors,
             MetricSnapshotRepository snapshotRepository, NativeAlertProcessor 
alertProcessor,
             AlertCollectionLease collectionLease, ExecutorService 
collectionExecutor) {
+        this(properties, instanceRepository, clusterCollectors, 
businessCollectors, snapshotRepository, alertProcessor,
+                collectionLease, collectionExecutor, 
newLeaseRenewalExecutor());
+    }
+
+    CollectorScheduler(AlertingProperties properties, InstanceRepository 
instanceRepository,
+            List<ClusterMetricsCollector> clusterCollectors, 
List<BusinessMetricsCollector> businessCollectors,
+            MetricSnapshotRepository snapshotRepository, NativeAlertProcessor 
alertProcessor,
+            AlertCollectionLease collectionLease, ExecutorService 
collectionExecutor,
+            ScheduledExecutorService leaseRenewalExecutor) {
         this.properties = properties;
         this.instanceRepository = instanceRepository;
         this.clusterCollectors = clusterCollectors;
@@ -81,6 +95,7 @@ public class CollectorScheduler {
         this.alertProcessor = alertProcessor;
         this.collectionLease = collectionLease;
         this.collectionExecutor = collectionExecutor;
+        this.leaseRenewalExecutor = leaseRenewalExecutor;
     }
 
     @Scheduled(fixedDelayString = 
"${studio.alerting.collection-interval:PT30S}")
@@ -89,8 +104,20 @@ public class CollectorScheduler {
             log.debug("Skipping native alert collection because another Studio 
replica holds the lease");
             return;
         }
+        List<Future<?>> leaseJobs = new CopyOnWriteArrayList<>();
+        LeaseHeartbeat heartbeat = new LeaseHeartbeat(collectionLease, 
leaseRenewalExecutor,
+                leaseRenewalInterval(), leaseJobs);
+        heartbeat.start();
+        try {
+            collectWithPassBudget(heartbeat, leaseJobs);
+        } finally {
+            heartbeat.close();
+        }
+    }
+
+    private void collectWithPassBudget(LeaseHeartbeat heartbeat, 
List<Future<?>> leaseJobs) {
         List<InstanceVO> instances = instanceRepository.findAll();
-        if (instances.isEmpty()) {
+        if (instances.isEmpty() || !heartbeat.isHeld()) {
             return;
         }
         Duration timeout = 
parsePositiveDuration(properties.getCollectionTimeout(), 
Duration.ofSeconds(15));
@@ -101,11 +128,13 @@ public class CollectorScheduler {
         ArrayDeque<InstanceVO> pending = new ArrayDeque<>(instances);
         List<CollectionJob> running = new ArrayList<>();
         try {
-            submitAvailable(completionService, pending, running, parallelism, 
timeout, passDeadline);
-            while (!running.isEmpty()) {
+            submitAvailable(completionService, pending, running, parallelism, 
timeout, passDeadline, heartbeat,
+                    leaseJobs);
+            while (!running.isEmpty() && heartbeat.isHeld()) {
                 completeFinishedJobs(running);
                 cancelExpiredJobs(running, timeout, passDeadline, passTimeout);
-                submitAvailable(completionService, pending, running, 
parallelism, timeout, passDeadline);
+                submitAvailable(completionService, pending, running, 
parallelism, timeout, passDeadline, heartbeat,
+                        leaseJobs);
                 if (running.isEmpty()) {
                     break;
                 }
@@ -124,6 +153,10 @@ public class CollectorScheduler {
             log.warn("Native metric collection executor rejected new work; 
aborting pass with {} unstarted instance(s)",
                     pending.size());
         }
+        if (!heartbeat.isHeld()) {
+            cancelRunningJobs(running);
+            return;
+        }
         if (!pending.isEmpty()) {
             log.warn("Native metric collection pass exceeded {}; {} 
instance(s) were not started", passTimeout,
                     pending.size());
@@ -131,8 +164,9 @@ public class CollectorScheduler {
     }
 
     private void submitAvailable(CompletionService<InstanceVO> 
completionService, ArrayDeque<InstanceVO> pending,
-            List<CollectionJob> running, int parallelism, Duration timeout, 
Instant passDeadline) {
-        while (!pending.isEmpty() && running.size() < parallelism) {
+            List<CollectionJob> running, int parallelism, Duration timeout, 
Instant passDeadline,
+            LeaseHeartbeat heartbeat, List<Future<?>> leaseJobs) {
+        while (!pending.isEmpty() && running.size() < parallelism && 
heartbeat.isHeld()) {
             Instant now = Instant.now();
             if (!passDeadline.isAfter(now)) {
                 return;
@@ -140,10 +174,13 @@ public class CollectorScheduler {
             InstanceVO instance = pending.removeFirst();
             try {
                 Future<InstanceVO> future = completionService.submit(() -> {
-                    collectClusterMetrics(instance);
-                    collectBusinessMetrics(instance);
+                    if (heartbeat.isHeld()) {
+                        collectClusterMetrics(instance, heartbeat);
+                        collectBusinessMetrics(instance, heartbeat);
+                    }
                     return instance;
                 });
+                leaseJobs.add(future);
                 running.add(new CollectionJob(instance, future, 
now.plus(timeout)));
             } catch (RejectedExecutionException error) {
                 pending.addFirst(instance);
@@ -232,12 +269,15 @@ public class CollectorScheduler {
         snapshotRepository.deleteBefore(Instant.now().minus(retention));
     }
 
-    private void collectClusterMetrics(InstanceVO instance) {
+    private void collectClusterMetrics(InstanceVO instance, LeaseHeartbeat 
heartbeat) {
         for (ClusterMetricsCollector collector : clusterCollectors) {
+            if (!heartbeat.isHeld()) {
+                return;
+            }
             try {
                 if (collector.supports(instance)) {
                     List<MetricSample> samples = collector.collect(instance);
-                    persist(AlertDomain.CLUSTER, instance, 
collector.metricKeys(), samples);
+                    persist(AlertDomain.CLUSTER, instance, 
collector.metricKeys(), samples, heartbeat);
                 }
             } catch (RuntimeException error) {
                 log.warn("Native metric collector failed for instance {}: {}", 
instance.getName(), error.getMessage());
@@ -245,12 +285,15 @@ public class CollectorScheduler {
         }
     }
 
-    private void collectBusinessMetrics(InstanceVO instance) {
+    private void collectBusinessMetrics(InstanceVO instance, LeaseHeartbeat 
heartbeat) {
         for (BusinessMetricsCollector collector : businessCollectors) {
+            if (!heartbeat.isHeld()) {
+                return;
+            }
             try {
                 if (collector.supports(instance)) {
                     List<MetricSample> samples = collector.collect(instance);
-                    persist(AlertDomain.BUSINESS, instance, 
collector.metricKeys(), samples);
+                    persist(AlertDomain.BUSINESS, instance, 
collector.metricKeys(), samples, heartbeat);
                 }
             } catch (RuntimeException error) {
                 log.warn("Native metric collector failed for instance {}: {}", 
instance.getName(), error.getMessage());
@@ -259,7 +302,7 @@ public class CollectorScheduler {
     }
 
     private void persist(AlertDomain domain, InstanceVO instance, Set<String> 
declaredMetricKeys,
-            List<MetricSample> samples) {
+            List<MetricSample> samples, LeaseHeartbeat heartbeat) {
         List<MetricSample> collected = samples == null ? List.of() : samples;
         Set<String> scopeMetricKeys = metricKeys(declaredMetricKeys, 
collected);
         if (scopeMetricKeys.isEmpty()) {
@@ -267,7 +310,9 @@ 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.
-        if (!collectionLease.tryAcquire()) {
+        // This second ownership check is defense-in-depth against a heartbeat 
tick racing this write.
+        if (!heartbeat.isHeld() || !collectionLease.tryAcquire() || 
!heartbeat.isHeld()) {
+            heartbeat.markLost();
             log.debug("Discarding native metric samples because the collection 
lease was lost");
             return;
         }
@@ -287,6 +332,7 @@ public class CollectorScheduler {
     @PreDestroy
     void stopCollectionExecutor() {
         collectionExecutor.shutdownNow();
+        leaseRenewalExecutor.shutdownNow();
     }
 
     private static ExecutorService newCollectionExecutor(AlertingProperties 
properties) {
@@ -300,6 +346,92 @@ public class CollectorScheduler {
                 new ArrayBlockingQueue<>(parallelism), threadFactory, new 
ThreadPoolExecutor.AbortPolicy());
     }
 
+    private static ScheduledExecutorService newLeaseRenewalExecutor() {
+        ThreadFactory threadFactory = task -> {
+            Thread thread = new Thread(task, 
"studio-native-metric-lease-renewer");
+            thread.setDaemon(true);
+            return thread;
+        };
+        return 
java.util.concurrent.Executors.newSingleThreadScheduledExecutor(threadFactory);
+    }
+
+    private Duration leaseRenewalInterval() {
+        Duration leaseDuration = 
parsePositiveDuration(properties.getCollectionLeaseDuration(), 
Duration.ofMinutes(1));
+        Duration safeMaximum = leaseDuration.dividedBy(3);
+        if (!safeMaximum.isPositive()) {
+            safeMaximum = Duration.ofMillis(1);
+        }
+        Duration configured = 
parsePositiveDuration(properties.getCollectionLeaseRenewalInterval(), 
safeMaximum);
+        return configured.compareTo(safeMaximum) > 0 ? safeMaximum : 
configured;
+    }
+
+    /**
+     * Renews the collection lease while broker calls are in flight and 
cancels outstanding work
+     * as soon as ownership is lost, so an expired pass cannot publish late 
samples.
+     */
+    private static final class LeaseHeartbeat implements AutoCloseable {
+        private final AlertCollectionLease lease;
+        private final ScheduledExecutorService executor;
+        private final Duration interval;
+        private final List<Future<?>> jobs;
+        private final AtomicBoolean held = new AtomicBoolean(true);
+        private volatile ScheduledFuture<?> scheduledTask;
+
+        private LeaseHeartbeat(AlertCollectionLease lease, 
ScheduledExecutorService executor, Duration interval,
+                List<Future<?>> jobs) {
+            this.lease = lease;
+            this.executor = executor;
+            this.interval = interval;
+            this.jobs = jobs;
+        }
+
+        private void start() {
+            long intervalMillis = Math.max(1L, interval.toMillis());
+            try {
+                scheduledTask = executor.scheduleAtFixedRate(this::renewLease, 
intervalMillis, intervalMillis,
+                        TimeUnit.MILLISECONDS);
+            } catch (RuntimeException error) {
+                markLost();
+                log.warn("Unable to start native alert collection lease 
heartbeat", error);
+            }
+        }
+
+        private void renewLease() {
+            if (!held.get()) {
+                return;
+            }
+            try {
+                if (!lease.renew()) {
+                    markLost();
+                    log.warn("Native alert collection lease renewal failed; 
cancelling the active collection pass");
+                }
+            } catch (RuntimeException error) {
+                markLost();
+                log.warn("Native alert collection lease renewal raised an 
exception; cancelling the active pass",
+                        error);
+            }
+        }
+
+        private boolean isHeld() {
+            return held.get();
+        }
+
+        private void markLost() {
+            if (held.compareAndSet(true, false)) {
+                jobs.forEach(job -> job.cancel(true));
+            }
+        }
+
+        @Override
+        public void close() {
+            held.set(false);
+            ScheduledFuture<?> task = scheduledTask;
+            if (task != null) {
+                task.cancel(false);
+            }
+        }
+    }
+
     private record CollectionJob(InstanceVO instance, Future<InstanceVO> 
future, Instant deadline) {
     }
 
diff --git 
a/server/src/main/java/org/apache/rocketmq/studio/cluster/metrics/MybatisPlusAlertCollectionLease.java
 
b/server/src/main/java/org/apache/rocketmq/studio/cluster/metrics/MybatisPlusAlertCollectionLease.java
index bca58e6b8..6bd12afa4 100644
--- 
a/server/src/main/java/org/apache/rocketmq/studio/cluster/metrics/MybatisPlusAlertCollectionLease.java
+++ 
b/server/src/main/java/org/apache/rocketmq/studio/cluster/metrics/MybatisPlusAlertCollectionLease.java
@@ -61,6 +61,15 @@ public class MybatisPlusAlertCollectionLease implements 
AlertCollectionLease {
         }
     }
 
+    @Override
+    public boolean renew() {
+        Instant now = Instant.now();
+        Duration duration = 
parseDuration(properties.getCollectionLeaseDuration());
+        LocalDateTime renewedAt = LocalDateTime.ofInstant(now, ZoneOffset.UTC);
+        LocalDateTime expiresAt = LocalDateTime.ofInstant(now.plus(duration), 
ZoneOffset.UTC);
+        return mapper.renew(LEASE_NAME, holderId, renewedAt, expiresAt) > 0;
+    }
+
     private static Duration parseDuration(String configured) {
         try {
             Duration duration = Duration.parse(configured);
diff --git 
a/server/src/main/java/org/apache/rocketmq/studio/persistence/mapper/RmqAlertCollectionLeaseMapper.java
 
b/server/src/main/java/org/apache/rocketmq/studio/persistence/mapper/RmqAlertCollectionLeaseMapper.java
index 023074702..a932c9bc5 100644
--- 
a/server/src/main/java/org/apache/rocketmq/studio/persistence/mapper/RmqAlertCollectionLeaseMapper.java
+++ 
b/server/src/main/java/org/apache/rocketmq/studio/persistence/mapper/RmqAlertCollectionLeaseMapper.java
@@ -29,4 +29,9 @@ public interface RmqAlertCollectionLeaseMapper extends 
BaseMapper<RmqAlertCollec
             + "AND (expires_at <= #{now} OR holder_id = #{holderId})")
     int acquire(@Param("leaseName") String leaseName, @Param("holderId") 
String holderId,
             @Param("now") LocalDateTime now, @Param("expiresAt") LocalDateTime 
expiresAt);
+
+    @Update("UPDATE rmq_alert_collection_lease SET expires_at = #{expiresAt}, 
gmt_modified = #{now} "
+            + "WHERE lease_name = #{leaseName} AND holder_id = #{holderId} AND 
expires_at > #{now}")
+    int renew(@Param("leaseName") String leaseName, @Param("holderId") String 
holderId,
+            @Param("now") LocalDateTime now, @Param("expiresAt") LocalDateTime 
expiresAt);
 }
diff --git a/server/src/main/resources/application.yml 
b/server/src/main/resources/application.yml
index 9a586acbb..6efbf2bb5 100644
--- a/server/src/main/resources/application.yml
+++ b/server/src/main/resources/application.yml
@@ -85,6 +85,8 @@ studio:
     collection-interval: ${STUDIO_ALERTING_COLLECTION_INTERVAL:PT30S}
     collection-parallelism: ${STUDIO_ALERTING_COLLECTION_PARALLELISM:4}
     collection-timeout: ${STUDIO_ALERTING_COLLECTION_TIMEOUT:PT15S}
+    collection-lease-duration: 
${STUDIO_ALERTING_COLLECTION_LEASE_DURATION:PT1M}
+    collection-lease-renewal-interval: 
${STUDIO_ALERTING_COLLECTION_LEASE_RENEWAL_INTERVAL:PT15S}
     snapshot-retention: ${STUDIO_ALERTING_SNAPSHOT_RETENTION:PT24H}
     snapshot-cleanup-interval: 
${STUDIO_ALERTING_SNAPSHOT_CLEANUP_INTERVAL:PT1H}
     notification-dispatch-interval: 
${STUDIO_ALERTING_NOTIFICATION_DISPATCH_INTERVAL:PT10S}
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 ecf956412..33fa2ed72 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
@@ -30,6 +30,7 @@ import java.util.concurrent.CountDownLatch;
 import java.util.concurrent.ExecutorService;
 import java.util.concurrent.Executors;
 import java.util.concurrent.LinkedBlockingQueue;
+import java.util.concurrent.ScheduledExecutorService;
 import java.util.concurrent.TimeUnit;
 import java.util.concurrent.ThreadPoolExecutor;
 import java.util.concurrent.atomic.AtomicInteger;
@@ -38,6 +39,7 @@ import static org.junit.jupiter.api.Assertions.assertEquals;
 import static org.junit.jupiter.api.Assertions.assertTrue;
 import static org.mockito.ArgumentMatchers.any;
 import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.atLeast;
 import static org.mockito.Mockito.never;
 import static org.mockito.Mockito.verify;
 import static org.mockito.Mockito.when;
@@ -331,6 +333,72 @@ class CollectorSchedulerTest {
         
verify(processor).processSuccessfulCollection(any(MetricCollectionScope.class), 
org.mockito.ArgumentMatchers.eq(List.of()));
     }
 
+    @Test
+    void renewsLeaseWhileAnEmptyCollectionPassIsStillRunningTest() throws 
Exception {
+        AlertingProperties properties = new AlertingProperties();
+        properties.setCollectionLeaseDuration("PT0.2S");
+        properties.setCollectionLeaseRenewalInterval("PT1S");
+        properties.setCollectionTimeout("PT1S");
+        InstanceVO instance = InstanceVO.builder().name("slow-empty").build();
+        InstanceRepository instances = mock(InstanceRepository.class);
+        when(instances.findAll()).thenReturn(List.of(instance));
+        ClusterMetricsCollector collector = 
mock(ClusterMetricsCollector.class);
+        when(collector.supports(instance)).thenReturn(true);
+        when(collector.collect(instance)).thenAnswer(invocation -> {
+            Thread.sleep(350);
+            return List.of();
+        });
+        AlertCollectionLease lease = mock(AlertCollectionLease.class);
+        when(lease.tryAcquire()).thenReturn(true);
+        when(lease.renew()).thenReturn(true);
+        ExecutorService collectionExecutor = Executors.newFixedThreadPool(1);
+        ScheduledExecutorService renewalExecutor = 
Executors.newSingleThreadScheduledExecutor();
+        CollectorScheduler scheduler = new CollectorScheduler(properties, 
instances, List.of(collector), List.of(),
+                mock(MetricSnapshotRepository.class), 
mock(NativeAlertProcessor.class), lease, collectionExecutor,
+                renewalExecutor);
+
+        try {
+            scheduler.collect();
+            verify(lease, atLeast(3)).renew();
+        } finally {
+            scheduler.stopCollectionExecutor();
+        }
+    }
+
+    @Test
+    void stopsAnActivePassWhenLeaseRenewalFailsTest() throws Exception {
+        AlertingProperties properties = new AlertingProperties();
+        properties.setCollectionLeaseDuration("PT0.2S");
+        properties.setCollectionLeaseRenewalInterval("PT1S");
+        properties.setCollectionTimeout("PT1S");
+        InstanceVO instance = InstanceVO.builder().name("lease-lost").build();
+        InstanceRepository instances = mock(InstanceRepository.class);
+        when(instances.findAll()).thenReturn(List.of(instance));
+        ClusterMetricsCollector collector = 
mock(ClusterMetricsCollector.class);
+        when(collector.supports(instance)).thenReturn(true);
+        when(collector.collect(instance)).thenAnswer(invocation -> {
+            Thread.sleep(350);
+            return List.of(sampleFor(instance));
+        });
+        MetricSnapshotRepository snapshots = 
mock(MetricSnapshotRepository.class);
+        NativeAlertProcessor processor = mock(NativeAlertProcessor.class);
+        AlertCollectionLease lease = mock(AlertCollectionLease.class);
+        when(lease.tryAcquire()).thenReturn(true);
+        when(lease.renew()).thenReturn(false);
+        ExecutorService collectionExecutor = Executors.newFixedThreadPool(1);
+        ScheduledExecutorService renewalExecutor = 
Executors.newSingleThreadScheduledExecutor();
+        CollectorScheduler scheduler = new CollectorScheduler(properties, 
instances, List.of(collector), List.of(),
+                snapshots, processor, lease, collectionExecutor, 
renewalExecutor);
+
+        try {
+            scheduler.collect();
+            verify(snapshots, never()).saveAll(any());
+            verify(processor, never()).processSuccessfulCollection(any(), 
any());
+        } finally {
+            scheduler.stopCollectionExecutor();
+        }
+    }
+
     private static MetricSample sampleFor(InstanceVO instance) {
         return new MetricSample("nameserver.availability", 
AlertDomain.CLUSTER, instance.getName(), null,
                 null, 1D, MetricAvailability.AVAILABLE, Instant.now());
diff --git 
a/server/src/test/java/org/apache/rocketmq/studio/cluster/metrics/MybatisPlusAlertCollectionLeaseTest.java
 
b/server/src/test/java/org/apache/rocketmq/studio/cluster/metrics/MybatisPlusAlertCollectionLeaseTest.java
new file mode 100644
index 000000000..ea17e6a84
--- /dev/null
+++ 
b/server/src/test/java/org/apache/rocketmq/studio/cluster/metrics/MybatisPlusAlertCollectionLeaseTest.java
@@ -0,0 +1,56 @@
+/*
+ * 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.persistence.mapper.RmqAlertCollectionLeaseMapper;
+import org.junit.jupiter.api.Test;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+class MybatisPlusAlertCollectionLeaseTest {
+
+    @Test
+    void renewsAnUnexpiredLeaseHeldByThisReplicaTest() {
+        AlertingProperties properties = new AlertingProperties();
+        properties.setCollectionLeaseDuration("PT30S");
+        RmqAlertCollectionLeaseMapper mapper = 
mock(RmqAlertCollectionLeaseMapper.class);
+        when(mapper.renew(eq("native-alert-collection"), anyString(), any(), 
any())).thenReturn(1);
+
+        MybatisPlusAlertCollectionLease lease = new 
MybatisPlusAlertCollectionLease(properties, mapper);
+
+        assertThat(lease.renew()).isTrue();
+        verify(mapper).renew(eq("native-alert-collection"), anyString(), 
any(), any());
+    }
+
+    @Test
+    void reportsLeaseLossWhenTheDatabaseNoLongerMatchesThisHolderTest() {
+        AlertingProperties properties = new AlertingProperties();
+        RmqAlertCollectionLeaseMapper mapper = 
mock(RmqAlertCollectionLeaseMapper.class);
+        when(mapper.renew(eq("native-alert-collection"), anyString(), any(), 
any())).thenReturn(0);
+
+        MybatisPlusAlertCollectionLease lease = new 
MybatisPlusAlertCollectionLease(properties, mapper);
+
+        assertThat(lease.renew()).isFalse();
+        verify(mapper).renew(eq("native-alert-collection"), anyString(), 
any(), any());
+    }
+}

Reply via email to