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 30e697660 fix(alert): consolidate native collection reliability (#2957)
30e697660 is described below

commit 30e697660be86af2acaa98910e1fc6e7f3e4560f
Author: aias00 <[email protected]>
AuthorDate: Wed Sep 2 15:05:50 2026 +0800

    fix(alert): consolidate native collection reliability (#2957)
    
    * Prevent native alert collection from dropping saturated instances
    
    Use a sliding collection window so one pass keeps at most 
collectionParallelism jobs in flight, starts the next instance only after a job 
completes or times out, and keeps the executor queue bounded.
    
    Constraint: preserve lease refresh before persistence, per-instance timeout 
cancellation, and interrupt cancellation
    
    Rejected: unbounded executor queue | it can hide scheduler saturation by 
queueing the full instance inventory
    
    Confidence: high
    
    Scope-risk: narrow
    
    Tested: JAVA_HOME=$(/usr/libexec/java_home -v 21) mvn 
-Dtest=CollectorSchedulerTest test
    Signed-off-by: liuhy <[email protected]>
    
    * Resolve stale native alerts after complete collections
    
    Native alert states can remain FIRING or ACKED when the monitored resource 
disappears and no longer emits a sample. Reconcile active fingerprints only 
after a collector has completed a scoped collection, and use the latest alert 
event metadata to emit one resolved lifecycle event with the original labels.
    
    Also batch-load latest alert event metadata for active states to avoid 
per-state lookups when reconciling multiple active fingerprints.
    
    Constraint: failed, partial, or lease-lost collection must not clear active 
state
    
    Rejected: per-state latest alert selectOne lookup | causes N+1 queries 
during reconciliation
    
    Confidence: high
    
    Scope-risk: moderate
    
    Tested: JAVA_HOME=$(/usr/libexec/java_home -v 21) mvn 
-Dtest=MybatisPlusAlertStateRepositoryTest test
    
    Tested: JAVA_HOME=$(/usr/libexec/java_home -v 21) mvn 
-Dtest=NativeAlertProcessorTest,CollectorSchedulerTest,MybatisPlusAlertStateRepositoryTest
 test
    
    Tested: JAVA_HOME=$(/usr/libexec/java_home -v 21) mvn 
-Dtest='org.apache.rocketmq.studio.ops.alert.*Test,org.apache.rocketmq.studio.cluster.metrics.*Test'
 test
    
    Tested: JAVA_HOME=$(/usr/libexec/java_home -v 21) mvn test
    
    Tested: JAVA_HOME=$(/usr/libexec/java_home -v 21) mvn -DskipTests package
    Signed-off-by: liuhy <[email protected]>
    
    ---------
    
    Signed-off-by: liuhy <[email protected]>
---
 .../cluster/metrics/BusinessMetricsCollector.java  |   5 +
 .../cluster/metrics/ClusterMetricsCollector.java   |   5 +
 .../studio/cluster/metrics/CollectorScheduler.java | 206 +++++++++++++++++----
 .../cluster/metrics/MetricCollectionScope.java     |  41 ++++
 .../ApacheRocketMqBusinessMetricsCollector.java    |   6 +
 .../ApacheRocketMqClusterMetricsCollector.java     |   7 +
 .../ApacheRocketMqDlqMetricsCollector.java         |   6 +
 .../ApacheRocketMqProxyMetricsCollector.java       |   6 +
 .../CloudRocketMqBusinessMetricsCollector.java     |   6 +
 .../CloudRocketMqClusterMetricsCollector.java      |   6 +
 .../alert/ActiveAlertState.java}                   |  22 ++-
 .../studio/ops/alert/AlertStateRepository.java     |   6 +
 .../ops/alert/MybatisPlusAlertStateRepository.java |  85 +++++++++
 .../studio/ops/alert/NativeAlertProcessor.java     | 130 ++++++++++---
 .../cluster/metrics/CollectorSchedulerTest.java    | 158 +++++++++++++++-
 .../alert/MybatisPlusAlertStateRepositoryTest.java |  95 +++++++++-
 .../studio/ops/alert/NativeAlertProcessorTest.java |  89 +++++++++
 17 files changed, 800 insertions(+), 79 deletions(-)

diff --git 
a/server/src/main/java/org/apache/rocketmq/studio/cluster/metrics/BusinessMetricsCollector.java
 
b/server/src/main/java/org/apache/rocketmq/studio/cluster/metrics/BusinessMetricsCollector.java
index 327b2dc63..008f36801 100644
--- 
a/server/src/main/java/org/apache/rocketmq/studio/cluster/metrics/BusinessMetricsCollector.java
+++ 
b/server/src/main/java/org/apache/rocketmq/studio/cluster/metrics/BusinessMetricsCollector.java
@@ -19,10 +19,15 @@ package org.apache.rocketmq.studio.cluster.metrics;
 import org.apache.rocketmq.studio.instance.InstanceVO;
 
 import java.util.List;
+import java.util.Set;
 
 /** Collects business-flow metrics for one Studio-managed instance. */
 public interface BusinessMetricsCollector {
     boolean supports(InstanceVO instance);
 
     List<MetricSample> collect(InstanceVO instance);
+
+    default Set<String> metricKeys() {
+        return Set.of();
+    }
 }
diff --git 
a/server/src/main/java/org/apache/rocketmq/studio/cluster/metrics/ClusterMetricsCollector.java
 
b/server/src/main/java/org/apache/rocketmq/studio/cluster/metrics/ClusterMetricsCollector.java
index 406b1207d..c07f0c5d9 100644
--- 
a/server/src/main/java/org/apache/rocketmq/studio/cluster/metrics/ClusterMetricsCollector.java
+++ 
b/server/src/main/java/org/apache/rocketmq/studio/cluster/metrics/ClusterMetricsCollector.java
@@ -19,10 +19,15 @@ package org.apache.rocketmq.studio.cluster.metrics;
 import org.apache.rocketmq.studio.instance.InstanceVO;
 
 import java.util.List;
+import java.util.Set;
 
 /** Collects operational metrics for one Studio-managed instance. */
 public interface ClusterMetricsCollector {
     boolean supports(InstanceVO instance);
 
     List<MetricSample> collect(InstanceVO instance);
+
+    default Set<String> metricKeys() {
+        return Set.of();
+    }
 }
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 4cdfe880a..22fc26270 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
@@ -20,21 +20,31 @@ import jakarta.annotation.PreDestroy;
 import lombok.extern.slf4j.Slf4j;
 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.springframework.beans.factory.annotation.Autowired;
 import 
org.springframework.boot.context.properties.EnableConfigurationProperties;
 import org.springframework.scheduling.annotation.Scheduled;
 import org.springframework.stereotype.Component;
 
+import java.util.ArrayDeque;
+import java.util.ArrayList;
+import java.util.Iterator;
 import java.util.List;
 import java.util.concurrent.ArrayBlockingQueue;
+import java.util.concurrent.CancellationException;
+import java.util.concurrent.CompletionService;
+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.ThreadFactory;
 import java.util.concurrent.ThreadPoolExecutor;
 import java.util.concurrent.TimeUnit;
 import java.time.Duration;
 import java.time.Instant;
+import java.util.Set;
 
 /** Runs independent, bounded collection jobs for each configured instance. */
 @Slf4j
@@ -79,43 +89,138 @@ public class CollectorScheduler {
             log.debug("Skipping native alert collection because another Studio 
replica holds the lease");
             return;
         }
-        List<Future<?>> jobs = instanceRepository.findAll().stream()
-                .map(this::submitCollection)
-                .filter(java.util.Objects::nonNull)
-                .toList();
+        List<InstanceVO> instances = instanceRepository.findAll();
+        if (instances.isEmpty()) {
+            return;
+        }
         Duration timeout = 
parsePositiveDuration(properties.getCollectionTimeout(), 
Duration.ofSeconds(15));
-        long passStartedAt = System.nanoTime();
-        long timeoutNanos = timeout.toNanos();
-        for (Future<?> job : jobs) {
-            try {
-                long remainingNanos = timeoutNanos - (System.nanoTime() - 
passStartedAt);
-                if (remainingNanos <= 0) {
-                    job.cancel(true);
-                    continue;
+        Duration passTimeout = timeout.multipliedBy(instances.size());
+        Instant passDeadline = Instant.now().plus(passTimeout);
+        int parallelism = Math.max(1, properties.getCollectionParallelism());
+        CompletionService<InstanceVO> completionService = new 
ExecutorCompletionService<>(collectionExecutor);
+        ArrayDeque<InstanceVO> pending = new ArrayDeque<>(instances);
+        List<CollectionJob> running = new ArrayList<>();
+        try {
+            submitAvailable(completionService, pending, running, parallelism, 
timeout, passDeadline);
+            while (!running.isEmpty()) {
+                completeFinishedJobs(running);
+                cancelExpiredJobs(running, timeout, passDeadline, passTimeout);
+                submitAvailable(completionService, pending, running, 
parallelism, timeout, passDeadline);
+                if (running.isEmpty()) {
+                    break;
+                }
+                Future<InstanceVO> completed = 
completionService.poll(nextWaitMillis(running, passDeadline),
+                        TimeUnit.MILLISECONDS);
+                if (completed != null) {
+                    completeJob(running, completed);
                 }
-                job.get(remainingNanos, TimeUnit.NANOSECONDS);
-            } catch (java.util.concurrent.TimeoutException error) {
-                job.cancel(true);
-                log.warn("Native metric collection pass exceeded {} and 
unfinished work was cancelled", timeout);
-            } catch (InterruptedException error) {
-                Thread.currentThread().interrupt();
+            }
+        } catch (InterruptedException error) {
+            Thread.currentThread().interrupt();
+            cancelRunningJobs(running);
+            return;
+        } catch (RejectedExecutionException error) {
+            cancelRunningJobs(running);
+            log.warn("Native metric collection executor rejected new work; 
aborting pass with {} unstarted instance(s)",
+                    pending.size());
+        }
+        if (!pending.isEmpty()) {
+            log.warn("Native metric collection pass exceeded {}; {} 
instance(s) were not started", passTimeout,
+                    pending.size());
+        }
+    }
+
+    private void submitAvailable(CompletionService<InstanceVO> 
completionService, ArrayDeque<InstanceVO> pending,
+            List<CollectionJob> running, int parallelism, Duration timeout, 
Instant passDeadline) {
+        while (!pending.isEmpty() && running.size() < parallelism) {
+            Instant now = Instant.now();
+            if (!passDeadline.isAfter(now)) {
                 return;
-            } catch (java.util.concurrent.ExecutionException error) {
-                log.warn("Native metric collection job failed: {}", 
error.getCause().getMessage());
+            }
+            InstanceVO instance = pending.removeFirst();
+            try {
+                Future<InstanceVO> future = completionService.submit(() -> {
+                    collectClusterMetrics(instance);
+                    collectBusinessMetrics(instance);
+                    return instance;
+                });
+                running.add(new CollectionJob(instance, future, 
now.plus(timeout)));
+            } catch (RejectedExecutionException error) {
+                pending.addFirst(instance);
+                throw error;
+            }
+        }
+    }
+
+    private void completeFinishedJobs(List<CollectionJob> running) throws 
InterruptedException {
+        Iterator<CollectionJob> iterator = running.iterator();
+        while (iterator.hasNext()) {
+            CollectionJob job = iterator.next();
+            if (job.future().isDone()) {
+                iterator.remove();
+                awaitJob(job);
             }
         }
     }
 
-    private Future<?> submitCollection(InstanceVO instance) {
+    private void completeJob(List<CollectionJob> running, Future<InstanceVO> 
completed) throws InterruptedException {
+        Iterator<CollectionJob> iterator = running.iterator();
+        while (iterator.hasNext()) {
+            CollectionJob job = iterator.next();
+            if (job.future() == completed) {
+                iterator.remove();
+                awaitJob(job);
+                return;
+            }
+        }
+    }
+
+    private void awaitJob(CollectionJob job) throws InterruptedException {
         try {
-            return collectionExecutor.submit(() -> {
-                collectClusterMetrics(instance);
-                collectBusinessMetrics(instance);
-            });
-        } catch (java.util.concurrent.RejectedExecutionException error) {
-            log.warn("Skipping native metric collection for instance {} 
because the collector is saturated", instance.getName());
-            return null;
+            job.future().get();
+        } catch (CancellationException ignored) {
+            // Already logged when the job was cancelled.
+        } catch (ExecutionException error) {
+            log.warn("Native metric collection job failed for instance {}: 
{}", job.instance().getName(),
+                    error.getCause().getMessage());
+        }
+    }
+
+    private void cancelExpiredJobs(List<CollectionJob> running, Duration 
timeout, Instant passDeadline,
+            Duration passTimeout) {
+        Instant now = Instant.now();
+        Iterator<CollectionJob> iterator = running.iterator();
+        while (iterator.hasNext()) {
+            CollectionJob job = iterator.next();
+            if (!passDeadline.isAfter(now)) {
+                job.future().cancel(true);
+                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);
+                iterator.remove();
+                log.warn("Native metric collection exceeded {} for instance {} 
and was cancelled", timeout,
+                        job.instance().getName());
+            }
+        }
+    }
+
+    private void cancelRunningJobs(List<CollectionJob> running) {
+        for (CollectionJob job : running) {
+            job.future().cancel(true);
         }
+        running.clear();
+    }
+
+    private long nextWaitMillis(List<CollectionJob> running, Instant 
passDeadline) {
+        Instant nextDeadline = passDeadline;
+        for (CollectionJob job : running) {
+            if (job.deadline().isBefore(nextDeadline)) {
+                nextDeadline = job.deadline();
+            }
+        }
+        return Math.max(1, Duration.between(Instant.now(), 
nextDeadline).toMillis());
     }
 
     @Scheduled(fixedDelayString = 
"${studio.alerting.snapshot-cleanup-interval:PT1H}")
@@ -130,7 +235,10 @@ public class CollectorScheduler {
     private void collectClusterMetrics(InstanceVO instance) {
         for (ClusterMetricsCollector collector : clusterCollectors) {
             try {
-                persist(collector.supports(instance) ? 
collector.collect(instance) : List.of());
+                if (collector.supports(instance)) {
+                    List<MetricSample> samples = collector.collect(instance);
+                    persist(AlertDomain.CLUSTER, instance, 
collector.metricKeys(), samples);
+                }
             } catch (RuntimeException error) {
                 log.warn("Native metric collector failed for instance {}: {}", 
instance.getName(), error.getMessage());
             }
@@ -140,23 +248,40 @@ public class CollectorScheduler {
     private void collectBusinessMetrics(InstanceVO instance) {
         for (BusinessMetricsCollector collector : businessCollectors) {
             try {
-                persist(collector.supports(instance) ? 
collector.collect(instance) : List.of());
+                if (collector.supports(instance)) {
+                    List<MetricSample> samples = collector.collect(instance);
+                    persist(AlertDomain.BUSINESS, instance, 
collector.metricKeys(), samples);
+                }
             } catch (RuntimeException error) {
                 log.warn("Native metric collector failed for instance {}: {}", 
instance.getName(), error.getMessage());
             }
         }
     }
 
-    private void persist(List<MetricSample> samples) {
-        if (!samples.isEmpty()) {
-            // Refresh immediately before persisting so a slow remote 
collection cannot write after lease loss.
-            if (!collectionLease.tryAcquire()) {
-                log.debug("Discarding native metric samples because the 
collection lease was lost");
-                return;
-            }
-            snapshotRepository.saveAll(samples);
-            alertProcessor.process(samples);
+    private void persist(AlertDomain domain, InstanceVO instance, Set<String> 
declaredMetricKeys,
+            List<MetricSample> samples) {
+        List<MetricSample> collected = samples == null ? List.of() : samples;
+        Set<String> scopeMetricKeys = metricKeys(declaredMetricKeys, 
collected);
+        if (scopeMetricKeys.isEmpty()) {
+            return;
+        }
+        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()) {
+            log.debug("Discarding native metric samples because the collection 
lease was lost");
+            return;
         }
+        if (!collected.isEmpty()) {
+            snapshotRepository.saveAll(collected);
+        }
+        alertProcessor.processSuccessfulCollection(actualScope, collected);
+    }
+
+    private static Set<String> metricKeys(Set<String> declared, 
List<MetricSample> samples) {
+        if (declared != null && !declared.isEmpty()) {
+            return declared;
+        }
+        return 
samples.stream().map(MetricSample::metricKey).collect(java.util.stream.Collectors.toSet());
     }
 
     @PreDestroy
@@ -175,6 +300,9 @@ public class CollectorScheduler {
                 new ArrayBlockingQueue<>(parallelism), threadFactory, new 
ThreadPoolExecutor.AbortPolicy());
     }
 
+    private record CollectionJob(InstanceVO instance, Future<InstanceVO> 
future, Instant deadline) {
+    }
+
     private static Duration parsePositiveDuration(String value, Duration 
fallback) {
         try {
             Duration duration = Duration.parse(value);
diff --git 
a/server/src/main/java/org/apache/rocketmq/studio/cluster/metrics/MetricCollectionScope.java
 
b/server/src/main/java/org/apache/rocketmq/studio/cluster/metrics/MetricCollectionScope.java
new file mode 100644
index 000000000..a3d49b165
--- /dev/null
+++ 
b/server/src/main/java/org/apache/rocketmq/studio/cluster/metrics/MetricCollectionScope.java
@@ -0,0 +1,41 @@
+/*
+ * 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.ops.alert.AlertDomain;
+
+import java.util.Objects;
+import java.util.Set;
+
+/** Identifies one successful native metric collection scope. */
+public record MetricCollectionScope(AlertDomain domain, String instanceId, 
Set<String> metricKeys) {
+    public MetricCollectionScope {
+        Objects.requireNonNull(domain, "domain is required");
+        if (instanceId == null || instanceId.isBlank()) {
+            throw new IllegalArgumentException("instanceId is required");
+        }
+        metricKeys = Set.copyOf(metricKeys == null ? Set.of() : metricKeys);
+        if (metricKeys.isEmpty() || metricKeys.stream().anyMatch(key -> key == 
null || key.isBlank())) {
+            throw new IllegalArgumentException("metricKeys must not be empty");
+        }
+    }
+
+    public boolean contains(MetricSample sample) {
+        return sample != null && domain == sample.domain() && 
instanceId.equals(sample.instanceId())
+                && metricKeys.contains(sample.metricKey());
+    }
+}
diff --git 
a/server/src/main/java/org/apache/rocketmq/studio/cluster/metrics/collectors/ApacheRocketMqBusinessMetricsCollector.java
 
b/server/src/main/java/org/apache/rocketmq/studio/cluster/metrics/collectors/ApacheRocketMqBusinessMetricsCollector.java
index dfeb918a5..e4cfe9af9 100644
--- 
a/server/src/main/java/org/apache/rocketmq/studio/cluster/metrics/collectors/ApacheRocketMqBusinessMetricsCollector.java
+++ 
b/server/src/main/java/org/apache/rocketmq/studio/cluster/metrics/collectors/ApacheRocketMqBusinessMetricsCollector.java
@@ -34,6 +34,7 @@ import java.time.Instant;
 import java.util.ArrayList;
 import java.util.List;
 import java.util.Map;
+import java.util.Set;
 
 /** Collects consumer-group lag directly from Studio's Apache instance 
provider. */
 @Slf4j
@@ -53,6 +54,11 @@ public class ApacheRocketMqBusinessMetricsCollector 
implements BusinessMetricsCo
                 && instance.getName() != null;
     }
 
+    @Override
+    public Set<String> metricKeys() {
+        return Set.of(CONSUMER_LAG_TOTAL, CONSUMER_LAG_MAX_QUEUE, 
CONSUMER_DELAY_SECONDS, TOPIC_BACKLOG_TOTAL);
+    }
+
     @Override
     public List<MetricSample> collect(InstanceVO instance) {
         if (!supports(instance)) {
diff --git 
a/server/src/main/java/org/apache/rocketmq/studio/cluster/metrics/collectors/ApacheRocketMqClusterMetricsCollector.java
 
b/server/src/main/java/org/apache/rocketmq/studio/cluster/metrics/collectors/ApacheRocketMqClusterMetricsCollector.java
index 27580f6ab..df1435be8 100644
--- 
a/server/src/main/java/org/apache/rocketmq/studio/cluster/metrics/collectors/ApacheRocketMqClusterMetricsCollector.java
+++ 
b/server/src/main/java/org/apache/rocketmq/studio/cluster/metrics/collectors/ApacheRocketMqClusterMetricsCollector.java
@@ -36,6 +36,7 @@ import java.time.Instant;
 import java.util.ArrayList;
 import java.util.List;
 import java.util.Map;
+import java.util.Set;
 
 /** Native Apache RocketMQ health collection through Studio's managed admin 
client. */
 @Slf4j
@@ -57,6 +58,12 @@ public class ApacheRocketMqClusterMetricsCollector 
implements ClusterMetricsColl
                 && StringUtils.hasText(instance.getName()) && 
StringUtils.hasText(instance.getEndpoint());
     }
 
+    @Override
+    public Set<String> metricKeys() {
+        return Set.of(NAMESERVER_AVAILABILITY, BROKER_AVAILABILITY, 
BROKER_DISK_USAGE_RATIO,
+                BROKER_JVM_HEAP_USAGE_RATIO, BROKER_SEND_QUEUE_USAGE_RATIO);
+    }
+
     @Override
     public List<MetricSample> collect(InstanceVO instance) {
         if (!supports(instance)) {
diff --git 
a/server/src/main/java/org/apache/rocketmq/studio/cluster/metrics/collectors/ApacheRocketMqDlqMetricsCollector.java
 
b/server/src/main/java/org/apache/rocketmq/studio/cluster/metrics/collectors/ApacheRocketMqDlqMetricsCollector.java
index 6ef6fdfc8..d8002771b 100644
--- 
a/server/src/main/java/org/apache/rocketmq/studio/cluster/metrics/collectors/ApacheRocketMqDlqMetricsCollector.java
+++ 
b/server/src/main/java/org/apache/rocketmq/studio/cluster/metrics/collectors/ApacheRocketMqDlqMetricsCollector.java
@@ -31,6 +31,7 @@ import org.springframework.stereotype.Component;
 import java.time.Instant;
 import java.util.List;
 import java.util.Map;
+import java.util.Set;
 
 /** Collects dead-letter message counts directly from the Apache DLQ provider. 
*/
 @Slf4j
@@ -47,6 +48,11 @@ public class ApacheRocketMqDlqMetricsCollector implements 
BusinessMetricsCollect
                 && instance.getName() != null;
     }
 
+    @Override
+    public Set<String> metricKeys() {
+        return Set.of(DLQ_MESSAGE_COUNT);
+    }
+
     @Override
     public List<MetricSample> collect(InstanceVO instance) {
         if (!supports(instance)) {
diff --git 
a/server/src/main/java/org/apache/rocketmq/studio/cluster/metrics/collectors/ApacheRocketMqProxyMetricsCollector.java
 
b/server/src/main/java/org/apache/rocketmq/studio/cluster/metrics/collectors/ApacheRocketMqProxyMetricsCollector.java
index 15293254a..2a384ab74 100644
--- 
a/server/src/main/java/org/apache/rocketmq/studio/cluster/metrics/collectors/ApacheRocketMqProxyMetricsCollector.java
+++ 
b/server/src/main/java/org/apache/rocketmq/studio/cluster/metrics/collectors/ApacheRocketMqProxyMetricsCollector.java
@@ -35,6 +35,7 @@ import java.time.Instant;
 import java.util.ArrayList;
 import java.util.List;
 import java.util.Map;
+import java.util.Set;
 
 /** Collects TCP reachability for Proxies discovered from the selected Studio 
instance. */
 @Slf4j
@@ -54,6 +55,11 @@ public class ApacheRocketMqProxyMetricsCollector implements 
ClusterMetricsCollec
                 && StringUtils.hasText(instance.getName()) && 
StringUtils.hasText(instance.getEndpoint());
     }
 
+    @Override
+    public Set<String> metricKeys() {
+        return Set.of(PROXY_AVAILABILITY);
+    }
+
     @Override
     public List<MetricSample> collect(InstanceVO instance) {
         if (!supports(instance)) {
diff --git 
a/server/src/main/java/org/apache/rocketmq/studio/cluster/metrics/collectors/CloudRocketMqBusinessMetricsCollector.java
 
b/server/src/main/java/org/apache/rocketmq/studio/cluster/metrics/collectors/CloudRocketMqBusinessMetricsCollector.java
index 875208dc5..4cc1d6161 100644
--- 
a/server/src/main/java/org/apache/rocketmq/studio/cluster/metrics/collectors/CloudRocketMqBusinessMetricsCollector.java
+++ 
b/server/src/main/java/org/apache/rocketmq/studio/cluster/metrics/collectors/CloudRocketMqBusinessMetricsCollector.java
@@ -34,6 +34,7 @@ import java.time.Instant;
 import java.util.ArrayList;
 import java.util.List;
 import java.util.Map;
+import java.util.Set;
 
 /** Collects cloud consumer lag using the provider's existing consumer 
progress API. */
 @Slf4j
@@ -52,6 +53,11 @@ public class CloudRocketMqBusinessMetricsCollector 
implements BusinessMetricsCol
                 || instance.getVendor() == InstanceVendor.TENCENT) && 
instance.getName() != null;
     }
 
+    @Override
+    public Set<String> metricKeys() {
+        return Set.of(CONSUMER_LAG_TOTAL, CONSUMER_LAG_MAX_QUEUE, 
TOPIC_BACKLOG_TOTAL);
+    }
+
     @Override
     public List<MetricSample> collect(InstanceVO instance) {
         if (!supports(instance)) {
diff --git 
a/server/src/main/java/org/apache/rocketmq/studio/cluster/metrics/collectors/CloudRocketMqClusterMetricsCollector.java
 
b/server/src/main/java/org/apache/rocketmq/studio/cluster/metrics/collectors/CloudRocketMqClusterMetricsCollector.java
index ddbec51d7..92b91b1b0 100644
--- 
a/server/src/main/java/org/apache/rocketmq/studio/cluster/metrics/collectors/CloudRocketMqClusterMetricsCollector.java
+++ 
b/server/src/main/java/org/apache/rocketmq/studio/cluster/metrics/collectors/CloudRocketMqClusterMetricsCollector.java
@@ -31,6 +31,7 @@ import org.springframework.util.StringUtils;
 
 import java.time.Instant;
 import java.util.Map;
+import java.util.Set;
 
 /** Collects the cloud provider's managed-instance lifecycle status for Aliyun 
and Tencent. */
 @Slf4j
@@ -49,6 +50,11 @@ public class CloudRocketMqClusterMetricsCollector implements 
ClusterMetricsColle
                 && StringUtils.hasText(instance.getCloudInstanceId());
     }
 
+    @Override
+    public Set<String> metricKeys() {
+        return Set.of(CLOUD_INSTANCE_AVAILABILITY);
+    }
+
     @Override
     public java.util.List<MetricSample> collect(InstanceVO instance) {
         if (!supports(instance)) {
diff --git 
a/server/src/main/java/org/apache/rocketmq/studio/cluster/metrics/BusinessMetricsCollector.java
 
b/server/src/main/java/org/apache/rocketmq/studio/ops/alert/ActiveAlertState.java
similarity index 55%
copy from 
server/src/main/java/org/apache/rocketmq/studio/cluster/metrics/BusinessMetricsCollector.java
copy to 
server/src/main/java/org/apache/rocketmq/studio/ops/alert/ActiveAlertState.java
index 327b2dc63..acafc73aa 100644
--- 
a/server/src/main/java/org/apache/rocketmq/studio/cluster/metrics/BusinessMetricsCollector.java
+++ 
b/server/src/main/java/org/apache/rocketmq/studio/ops/alert/ActiveAlertState.java
@@ -14,15 +14,19 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-package org.apache.rocketmq.studio.cluster.metrics;
+package org.apache.rocketmq.studio.ops.alert;
 
-import org.apache.rocketmq.studio.instance.InstanceVO;
+import java.util.Map;
+import java.util.Objects;
 
-import java.util.List;
-
-/** Collects business-flow metrics for one Studio-managed instance. */
-public interface BusinessMetricsCollector {
-    boolean supports(InstanceVO instance);
-
-    List<MetricSample> collect(InstanceVO instance);
+/** Persisted active state plus labels needed to emit a lifecycle recovery 
event. */
+public record ActiveAlertState(AlertStateKey key, AlertRuleState state, String 
instanceId, Map<String, String> labels) {
+    public ActiveAlertState {
+        Objects.requireNonNull(key, "key is required");
+        Objects.requireNonNull(state, "state is required");
+        if (instanceId == null || instanceId.isBlank()) {
+            throw new IllegalArgumentException("instanceId is required");
+        }
+        labels = Map.copyOf(labels == null ? Map.of() : labels);
+    }
 }
diff --git 
a/server/src/main/java/org/apache/rocketmq/studio/ops/alert/AlertStateRepository.java
 
b/server/src/main/java/org/apache/rocketmq/studio/ops/alert/AlertStateRepository.java
index f33674d11..8f5607859 100644
--- 
a/server/src/main/java/org/apache/rocketmq/studio/ops/alert/AlertStateRepository.java
+++ 
b/server/src/main/java/org/apache/rocketmq/studio/ops/alert/AlertStateRepository.java
@@ -16,6 +16,8 @@
  */
 package org.apache.rocketmq.studio.ops.alert;
 
+import org.apache.rocketmq.studio.cluster.metrics.MetricCollectionScope;
+
 import java.time.Instant;
 import java.util.Optional;
 import java.util.List;
@@ -37,6 +39,10 @@ public interface AlertStateRepository {
 
     void deleteByRuleId(Long ruleId);
 
+    default List<ActiveAlertState> findActive(MetricCollectionScope scope, 
List<AlertRuleVO> rules) {
+        return List.of();
+    }
+
     default List<AlertRuleRuntimeVO> findRuntimeByRuleIds(List<AlertRuleVO> 
rules) {
         return List.of();
     }
diff --git 
a/server/src/main/java/org/apache/rocketmq/studio/ops/alert/MybatisPlusAlertStateRepository.java
 
b/server/src/main/java/org/apache/rocketmq/studio/ops/alert/MybatisPlusAlertStateRepository.java
index aa0d85262..b71d44628 100644
--- 
a/server/src/main/java/org/apache/rocketmq/studio/ops/alert/MybatisPlusAlertStateRepository.java
+++ 
b/server/src/main/java/org/apache/rocketmq/studio/ops/alert/MybatisPlusAlertStateRepository.java
@@ -17,24 +17,35 @@
 package org.apache.rocketmq.studio.ops.alert;
 
 import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
+import com.fasterxml.jackson.core.type.TypeReference;
+import com.fasterxml.jackson.databind.ObjectMapper;
 import lombok.RequiredArgsConstructor;
+import org.apache.rocketmq.studio.cluster.metrics.MetricCollectionScope;
 import org.apache.rocketmq.studio.persistence.entity.RmqAlertState;
+import org.apache.rocketmq.studio.persistence.entity.RmqSystemAlert;
 import org.apache.rocketmq.studio.persistence.mapper.RmqAlertStateMapper;
+import org.apache.rocketmq.studio.persistence.mapper.RmqSystemAlertMapper;
 import org.springframework.dao.DuplicateKeyException;
 import org.springframework.stereotype.Repository;
+import org.springframework.util.StringUtils;
 
 import java.time.Instant;
 import java.time.LocalDateTime;
 import java.time.ZoneOffset;
 import java.util.Optional;
+import java.util.HashSet;
 import java.util.List;
 import java.util.Map;
+import java.util.Set;
 import java.util.stream.Collectors;
 
 @Repository
 @RequiredArgsConstructor
 public class MybatisPlusAlertStateRepository implements AlertStateRepository {
+    private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();
+
     private final RmqAlertStateMapper mapper;
+    private final RmqSystemAlertMapper alertMapper;
 
     @Override
     public Optional<AlertRuleState> find(AlertStateKey key) {
@@ -82,6 +93,35 @@ public class MybatisPlusAlertStateRepository implements 
AlertStateRepository {
         }
     }
 
+    @Override
+    public List<ActiveAlertState> findActive(MetricCollectionScope scope, 
List<AlertRuleVO> rules) {
+        if (scope == null || rules == null || rules.isEmpty()) {
+            return List.of();
+        }
+        Set<String> metricKeys = scope.metricKeys();
+        Map<Long, AlertRuleVO> scopedRules = rules.stream()
+                .filter(rule -> rule.getId() != null)
+                .filter(AlertRuleVO::isEnabled)
+                .filter(rule -> ruleDomain(rule) == scope.domain())
+                .filter(rule -> metricKeys.contains(rule.getMetric()))
+                .filter(rule -> !StringUtils.hasText(rule.getInstanceId())
+                        || scope.instanceId().equals(rule.getInstanceId()))
+                .collect(Collectors.toMap(AlertRuleVO::getId, rule -> rule, 
(left, right) -> left));
+        if (scopedRules.isEmpty()) {
+            return List.of();
+        }
+        List<RmqAlertState> activeStates = mapper.selectList(new 
QueryWrapper<RmqAlertState>()
+                        .in("rule_id", scopedRules.keySet())
+                        .in("status", List.of(AlertStateStatus.FIRING.name(), 
AlertStateStatus.ACKED.name())));
+        Map<AlertStateKey, RmqSystemAlert> latestAlerts = 
findLatestAlerts(scope, activeStates);
+        return activeStates
+                .stream()
+                .map(state -> toActiveState(state, latestAlerts.get(new 
AlertStateKey(state.getRuleId(),
+                        state.getFingerprint()))))
+                .flatMap(Optional::stream)
+                .toList();
+    }
+
     @Override
     public List<AlertRuleRuntimeVO> findRuntimeByRuleIds(List<AlertRuleVO> 
rules) {
         Map<Long, AlertRuleVO> byId = rules.stream().filter(rule -> 
rule.getId() != null)
@@ -99,6 +139,40 @@ public class MybatisPlusAlertStateRepository implements 
AlertStateRepository {
                 
.currentValue(entity.getCurrentValue()).lastNotifiedAt(entity.getLastNotifiedAt()).nextReminderAt(next).build();
     }
 
+    private Map<AlertStateKey, RmqSystemAlert> 
findLatestAlerts(MetricCollectionScope scope,
+            List<RmqAlertState> activeStates) {
+        if (activeStates.isEmpty()) {
+            return Map.of();
+        }
+        Set<Long> ruleIds = 
activeStates.stream().map(RmqAlertState::getRuleId).collect(Collectors.toSet());
+        Set<String> fingerprints = 
activeStates.stream().map(RmqAlertState::getFingerprint).collect(Collectors.toSet());
+        Set<AlertStateKey> activeKeys = activeStates.stream()
+                .map(state -> new AlertStateKey(state.getRuleId(), 
state.getFingerprint()))
+                .collect(Collectors.toCollection(HashSet::new));
+        return alertMapper.selectList(new QueryWrapper<RmqSystemAlert>()
+                        .in("rule_id", ruleIds)
+                        .in("fingerprint", fingerprints)
+                        .eq("domain", scope.domain().name())
+                        .eq("instance_id", scope.instanceId())
+                        .orderByDesc("time", "id"))
+                .stream()
+                .filter(alert -> activeKeys.contains(new 
AlertStateKey(alert.getRuleId(), alert.getFingerprint())))
+                .collect(Collectors.toMap(alert -> new 
AlertStateKey(alert.getRuleId(), alert.getFingerprint()),
+                        alert -> alert, (latest, ignored) -> latest));
+    }
+
+    private Optional<ActiveAlertState> toActiveState(RmqAlertState state, 
RmqSystemAlert alert) {
+        if (alert == null) {
+            return Optional.empty();
+        }
+        return Optional.of(new ActiveAlertState(new 
AlertStateKey(state.getRuleId(), state.getFingerprint()),
+                toState(state), alert.getInstanceId(), 
readLabels(alert.getLabelsJson())));
+    }
+
+    private static AlertDomain ruleDomain(AlertRuleVO rule) {
+        return rule.getDomain() == null ? AlertDomain.BUSINESS : 
rule.getDomain();
+    }
+
     private static void apply(RmqAlertState entity, AlertRuleState state) {
         entity.setStatus(state.status().name());
         entity.setConsecutiveHits(state.consecutiveHits());
@@ -124,4 +198,15 @@ public class MybatisPlusAlertStateRepository implements 
AlertStateRepository {
     private static Instant toInstant(LocalDateTime value) {
         return value == null ? null : value.toInstant(ZoneOffset.UTC);
     }
+
+    private static Map<String, String> readLabels(String labelsJson) {
+        if (!StringUtils.hasText(labelsJson)) {
+            return Map.of();
+        }
+        try {
+            return OBJECT_MAPPER.readValue(labelsJson, new TypeReference<>() { 
});
+        } catch (Exception error) {
+            throw new IllegalStateException("Unable to read alert labels", 
error);
+        }
+    }
 }
diff --git 
a/server/src/main/java/org/apache/rocketmq/studio/ops/alert/NativeAlertProcessor.java
 
b/server/src/main/java/org/apache/rocketmq/studio/ops/alert/NativeAlertProcessor.java
index b4559a533..f75560654 100644
--- 
a/server/src/main/java/org/apache/rocketmq/studio/ops/alert/NativeAlertProcessor.java
+++ 
b/server/src/main/java/org/apache/rocketmq/studio/ops/alert/NativeAlertProcessor.java
@@ -20,19 +20,24 @@ import lombok.RequiredArgsConstructor;
 import org.apache.rocketmq.studio.cluster.metrics.MetricSample;
 import org.apache.rocketmq.studio.cluster.metrics.MetricSnapshotRepository;
 import org.apache.rocketmq.studio.cluster.metrics.MetricAvailability;
+import org.apache.rocketmq.studio.cluster.metrics.MetricCollectionScope;
 import org.apache.rocketmq.studio.common.domain.enums.AlertLevel;
 import org.springframework.stereotype.Component;
 import org.springframework.transaction.annotation.Transactional;
 
 import java.time.LocalDateTime;
 import java.time.Duration;
+import java.time.Instant;
 import java.time.ZoneOffset;
 import java.util.List;
 import java.util.Locale;
 import java.util.Map;
 import java.util.Optional;
+import java.util.Set;
 import java.util.TreeMap;
 import java.util.EnumMap;
+import java.util.Objects;
+import java.util.stream.Collectors;
 
 /** Applies native samples to persisted rule state and emits only lifecycle 
transitions. */
 @Component
@@ -49,6 +54,27 @@ public class NativeAlertProcessor {
 
     @Transactional
     public void process(List<MetricSample> samples) {
+        processSamples(samples);
+    }
+
+    @Transactional
+    public void processSuccessfulCollection(MetricCollectionScope scope, 
List<MetricSample> samples) {
+        Objects.requireNonNull(scope, "scope is required");
+        List<MetricSample> collected = samples == null ? List.of() : samples;
+        processSamples(collected);
+        if (containsWholeScopeFailure(scope, collected)) {
+            return;
+        }
+        reconcileMissingActiveStates(scope, collected);
+    }
+
+    private static boolean containsWholeScopeFailure(MetricCollectionScope 
scope, List<MetricSample> samples) {
+        return samples.stream().filter(scope::contains)
+                .anyMatch(sample -> sample.availability() != 
MetricAvailability.AVAILABLE
+                        && sample.labels().isEmpty());
+    }
+
+    private void processSamples(List<MetricSample> samples) {
         Map<AlertDomain, List<AlertRuleVO>> rulesByDomain = new 
EnumMap<>(AlertDomain.class);
         for (MetricSample sample : samples) {
             for (AlertRuleVO rule : 
rulesByDomain.computeIfAbsent(sample.domain(), alertService::listRules)) {
@@ -76,39 +102,87 @@ public class NativeAlertProcessor {
                 }
                 if (update.transition() == AlertStateTransition.FIRING || 
update.transition() == AlertStateTransition.REMINDER
                         || update.transition() == 
AlertStateTransition.RESOLVED) {
-                    LocalDateTime eventTime = 
LocalDateTime.ofInstant(sample.collectedAt(), ZoneOffset.UTC);
-                    SystemAlertVO event = 
SystemAlertVO.builder().level(level(rule.getSeverity()))
-                            
.title(rule.getName()).description(update.transition() + " " + 
sample.metricKey()
-                                    + " on " + 
sample.instanceId()).time(eventTime)
-                            
.acknowledged(false).domain(sample.domain()).ruleId(rule.getId())
-                            
.fingerprint(key.fingerprint()).transition(update.transition().name())
-                            
.instanceId(sample.instanceId()).currentValue(update.state().currentValue())
-                            .labels(Map.copyOf(new 
TreeMap<>(sample.labels()))).build();
-                    boolean suppressNotification = 
shouldSuppress(sample.domain(), update.transition());
-                    if (suppressNotification) {
-                        Optional<SystemAlertVO> cause = 
notificationSuppressionService.findSuppressingClusterAlert(event);
-                        if (cause.isPresent()) {
-                            event.setNotificationSuppressed(true);
-                            
event.setSuppressionCauseAlertId(cause.get().getId());
-                            event.setSuppressionReason("Suppressed by active 
cluster incident #" + cause.get().getId()
-                                    + ": " + cause.get().getTitle());
-                        }
-                    }
-                    SystemAlertVO savedEvent = 
alertRepository.saveAlert(event);
-                    if (savedEvent != null) {
-                        event = savedEvent;
-                    }
-                    if (update.transition() == AlertStateTransition.FIRING) {
-                        alertRepository.markRuleTriggered(rule.getId(), 
eventTime.toString());
-                    }
-                    if (!event.isNotificationSuppressed()) {
-                        notificationOutboxService.enqueue(event, rule, 
sample.labels());
-                    }
+                    emitLifecycleEvent(rule, key, update, sample.domain(), 
sample.instanceId(), sample.metricKey(),
+                            sample.labels(), sample.collectedAt());
                 }
             }
         }
     }
 
+    private void reconcileMissingActiveStates(MetricCollectionScope scope, 
List<MetricSample> samples) {
+        List<AlertRuleVO> rules = 
alertService.listRules(scope.domain()).stream()
+                .filter(rule -> rule.getId() != null)
+                .filter(AlertRuleVO::isEnabled)
+                .filter(rule -> scope.metricKeys().contains(rule.getMetric()))
+                .filter(rule -> rule.getInstanceId() == null || 
scope.instanceId().equals(rule.getInstanceId()))
+                .toList();
+        if (rules.isEmpty()) {
+            return;
+        }
+        Set<AlertStateKey> presentKeys = samples.stream()
+                .filter(scope::contains)
+                .flatMap(sample -> rules.stream()
+                        .filter(rule -> 
NativeAlertRuleScopeMatcher.matches(rule, sample))
+                        .map(rule -> new AlertStateKey(rule.getId(),
+                                AlertFingerprint.of(rule.getId(), 
sample.instanceId(), sample.labels()))))
+                .collect(Collectors.toSet());
+        Map<Long, AlertRuleVO> byId = 
rules.stream().collect(Collectors.toMap(AlertRuleVO::getId, rule -> rule));
+        Instant resolvedAt = 
samples.stream().filter(scope::contains).map(MetricSample::collectedAt).max(Instant::compareTo)
+                .orElseGet(Instant::now);
+        AlertEvaluationResult clear = new AlertEvaluationResult(true, false, 
null, MetricAvailability.AVAILABLE);
+        for (ActiveAlertState active : stateRepository.findActive(scope, 
rules)) {
+            if (presentKeys.contains(active.key())) {
+                continue;
+            }
+            AlertRuleVO rule = byId.get(active.key().ruleId());
+            if (rule == null) {
+                continue;
+            }
+            AlertStateUpdate update = stateMachine.advance(active.state(), 
clear,
+                    Math.max(1, rule.getConsecutiveSamples()), 
AlertRuleDuration.parse(rule.getDuration()),
+                    AlertRuleDuration.parse(rule.getReminderInterval()), 
resolvedAt);
+            if (update.transition() != AlertStateTransition.RESOLVED) {
+                continue;
+            }
+            if (!stateRepository.save(active.key(), update.state())) {
+                continue;
+            }
+            emitLifecycleEvent(rule, active.key(), update, scope.domain(), 
active.instanceId(), rule.getMetric(),
+                    active.labels(), resolvedAt);
+        }
+    }
+
+    private void emitLifecycleEvent(AlertRuleVO rule, AlertStateKey key, 
AlertStateUpdate update, AlertDomain domain,
+            String instanceId, String metricKey, Map<String, String> labels, 
Instant collectedAt) {
+        LocalDateTime eventTime = LocalDateTime.ofInstant(collectedAt, 
ZoneOffset.UTC);
+        Map<String, String> eventLabels = Map.copyOf(new TreeMap<>(labels == 
null ? Map.of() : labels));
+        SystemAlertVO event = 
SystemAlertVO.builder().level(level(rule.getSeverity()))
+                .title(rule.getName()).description(update.transition() + " " + 
metricKey + " on " + instanceId)
+                
.time(eventTime).acknowledged(false).domain(domain).ruleId(rule.getId())
+                
.fingerprint(key.fingerprint()).transition(update.transition().name()).instanceId(instanceId)
+                
.currentValue(update.state().currentValue()).labels(eventLabels).build();
+        boolean suppressNotification = shouldSuppress(domain, 
update.transition());
+        if (suppressNotification) {
+            Optional<SystemAlertVO> cause = 
notificationSuppressionService.findSuppressingClusterAlert(event);
+            if (cause.isPresent()) {
+                event.setNotificationSuppressed(true);
+                event.setSuppressionCauseAlertId(cause.get().getId());
+                event.setSuppressionReason("Suppressed by active cluster 
incident #" + cause.get().getId()
+                        + ": " + cause.get().getTitle());
+            }
+        }
+        SystemAlertVO savedEvent = alertRepository.saveAlert(event);
+        if (savedEvent != null) {
+            event = savedEvent;
+        }
+        if (update.transition() == AlertStateTransition.FIRING) {
+            alertRepository.markRuleTriggered(rule.getId(), 
eventTime.toString());
+        }
+        if (!event.isNotificationSuppressed()) {
+            notificationOutboxService.enqueue(event, rule, eventLabels);
+        }
+    }
+
     private static boolean shouldSuppress(AlertDomain domain, 
AlertStateTransition transition) {
         return domain == AlertDomain.BUSINESS
                 && (transition == AlertStateTransition.FIRING || transition == 
AlertStateTransition.REMINDER);
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 ea4c8fdad..ecf956412 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
@@ -23,12 +23,18 @@ import 
org.apache.rocketmq.studio.ops.alert.NativeAlertProcessor;
 import org.junit.jupiter.api.Test;
 
 import java.time.Instant;
+import java.util.ArrayList;
 import java.util.List;
+import java.util.concurrent.CopyOnWriteArrayList;
 import java.util.concurrent.CountDownLatch;
 import java.util.concurrent.ExecutorService;
 import java.util.concurrent.Executors;
+import java.util.concurrent.LinkedBlockingQueue;
 import java.util.concurrent.TimeUnit;
+import java.util.concurrent.ThreadPoolExecutor;
+import java.util.concurrent.atomic.AtomicInteger;
 
+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;
@@ -38,6 +44,103 @@ import static org.mockito.Mockito.when;
 
 class CollectorSchedulerTest {
 
+    @Test
+    void collectsAllInstancesWhenInventoryExceedsExecutorCapacityTest() throws 
Exception {
+        AlertingProperties properties = new AlertingProperties();
+        properties.setCollectionParallelism(2);
+        properties.setCollectionTimeout("PT2S");
+        InstanceRepository instances = mock(InstanceRepository.class);
+        List<InstanceVO> inventory = new ArrayList<>();
+        for (int index = 0; index < 5; index++) {
+            inventory.add(InstanceVO.builder().name("instance-" + 
index).endpoint("instance-" + index + ":9876").build());
+        }
+        when(instances.findAll()).thenReturn(inventory);
+
+        CountDownLatch firstWaveStarted = new CountDownLatch(2);
+        CountDownLatch releaseFirstWave = new CountDownLatch(1);
+        AtomicInteger started = new AtomicInteger();
+        ClusterMetricsCollector collector = 
mock(ClusterMetricsCollector.class);
+        for (InstanceVO instance : inventory) {
+            when(collector.supports(instance)).thenReturn(true);
+            when(collector.collect(instance)).thenAnswer(invocation -> {
+                if (started.incrementAndGet() <= 2) {
+                    firstWaveStarted.countDown();
+                    assertTrue(releaseFirstWave.await(1, TimeUnit.SECONDS), 
"test did not release the first wave");
+                }
+                return List.of(sampleFor(instance));
+            });
+        }
+
+        List<MetricSample> persisted = new CopyOnWriteArrayList<>();
+        MetricSnapshotRepository snapshots = 
mock(MetricSnapshotRepository.class);
+        org.mockito.Mockito.doAnswer(invocation -> {
+            persisted.addAll(invocation.getArgument(0));
+            return null;
+        }).when(snapshots).saveAll(any());
+        AlertCollectionLease lease = mock(AlertCollectionLease.class);
+        when(lease.tryAcquire()).thenReturn(true);
+        CollectorScheduler scheduler = new CollectorScheduler(properties, 
instances, List.of(collector), List.of(), snapshots,
+                mock(NativeAlertProcessor.class), lease);
+
+        Thread pass = new Thread(scheduler::collect);
+        pass.start();
+        assertTrue(firstWaveStarted.await(1, TimeUnit.SECONDS), "first wave 
should occupy the collector executor");
+        TimeUnit.MILLISECONDS.sleep(100);
+        releaseFirstWave.countDown();
+        pass.join(TimeUnit.SECONDS.toMillis(3));
+        scheduler.stopCollectionExecutor();
+
+        assertTrue(!pass.isAlive(), "collection pass should finish");
+        assertEquals(inventory.size(), persisted.size(), "collector saturation 
must not drop later instances");
+        
assertEquals(inventory.stream().map(InstanceVO::getName).sorted().toList(),
+                
persisted.stream().map(MetricSample::instanceId).sorted().toList());
+    }
+
+    @Test
+    void doesNotQueueMoreThanParallelismBeforeAnyCollectionCompletesTest() 
throws Exception {
+        AlertingProperties properties = new AlertingProperties();
+        properties.setCollectionParallelism(2);
+        properties.setCollectionTimeout("PT2S");
+        InstanceRepository instances = mock(InstanceRepository.class);
+        List<InstanceVO> inventory = new ArrayList<>();
+        for (int index = 0; index < 5; index++) {
+            inventory.add(InstanceVO.builder().name("window-" + 
index).endpoint("window-" + index + ":9876").build());
+        }
+        when(instances.findAll()).thenReturn(inventory);
+
+        CountDownLatch firstWaveStarted = new CountDownLatch(2);
+        CountDownLatch releaseFirstWave = new CountDownLatch(1);
+        AtomicInteger started = new AtomicInteger();
+        ClusterMetricsCollector collector = 
mock(ClusterMetricsCollector.class);
+        for (InstanceVO instance : inventory) {
+            when(collector.supports(instance)).thenReturn(true);
+            when(collector.collect(instance)).thenAnswer(invocation -> {
+                if (started.incrementAndGet() <= 2) {
+                    firstWaveStarted.countDown();
+                    assertTrue(releaseFirstWave.await(1, TimeUnit.SECONDS), 
"test did not release the first wave");
+                }
+                return List.of(sampleFor(instance));
+            });
+        }
+
+        LinkedBlockingQueue<Runnable> queue = new LinkedBlockingQueue<>();
+        ThreadPoolExecutor executor = new ThreadPoolExecutor(2, 2, 0, 
TimeUnit.MILLISECONDS, queue);
+        CollectorScheduler scheduler = new CollectorScheduler(properties, 
instances, List.of(collector), List.of(),
+                mock(MetricSnapshotRepository.class), 
mock(NativeAlertProcessor.class), acquiredLease(), executor);
+
+        Thread pass = new Thread(scheduler::collect);
+        pass.start();
+        assertTrue(firstWaveStarted.await(1, TimeUnit.SECONDS), "first wave 
should occupy the collector executor");
+        TimeUnit.MILLISECONDS.sleep(100);
+        int queuedBeforeAnyCompletion = queue.size();
+        releaseFirstWave.countDown();
+        pass.join(TimeUnit.SECONDS.toMillis(3));
+        scheduler.stopCollectionExecutor();
+
+        assertTrue(!pass.isAlive(), "collection pass should finish");
+        assertEquals(0, queuedBeforeAnyCompletion, "collector should submit 
the next instance only after one finishes");
+    }
+
     @Test
     void persistsFastInstanceWhileAnotherInstanceTimesOutTest() throws 
Exception {
         AlertingProperties properties = new AlertingProperties();
@@ -136,7 +239,7 @@ class CollectorSchedulerTest {
         new CollectorScheduler(properties, instances, List.of(collector), 
List.of(), snapshots, processor, lease).collect();
 
         verify(snapshots).saveAll(List.of(sample));
-        verify(processor).process(List.of(sample));
+        
verify(processor).processSuccessfulCollection(any(MetricCollectionScope.class), 
org.mockito.ArgumentMatchers.eq(List.of(sample)));
     }
 
     @Test
@@ -184,5 +287,58 @@ class CollectorSchedulerTest {
 
         verify(snapshots, never()).saveAll(any());
         verify(processor, never()).process(any());
+        verify(processor, 
never()).processSuccessfulCollection(any(MetricCollectionScope.class), any());
+    }
+
+    @Test
+    void doesNotReconcileCollectionScopeWhenCollectorFailsTest() {
+        AlertingProperties properties = new AlertingProperties();
+        InstanceRepository instances = mock(InstanceRepository.class);
+        ClusterMetricsCollector collector = 
mock(ClusterMetricsCollector.class);
+        MetricSnapshotRepository snapshots = 
mock(MetricSnapshotRepository.class);
+        NativeAlertProcessor processor = mock(NativeAlertProcessor.class);
+        AlertCollectionLease lease = mock(AlertCollectionLease.class);
+        InstanceVO instance = 
InstanceVO.builder().name("local").endpoint("localhost:9876").build();
+        when(instances.findAll()).thenReturn(List.of(instance));
+        when(collector.supports(instance)).thenReturn(true);
+        when(collector.collect(instance)).thenThrow(new 
IllegalStateException("collector failed"));
+        when(lease.tryAcquire()).thenReturn(true);
+
+        new CollectorScheduler(properties, instances, List.of(collector), 
List.of(), snapshots, processor, lease).collect();
+
+        verify(snapshots, never()).saveAll(any());
+        verify(processor, 
never()).processSuccessfulCollection(any(MetricCollectionScope.class), any());
+    }
+
+    @Test
+    void 
reconcilesSuccessfulEmptyCollectionWhenCollectorDeclaresMetricKeysTest() {
+        AlertingProperties properties = new AlertingProperties();
+        InstanceRepository instances = mock(InstanceRepository.class);
+        ClusterMetricsCollector collector = 
mock(ClusterMetricsCollector.class);
+        MetricSnapshotRepository snapshots = 
mock(MetricSnapshotRepository.class);
+        NativeAlertProcessor processor = mock(NativeAlertProcessor.class);
+        AlertCollectionLease lease = mock(AlertCollectionLease.class);
+        InstanceVO instance = 
InstanceVO.builder().name("local").endpoint("localhost:9876").build();
+        when(instances.findAll()).thenReturn(List.of(instance));
+        when(collector.supports(instance)).thenReturn(true);
+        
when(collector.metricKeys()).thenReturn(java.util.Set.of("broker.availability"));
+        when(collector.collect(instance)).thenReturn(List.of());
+        when(lease.tryAcquire()).thenReturn(true);
+
+        new CollectorScheduler(properties, instances, List.of(collector), 
List.of(), snapshots, processor, lease).collect();
+
+        verify(snapshots, never()).saveAll(any());
+        
verify(processor).processSuccessfulCollection(any(MetricCollectionScope.class), 
org.mockito.ArgumentMatchers.eq(List.of()));
+    }
+
+    private static MetricSample sampleFor(InstanceVO instance) {
+        return new MetricSample("nameserver.availability", 
AlertDomain.CLUSTER, instance.getName(), null,
+                null, 1D, MetricAvailability.AVAILABLE, Instant.now());
+    }
+
+    private static AlertCollectionLease acquiredLease() {
+        AlertCollectionLease lease = mock(AlertCollectionLease.class);
+        when(lease.tryAcquire()).thenReturn(true);
+        return lease;
     }
 }
diff --git 
a/server/src/test/java/org/apache/rocketmq/studio/ops/alert/MybatisPlusAlertStateRepositoryTest.java
 
b/server/src/test/java/org/apache/rocketmq/studio/ops/alert/MybatisPlusAlertStateRepositoryTest.java
index e7fd02f3d..4598d69b8 100644
--- 
a/server/src/test/java/org/apache/rocketmq/studio/ops/alert/MybatisPlusAlertStateRepositoryTest.java
+++ 
b/server/src/test/java/org/apache/rocketmq/studio/ops/alert/MybatisPlusAlertStateRepositoryTest.java
@@ -6,17 +6,26 @@
  */
 package org.apache.rocketmq.studio.ops.alert;
 
+import org.apache.rocketmq.studio.cluster.metrics.MetricCollectionScope;
 import org.apache.rocketmq.studio.persistence.entity.RmqAlertState;
+import org.apache.rocketmq.studio.persistence.entity.RmqSystemAlert;
 import org.apache.rocketmq.studio.persistence.mapper.RmqAlertStateMapper;
+import org.apache.rocketmq.studio.persistence.mapper.RmqSystemAlertMapper;
 import org.junit.jupiter.api.Test;
 
 import java.time.Instant;
 import java.time.LocalDateTime;
 import java.time.ZoneOffset;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
 
+import static org.assertj.core.api.Assertions.assertThat;
 import static org.mockito.ArgumentMatchers.any;
 import static org.mockito.ArgumentMatchers.eq;
 import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.times;
 import static org.mockito.Mockito.verify;
 import static org.mockito.Mockito.when;
 
@@ -29,7 +38,8 @@ class MybatisPlusAlertStateRepositoryTest {
         existing.setVersion(3);
         existing.setStatus(AlertStateStatus.FIRING.name());
         when(mapper.selectOne(any())).thenReturn(existing);
-        MybatisPlusAlertStateRepository repository = new 
MybatisPlusAlertStateRepository(mapper);
+        MybatisPlusAlertStateRepository repository = new 
MybatisPlusAlertStateRepository(mapper,
+                mock(RmqSystemAlertMapper.class));
 
         repository.save(new AlertStateKey(4L, "fingerprint"), new 
AlertRuleState(AlertStateStatus.FIRING, 2,
                 10D, Instant.now(), Instant.now(), Instant.now(), null));
@@ -43,11 +53,92 @@ class MybatisPlusAlertStateRepositoryTest {
         Instant firedAt = Instant.parse("2026-08-22T12:00:00Z");
         when(mapper.acknowledgeFiring(eq(4L), eq("fingerprint"),
                 eq(LocalDateTime.ofInstant(firedAt, ZoneOffset.UTC)), 
any())).thenReturn(1);
-        MybatisPlusAlertStateRepository repository = new 
MybatisPlusAlertStateRepository(mapper);
+        MybatisPlusAlertStateRepository repository = new 
MybatisPlusAlertStateRepository(mapper,
+                mock(RmqSystemAlertMapper.class));
 
         repository.acknowledge(new AlertStateKey(4L, "fingerprint"), firedAt);
 
         verify(mapper).acknowledgeFiring(eq(4L), eq("fingerprint"),
                 eq(LocalDateTime.ofInstant(firedAt, ZoneOffset.UTC)), any());
     }
+
+    @Test
+    void findsActiveStatesWithLabelsFromTheLatestAlertEventTest() {
+        RmqAlertStateMapper mapper = mock(RmqAlertStateMapper.class);
+        RmqAlertState state = new RmqAlertState();
+        state.setRuleId(4L);
+        state.setFingerprint("fingerprint");
+        state.setStatus(AlertStateStatus.ACKED.name());
+        state.setConsecutiveHits(1);
+        state.setCurrentValue(30D);
+        when(mapper.selectList(any())).thenReturn(List.of(state));
+        RmqSystemAlert alert = new RmqSystemAlert();
+        alert.setRuleId(4L);
+        alert.setFingerprint("fingerprint");
+        alert.setInstanceId("local");
+        alert.setLabelsJson("{\"consumerGroup\":\"orders\"}");
+        RmqSystemAlertMapper alertMapper = mock(RmqSystemAlertMapper.class);
+        when(alertMapper.selectList(any())).thenReturn(List.of(alert));
+        MybatisPlusAlertStateRepository repository = new 
MybatisPlusAlertStateRepository(mapper, alertMapper);
+        AlertRuleVO rule = 
AlertRuleVO.builder().id(4L).domain(AlertDomain.BUSINESS).enabled(true)
+                .instanceId("local").metric("consumer.lag.total").build();
+
+        List<ActiveAlertState> active = repository.findActive(new 
MetricCollectionScope(AlertDomain.BUSINESS,
+                "local", Set.of("consumer.lag.total")), List.of(rule));
+
+        assertThat(active).singleElement().satisfies(item -> {
+            assertThat(item.key()).isEqualTo(new AlertStateKey(4L, 
"fingerprint"));
+            
assertThat(item.state().status()).isEqualTo(AlertStateStatus.ACKED);
+            assertThat(item.instanceId()).isEqualTo("local");
+            assertThat(item.labels()).isEqualTo(Map.of("consumerGroup", 
"orders"));
+        });
+        verify(alertMapper).selectList(any());
+        verify(alertMapper, never()).selectOne(any());
+    }
+
+    @Test
+    void findsActiveStatesWithOneLatestAlertMetadataQueryTest() {
+        RmqAlertStateMapper mapper = mock(RmqAlertStateMapper.class);
+        RmqAlertState first = activeState(4L, "fingerprint-a", 
AlertStateStatus.FIRING);
+        RmqAlertState second = activeState(5L, "fingerprint-b", 
AlertStateStatus.ACKED);
+        when(mapper.selectList(any())).thenReturn(List.of(first, second));
+        RmqSystemAlertMapper alertMapper = mock(RmqSystemAlertMapper.class);
+        RmqSystemAlert firstAlert = alert(4L, "fingerprint-a", "local", 
"{\"consumerGroup\":\"orders\"}");
+        RmqSystemAlert secondAlert = alert(5L, "fingerprint-b", "local", 
"{\"consumerGroup\":\"payments\"}");
+        RmqSystemAlert staleFirstAlert = alert(4L, "fingerprint-a", "local", 
"{\"consumerGroup\":\"old\"}");
+        when(alertMapper.selectList(any())).thenReturn(List.of(firstAlert, 
secondAlert, staleFirstAlert));
+        MybatisPlusAlertStateRepository repository = new 
MybatisPlusAlertStateRepository(mapper, alertMapper);
+        AlertRuleVO firstRule = 
AlertRuleVO.builder().id(4L).domain(AlertDomain.BUSINESS).enabled(true)
+                .instanceId("local").metric("consumer.lag.total").build();
+        AlertRuleVO secondRule = 
AlertRuleVO.builder().id(5L).domain(AlertDomain.BUSINESS).enabled(true)
+                .instanceId("local").metric("consumer.lag.total").build();
+
+        List<ActiveAlertState> active = repository.findActive(new 
MetricCollectionScope(AlertDomain.BUSINESS,
+                "local", Set.of("consumer.lag.total")), List.of(firstRule, 
secondRule));
+
+        assertThat(active).hasSize(2);
+        assertThat(active).extracting(item -> 
item.labels().get("consumerGroup"))
+                .containsExactly("orders", "payments");
+        verify(alertMapper, times(1)).selectList(any());
+        verify(alertMapper, never()).selectOne(any());
+    }
+
+    private static RmqAlertState activeState(Long ruleId, String fingerprint, 
AlertStateStatus status) {
+        RmqAlertState state = new RmqAlertState();
+        state.setRuleId(ruleId);
+        state.setFingerprint(fingerprint);
+        state.setStatus(status.name());
+        state.setConsecutiveHits(1);
+        state.setCurrentValue(30D);
+        return state;
+    }
+
+    private static RmqSystemAlert alert(Long ruleId, String fingerprint, 
String instanceId, String labelsJson) {
+        RmqSystemAlert alert = new RmqSystemAlert();
+        alert.setRuleId(ruleId);
+        alert.setFingerprint(fingerprint);
+        alert.setInstanceId(instanceId);
+        alert.setLabelsJson(labelsJson);
+        return alert;
+    }
 }
diff --git 
a/server/src/test/java/org/apache/rocketmq/studio/ops/alert/NativeAlertProcessorTest.java
 
b/server/src/test/java/org/apache/rocketmq/studio/ops/alert/NativeAlertProcessorTest.java
index f93c82ac9..1f94d3410 100644
--- 
a/server/src/test/java/org/apache/rocketmq/studio/ops/alert/NativeAlertProcessorTest.java
+++ 
b/server/src/test/java/org/apache/rocketmq/studio/ops/alert/NativeAlertProcessorTest.java
@@ -17,6 +17,7 @@
 package org.apache.rocketmq.studio.ops.alert;
 
 import org.apache.rocketmq.studio.cluster.metrics.MetricAvailability;
+import org.apache.rocketmq.studio.cluster.metrics.MetricCollectionScope;
 import org.apache.rocketmq.studio.cluster.metrics.MetricSample;
 import org.apache.rocketmq.studio.cluster.metrics.MetricSnapshotRepository;
 import org.junit.jupiter.api.Test;
@@ -30,6 +31,7 @@ import java.util.Optional;
 
 import static org.assertj.core.api.Assertions.assertThat;
 import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.eq;
 import static org.mockito.Mockito.mock;
 import static org.mockito.Mockito.times;
 import static org.mockito.Mockito.verify;
@@ -334,6 +336,93 @@ class NativeAlertProcessorTest {
         verify(outbox).enqueue(any(), org.mockito.ArgumentMatchers.same(rule), 
any());
     }
 
+    @Test
+    void resolvesActiveFingerprintMissingFromSuccessfulCollectionScopeTest() {
+        AlertService service = mock(AlertService.class);
+        AlertRuleVO rule = rule("local", "orders", 1);
+        
when(service.listRules(AlertDomain.BUSINESS)).thenReturn(List.of(rule));
+        MetricSample oldSample = sample("orders");
+        AlertStateKey oldKey = new AlertStateKey(rule.getId(),
+                AlertFingerprint.of(rule.getId(), oldSample.instanceId(), 
oldSample.labels()));
+        AlertRuleState firing = new AlertRuleState(AlertStateStatus.FIRING, 1, 
20D,
+                oldSample.collectedAt().minusSeconds(60), 
oldSample.collectedAt().minusSeconds(60),
+                oldSample.collectedAt().minusSeconds(60), null);
+        ActiveAlertState active = new ActiveAlertState(oldKey, firing, 
oldSample.instanceId(), oldSample.labels());
+        AlertStateRepository states = mock(AlertStateRepository.class);
+        when(states.findActive(any(MetricCollectionScope.class), 
eq(List.of(rule)))).thenReturn(List.of(active));
+        when(states.save(eq(oldKey), 
any(AlertRuleState.class))).thenReturn(true);
+        AlertRepository alerts = mock(AlertRepository.class);
+        when(alerts.saveAlert(any(SystemAlertVO.class))).thenAnswer(invocation 
-> invocation.getArgument(0));
+        NotificationOutboxService outbox = 
mock(NotificationOutboxService.class);
+
+        new NativeAlertProcessor(service, new AlertRuleEvaluator(), new 
AlertStateMachine(), states,
+                mock(MetricSnapshotRepository.class), alerts, outbox, 
suppression())
+                .processSuccessfulCollection(new 
MetricCollectionScope(AlertDomain.BUSINESS, "local",
+                        java.util.Set.of("consumer.lag.total")), List.of());
+
+        org.mockito.ArgumentCaptor<AlertRuleState> state = 
org.mockito.ArgumentCaptor.forClass(AlertRuleState.class);
+        org.mockito.ArgumentCaptor<SystemAlertVO> event = 
org.mockito.ArgumentCaptor.forClass(SystemAlertVO.class);
+        verify(states).save(eq(oldKey), state.capture());
+        verify(alerts).saveAlert(event.capture());
+        
assertThat(state.getValue().status()).isEqualTo(AlertStateStatus.RESOLVED);
+        
assertThat(event.getValue().getTransition()).isEqualTo(AlertStateTransition.RESOLVED.name());
+        assertThat(event.getValue().getLabels()).isEqualTo(oldSample.labels());
+        verify(outbox).enqueue(any(SystemAlertVO.class), eq(rule), 
eq(oldSample.labels()));
+    }
+
+    @Test
+    void keepsActiveFingerprintWhenItAppearsInSuccessfulCollectionScopeTest() {
+        AlertService service = mock(AlertService.class);
+        AlertRuleVO rule = rule("local", "orders", 1);
+        
when(service.listRules(AlertDomain.BUSINESS)).thenReturn(List.of(rule));
+        MetricSample current = sample("orders");
+        AlertStateKey key = new AlertStateKey(rule.getId(),
+                AlertFingerprint.of(rule.getId(), current.instanceId(), 
current.labels()));
+        AlertRuleState firing = new AlertRuleState(AlertStateStatus.FIRING, 1, 
20D,
+                current.collectedAt().minusSeconds(60), 
current.collectedAt().minusSeconds(60),
+                current.collectedAt().minusSeconds(60), null);
+        ActiveAlertState active = new ActiveAlertState(key, firing, 
current.instanceId(), current.labels());
+        AlertStateRepository states = mock(AlertStateRepository.class);
+        when(states.find(key)).thenReturn(Optional.of(firing));
+        when(states.save(eq(key), any(AlertRuleState.class))).thenReturn(true);
+        when(states.findActive(any(MetricCollectionScope.class), 
eq(List.of(rule)))).thenReturn(List.of(active));
+        AlertRepository alerts = mock(AlertRepository.class);
+
+        new NativeAlertProcessor(service, new AlertRuleEvaluator(), new 
AlertStateMachine(), states,
+                mock(MetricSnapshotRepository.class), alerts, 
mock(NotificationOutboxService.class), suppression())
+                .processSuccessfulCollection(new 
MetricCollectionScope(AlertDomain.BUSINESS, "local",
+                        java.util.Set.of("consumer.lag.total")), 
List.of(current));
+
+        verify(alerts, never()).saveAlert(any(SystemAlertVO.class));
+    }
+
+    @Test
+    void 
doesNotResolveMissingActiveStateWhenCollectionReportsWholeScopeUnavailableTest()
 {
+        AlertService service = mock(AlertService.class);
+        AlertRuleVO rule = rule("local", "orders", 1);
+        
when(service.listRules(AlertDomain.BUSINESS)).thenReturn(List.of(rule));
+        MetricSample oldSample = sample("orders");
+        AlertStateKey oldKey = new AlertStateKey(rule.getId(),
+                AlertFingerprint.of(rule.getId(), oldSample.instanceId(), 
oldSample.labels()));
+        ActiveAlertState active = new ActiveAlertState(oldKey,
+                new AlertRuleState(AlertStateStatus.FIRING, 1, 20D, 
oldSample.collectedAt().minusSeconds(60),
+                        oldSample.collectedAt().minusSeconds(60), 
oldSample.collectedAt().minusSeconds(60), null),
+                oldSample.instanceId(), oldSample.labels());
+        AlertStateRepository states = mock(AlertStateRepository.class);
+        when(states.findActive(any(MetricCollectionScope.class), 
eq(List.of(rule)))).thenReturn(List.of(active));
+        AlertRepository alerts = mock(AlertRepository.class);
+
+        new NativeAlertProcessor(service, new AlertRuleEvaluator(), new 
AlertStateMachine(), states,
+                mock(MetricSnapshotRepository.class), alerts, 
mock(NotificationOutboxService.class), suppression())
+                .processSuccessfulCollection(new 
MetricCollectionScope(AlertDomain.BUSINESS, "local",
+                        java.util.Set.of("consumer.lag.total")), List.of(new 
MetricSample("consumer.lag.total",
+                        AlertDomain.BUSINESS, "local", null, Map.of(), null, 
MetricAvailability.UNAVAILABLE,
+                        Instant.now(), "BUSINESS_METRICS_COLLECTION_FAILED")));
+
+        verify(states, never()).save(eq(oldKey), any(AlertRuleState.class));
+        verify(alerts, never()).saveAlert(any(SystemAlertVO.class));
+    }
+
     private static AlertNotificationSuppressionService suppression() {
         AlertNotificationSuppressionService service = 
mock(AlertNotificationSuppressionService.class);
         
when(service.findSuppressingClusterAlert(any(SystemAlertVO.class))).thenReturn(Optional.empty());

Reply via email to