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 6772afcce fix: isolate native alert rule evaluations (#2697)
6772afcce is described below

commit 6772afccebccbdf1965fdf47d0b82dd80697c034
Author: xdz997 <[email protected]>
AuthorDate: Wed Sep 2 20:10:05 2026 +0800

    fix: isolate native alert rule evaluations (#2697)
    
    * fix: isolate native alert rule evaluations
    
    * test: verify native alert transaction rollback
---
 .../ops/alert/NativeAlertEvaluationService.java    | 156 +++++++++++++++++++++
 .../studio/ops/alert/NativeAlertProcessor.java     |  75 +++-------
 .../NativeAlertEvaluationTransactionTest.java      | 101 +++++++++++++
 .../studio/ops/alert/NativeAlertProcessorTest.java | 132 ++++++++++++++---
 4 files changed, 388 insertions(+), 76 deletions(-)

diff --git 
a/server/src/main/java/org/apache/rocketmq/studio/ops/alert/NativeAlertEvaluationService.java
 
b/server/src/main/java/org/apache/rocketmq/studio/ops/alert/NativeAlertEvaluationService.java
new file mode 100644
index 000000000..0de1fac79
--- /dev/null
+++ 
b/server/src/main/java/org/apache/rocketmq/studio/ops/alert/NativeAlertEvaluationService.java
@@ -0,0 +1,156 @@
+/*
+ * 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.ops.alert;
+
+import lombok.RequiredArgsConstructor;
+import org.apache.rocketmq.studio.cluster.metrics.MetricAvailability;
+import org.apache.rocketmq.studio.cluster.metrics.MetricSample;
+import org.apache.rocketmq.studio.cluster.metrics.MetricSnapshotRepository;
+import org.apache.rocketmq.studio.common.domain.enums.AlertLevel;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Propagation;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.time.Duration;
+import java.time.LocalDateTime;
+import java.time.ZoneOffset;
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+import java.util.Optional;
+import java.util.TreeMap;
+
+/**
+ * Evaluates one rule against one native sample in an independent transaction. 
A failure rolls
+ * back this evaluation's state, event, and outbox changes without 
invalidating other evaluations
+ * from the same collection batch.
+ */
+@Service
+@RequiredArgsConstructor
+public class NativeAlertEvaluationService {
+    private final AlertRuleEvaluator evaluator;
+    private final AlertStateMachine stateMachine;
+    private final AlertStateRepository stateRepository;
+    private final MetricSnapshotRepository snapshotRepository;
+    private final AlertRepository alertRepository;
+    private final NotificationOutboxService notificationOutboxService;
+    private final AlertNotificationSuppressionService 
notificationSuppressionService;
+
+    @Transactional(propagation = Propagation.REQUIRES_NEW)
+    public void evaluate(AlertRuleVO rule, MetricSample sample) {
+        MetricSample evaluatedSample = aggregate(rule, sample);
+        AlertEvaluationResult evaluation = evaluator.evaluate(rule, 
evaluatedSample);
+        if (!evaluation.matches()) {
+            return;
+        }
+
+        AlertStateKey key = new AlertStateKey(rule.getId(),
+                AlertFingerprint.of(rule.getId(), sample.instanceId(), 
sample.labels()));
+        AlertStateUpdate update = 
stateMachine.advance(stateRepository.find(key).orElse(null), evaluation,
+                Math.max(1, rule.getConsecutiveSamples()), 
AlertRuleDuration.parse(rule.getDuration()),
+                AlertRuleDuration.parse(rule.getReminderInterval()), 
sample.collectedAt());
+        if (!stateRepository.save(key, update.state()) || 
!emitsLifecycleEvent(update.transition())) {
+            return;
+        }
+
+        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();
+        applyNotificationSuppression(event, sample.domain(), 
update.transition());
+
+        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());
+        }
+    }
+
+    /** Reads the optional snapshot window inside this evaluation's 
independent transaction. */
+    private MetricSample aggregate(AlertRuleVO rule, MetricSample sample) {
+        if (sample.availability() != MetricAvailability.AVAILABLE || 
rule.getWindowSeconds() <= 0) {
+            return sample;
+        }
+        List<MetricSample> window = snapshotRepository.findRecent(sample,
+                
sample.collectedAt().minus(Duration.ofSeconds(rule.getWindowSeconds())));
+        if (window.isEmpty()) {
+            return sample;
+        }
+        double value = switch (rule.getAggregation() == null ? "LAST" : 
rule.getAggregation().toUpperCase(Locale.ROOT)) {
+            case "MAX" -> window.stream().mapToDouble(item -> 
item.value()).max().orElse(sample.value());
+            case "MIN" -> window.stream().mapToDouble(item -> 
item.value()).min().orElse(sample.value());
+            case "AVG" -> window.stream().mapToDouble(item -> 
item.value()).average().orElse(sample.value());
+            case "SUM" -> window.stream().mapToDouble(item -> 
item.value()).sum();
+            default -> window.get(window.size() - 1).value();
+        };
+        return new MetricSample(sample.metricKey(), sample.domain(), 
sample.instanceId(), sample.clusterId(),
+                sample.labels(), value, MetricAvailability.AVAILABLE, 
sample.collectedAt());
+    }
+
+    private void applyNotificationSuppression(SystemAlertVO event, AlertDomain 
domain,
+            AlertStateTransition transition) {
+        if (!shouldSuppress(domain, transition)) {
+            return;
+        }
+        Optional<SystemAlertVO> cause = 
notificationSuppressionService.findSuppressingClusterAlert(event);
+        if (cause.isEmpty()) {
+            return;
+        }
+        SystemAlertVO suppressingAlert = cause.get();
+        event.setNotificationSuppressed(true);
+        event.setSuppressionCauseAlertId(suppressingAlert.getId());
+        event.setSuppressionReason("Suppressed by active cluster incident #" + 
suppressingAlert.getId()
+                + ": " + suppressingAlert.getTitle());
+    }
+
+    private static boolean emitsLifecycleEvent(AlertStateTransition 
transition) {
+        return transition == AlertStateTransition.FIRING
+                || transition == AlertStateTransition.REMINDER
+                || transition == AlertStateTransition.RESOLVED;
+    }
+
+    private static boolean shouldSuppress(AlertDomain domain, 
AlertStateTransition transition) {
+        return domain == AlertDomain.BUSINESS
+                && (transition == AlertStateTransition.FIRING || transition == 
AlertStateTransition.REMINDER);
+    }
+
+    private static AlertLevel level(String severity) {
+        if ("critical".equalsIgnoreCase(severity)) {
+            return AlertLevel.error;
+        }
+        if ("warning".equalsIgnoreCase(severity)) {
+            return AlertLevel.warning;
+        }
+        return AlertLevel.info;
+    }
+}
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 f75560654..fad40db82 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
@@ -17,42 +17,39 @@
 package org.apache.rocketmq.studio.ops.alert;
 
 import lombok.RequiredArgsConstructor;
-import org.apache.rocketmq.studio.cluster.metrics.MetricSample;
-import org.apache.rocketmq.studio.cluster.metrics.MetricSnapshotRepository;
+import lombok.extern.slf4j.Slf4j;
 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.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.LocalDateTime;
 import java.time.ZoneOffset;
+import java.util.EnumMap;
 import java.util.List;
-import java.util.Locale;
 import java.util.Map;
+import java.util.Objects;
 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. */
+@Slf4j
 @Component
 @RequiredArgsConstructor
 public class NativeAlertProcessor {
     private final AlertService alertService;
-    private final AlertRuleEvaluator evaluator;
+    private final NativeAlertEvaluationService evaluationService;
     private final AlertStateMachine stateMachine;
     private final AlertStateRepository stateRepository;
-    private final MetricSnapshotRepository snapshotRepository;
     private final AlertRepository alertRepository;
     private final NotificationOutboxService notificationOutboxService;
     private final AlertNotificationSuppressionService 
notificationSuppressionService;
 
-    @Transactional
     public void process(List<MetricSample> samples) {
         processSamples(samples);
     }
@@ -76,37 +73,24 @@ public class NativeAlertProcessor {
 
     private void processSamples(List<MetricSample> samples) {
         Map<AlertDomain, List<AlertRuleVO>> rulesByDomain = new 
EnumMap<>(AlertDomain.class);
+        int failedEvaluations = 0;
         for (MetricSample sample : samples) {
             for (AlertRuleVO rule : 
rulesByDomain.computeIfAbsent(sample.domain(), alertService::listRules)) {
-                if (rule.getId() == null) {
+                if (!eligible(rule, sample)) {
                     continue;
                 }
-                if (!rule.isEnabled()) {
-                    continue;
-                }
-                if (!NativeAlertRuleScopeMatcher.matches(rule, sample)) {
-                    continue;
-                }
-                MetricSample evaluatedSample = aggregate(rule, sample);
-                AlertEvaluationResult evaluation = evaluator.evaluate(rule, 
evaluatedSample);
-                if (!evaluation.matches()) {
-                    continue;
-                }
-                AlertStateKey key = new AlertStateKey(rule.getId(),
-                        AlertFingerprint.of(rule.getId(), sample.instanceId(), 
sample.labels()));
-                AlertStateUpdate update = 
stateMachine.advance(stateRepository.find(key).orElse(null), evaluation,
-                        Math.max(1, rule.getConsecutiveSamples()), 
AlertRuleDuration.parse(rule.getDuration()),
-                        AlertRuleDuration.parse(rule.getReminderInterval()), 
sample.collectedAt());
-                if (!stateRepository.save(key, update.state())) {
-                    continue;
-                }
-                if (update.transition() == AlertStateTransition.FIRING || 
update.transition() == AlertStateTransition.REMINDER
-                        || update.transition() == 
AlertStateTransition.RESOLVED) {
-                    emitLifecycleEvent(rule, key, update, sample.domain(), 
sample.instanceId(), sample.metricKey(),
-                            sample.labels(), sample.collectedAt());
+                try {
+                    evaluationService.evaluate(rule, sample);
+                } catch (RuntimeException error) {
+                    failedEvaluations++;
+                    log.warn("Native alert evaluation failed: ruleId={}, 
instanceId={}, metric={}, cause={}",
+                            rule.getId(), sample.instanceId(), 
sample.metricKey(), error.getClass().getSimpleName());
                 }
             }
         }
+        if (failedEvaluations > 0) {
+            log.warn("Native alert batch completed with {} failed rule 
evaluation(s)", failedEvaluations);
+        }
     }
 
     private void reconcileMissingActiveStates(MetricCollectionScope scope, 
List<MetricSample> samples) {
@@ -188,26 +172,6 @@ public class NativeAlertProcessor {
                 && (transition == AlertStateTransition.FIRING || transition == 
AlertStateTransition.REMINDER);
     }
 
-    private MetricSample aggregate(AlertRuleVO rule, MetricSample sample) {
-        if (sample.availability() != MetricAvailability.AVAILABLE || 
rule.getWindowSeconds() <= 0) {
-            return sample;
-        }
-        List<MetricSample> window = snapshotRepository.findRecent(sample,
-                
sample.collectedAt().minus(Duration.ofSeconds(rule.getWindowSeconds())));
-        if (window.isEmpty()) {
-            return sample;
-        }
-        double value = switch (rule.getAggregation() == null ? "LAST" : 
rule.getAggregation().toUpperCase(Locale.ROOT)) {
-            case "MAX" -> window.stream().mapToDouble(item -> 
item.value()).max().orElse(sample.value());
-            case "MIN" -> window.stream().mapToDouble(item -> 
item.value()).min().orElse(sample.value());
-            case "AVG" -> window.stream().mapToDouble(item -> 
item.value()).average().orElse(sample.value());
-            case "SUM" -> window.stream().mapToDouble(item -> 
item.value()).sum();
-            default -> window.get(window.size() - 1).value();
-        };
-        return new MetricSample(sample.metricKey(), sample.domain(), 
sample.instanceId(), sample.clusterId(), sample.labels(),
-                value, MetricAvailability.AVAILABLE, sample.collectedAt());
-    }
-
     private static AlertLevel level(String severity) {
         if ("critical".equalsIgnoreCase(severity)) {
             return AlertLevel.error;
@@ -218,4 +182,7 @@ public class NativeAlertProcessor {
         return AlertLevel.info;
     }
 
+    private static boolean eligible(AlertRuleVO rule, MetricSample sample) {
+        return rule.getId() != null && rule.isEnabled() && 
NativeAlertRuleScopeMatcher.matches(rule, sample);
+    }
 }
diff --git 
a/server/src/test/java/org/apache/rocketmq/studio/ops/alert/NativeAlertEvaluationTransactionTest.java
 
b/server/src/test/java/org/apache/rocketmq/studio/ops/alert/NativeAlertEvaluationTransactionTest.java
new file mode 100644
index 000000000..5e582563f
--- /dev/null
+++ 
b/server/src/test/java/org/apache/rocketmq/studio/ops/alert/NativeAlertEvaluationTransactionTest.java
@@ -0,0 +1,101 @@
+/*
+ * 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.ops.alert;
+
+import org.apache.rocketmq.studio.cluster.metrics.MetricAvailability;
+import org.apache.rocketmq.studio.cluster.metrics.MetricSample;
+import org.apache.rocketmq.studio.cluster.metrics.MetricSnapshotRepository;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.test.context.SpringBootTest;
+import org.springframework.boot.test.mock.mockito.MockBean;
+import org.springframework.jdbc.core.JdbcTemplate;
+
+import java.time.Instant;
+import java.util.Map;
+import java.util.Optional;
+import java.util.concurrent.atomic.AtomicBoolean;
+
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.when;
+
+@SpringBootTest
+class NativeAlertEvaluationTransactionTest {
+    @Autowired
+    private NativeAlertEvaluationService evaluationService;
+    @Autowired
+    private JdbcTemplate jdbcTemplate;
+    @MockBean
+    private AlertRuleEvaluator evaluator;
+    @MockBean
+    private AlertStateMachine stateMachine;
+    @MockBean
+    private AlertStateRepository stateRepository;
+    @MockBean
+    private MetricSnapshotRepository snapshotRepository;
+    @MockBean
+    private AlertRepository alertRepository;
+    @MockBean
+    private NotificationOutboxService outbox;
+    @MockBean
+    private AlertNotificationSuppressionService suppression;
+
+    @BeforeEach
+    void setUp() {
+        jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS native_alert_tx_probe 
(id BIGINT PRIMARY KEY)");
+        jdbcTemplate.update("DELETE FROM native_alert_tx_probe");
+        when(evaluator.evaluate(any(), any())).thenReturn(
+                new AlertEvaluationResult(true, true, 20D, 
MetricAvailability.AVAILABLE));
+        when(stateRepository.find(any())).thenReturn(Optional.empty());
+        when(stateMachine.advance(any(), any(), any(Integer.class), any(), 
any(), any()))
+                .thenReturn(new AlertStateUpdate(new 
AlertRuleState(AlertStateStatus.FIRING, 1, 20D, null,
+                        Instant.now(), Instant.now(), null), 
AlertStateTransition.FIRING));
+        when(stateRepository.save(any(), any())).thenAnswer(invocation -> {
+            jdbcTemplate.update("INSERT INTO native_alert_tx_probe (id) VALUES 
(1)");
+            return true;
+        });
+        
when(suppression.findSuppressingClusterAlert(any())).thenReturn(Optional.empty());
+    }
+
+    @Test
+    void failedEvaluationRollsBackItsWritesButNextEvaluationCommits() {
+        AtomicBoolean fail = new AtomicBoolean(true);
+        when(alertRepository.saveAlert(any())).thenAnswer(invocation -> {
+            if (fail.get()) {
+                throw new IllegalStateException("event insert failed");
+            }
+            return invocation.getArgument(0);
+        });
+        AlertRuleVO rule = 
AlertRuleVO.builder().id(1L).domain(AlertDomain.BUSINESS).name("Orders")
+                
.metric("consumer.lag.total").operator(">").threshold(10).enabled(true).instanceId("local")
+                .consumerGroup("orders").build();
+        MetricSample sample = new MetricSample("consumer.lag.total", 
AlertDomain.BUSINESS, "local", null,
+                Map.of("consumerGroup", "orders"), 20D, 
MetricAvailability.AVAILABLE, Instant.now());
+
+        assertThatThrownBy(() -> evaluationService.evaluate(rule, 
sample)).isInstanceOf(IllegalStateException.class);
+        assertThat(jdbcTemplate.queryForObject("SELECT COUNT(*) FROM 
native_alert_tx_probe", Integer.class))
+                .isZero();
+
+        fail.set(false);
+        evaluationService.evaluate(rule, sample);
+        assertThat(jdbcTemplate.queryForObject("SELECT COUNT(*) FROM 
native_alert_tx_probe", Integer.class))
+                .isEqualTo(1);
+    }
+}
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 1f94d3410..e7f530711 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
@@ -30,6 +30,8 @@ import java.util.Map;
 import java.util.Optional;
 
 import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatCode;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
 import static org.mockito.ArgumentMatchers.any;
 import static org.mockito.ArgumentMatchers.eq;
 import static org.mockito.Mockito.mock;
@@ -41,6 +43,73 @@ import static org.mockito.Mockito.when;
 
 class NativeAlertProcessorTest {
 
+    @Test
+    void continuesWithLaterRulesWhenOneEvaluationFailsTest() {
+        AlertService service = mock(AlertService.class);
+        AlertRuleVO failing = rule(1L, "local", "orders", 1);
+        failing.setWindowSeconds(300);
+        AlertRuleVO healthy = rule(2L, "local", "orders", 1);
+        
when(service.listRules(AlertDomain.BUSINESS)).thenReturn(List.of(failing, 
healthy));
+        MetricSnapshotRepository snapshots = 
mock(MetricSnapshotRepository.class);
+        when(snapshots.findRecent(any(MetricSample.class), any(Instant.class)))
+                .thenThrow(new IllegalStateException("snapshot read failed"));
+        AlertStateRepository states = mock(AlertStateRepository.class);
+        
when(states.find(any(AlertStateKey.class))).thenReturn(Optional.empty());
+        when(states.save(any(AlertStateKey.class), 
any(AlertRuleState.class))).thenReturn(true);
+
+        NativeAlertProcessor processor = processor(service, states, snapshots, 
mock(AlertRepository.class),
+                mock(NotificationOutboxService.class), suppression());
+
+        assertThatCode(() -> 
processor.process(List.of(sample("orders")))).doesNotThrowAnyException();
+
+        verify(states).save(org.mockito.ArgumentMatchers.argThat(key -> 
key.ruleId().equals(2L)),
+                any(AlertRuleState.class));
+    }
+
+    @Test
+    void continuesWithLaterRulesWhenAlertPersistenceFailsTest() {
+        AlertService service = mock(AlertService.class);
+        AlertRuleVO failing = rule(1L, "local", "orders", 1);
+        AlertRuleVO healthy = rule(2L, "local", "orders", 1);
+        
when(service.listRules(AlertDomain.BUSINESS)).thenReturn(List.of(failing, 
healthy));
+        AlertStateRepository states = mock(AlertStateRepository.class);
+        
when(states.find(any(AlertStateKey.class))).thenReturn(Optional.empty());
+        when(states.save(any(AlertStateKey.class), 
any(AlertRuleState.class))).thenReturn(true);
+        AlertRepository alerts = mock(AlertRepository.class);
+        when(alerts.saveAlert(any(SystemAlertVO.class)))
+                .thenThrow(new IllegalStateException("event insert failed"))
+                .thenAnswer(invocation -> invocation.getArgument(0));
+        NotificationOutboxService outbox = 
mock(NotificationOutboxService.class);
+
+        NativeAlertProcessor processor = processor(service, states, 
mock(MetricSnapshotRepository.class), alerts,
+                outbox, suppression());
+
+        assertThatCode(() -> 
processor.process(List.of(sample("orders")))).doesNotThrowAnyException();
+
+        verify(states).save(org.mockito.ArgumentMatchers.argThat(key -> 
key.ruleId().equals(2L)),
+                any(AlertRuleState.class));
+        verify(outbox).enqueue(any(SystemAlertVO.class), 
org.mockito.ArgumentMatchers.same(healthy),
+                org.mockito.ArgumentMatchers.anyMap());
+    }
+
+    @Test
+    void doesNotSwallowErrorsTest() {
+        AlertService service = mock(AlertService.class);
+        AlertRuleVO rule = rule(1L, "local", "orders", 1);
+        
when(service.listRules(AlertDomain.BUSINESS)).thenReturn(List.of(rule));
+        NativeAlertEvaluationService evaluationService = 
mock(NativeAlertEvaluationService.class);
+        org.mockito.Mockito.doThrow(new AssertionError("fatal evaluation 
failure"))
+                .when(evaluationService).evaluate(any(AlertRuleVO.class), 
any(MetricSample.class));
+
+        NativeAlertProcessor processor = new NativeAlertProcessor(service, 
evaluationService,
+                new AlertStateMachine(), mock(AlertStateRepository.class), 
mock(AlertRepository.class),
+                mock(NotificationOutboxService.class), suppression());
+
+        assertThatThrownBy(() -> processor.process(List.of(sample("orders"))))
+                .isInstanceOf(AssertionError.class)
+                .hasMessage("fatal evaluation failure");
+    }
+
     @Test
     void requiresInstanceScopeBeforeProcessingNativeSamplesTest() {
         AlertService service = mock(AlertService.class);
@@ -155,8 +224,8 @@ class NativeAlertProcessorTest {
             }
         };
 
-        new NativeAlertProcessor(service, new AlertRuleEvaluator(), new 
AlertStateMachine(), states, snapshots,
-                mock(AlertRepository.class), 
mock(NotificationOutboxService.class), suppression()).process(List.of(current));
+        processor(service, states, snapshots, mock(AlertRepository.class), 
mock(NotificationOutboxService.class),
+                suppression()).process(List.of(current));
 
         assertThat(saved.values()).singleElement().satisfies(state -> {
             assertThat(state.status()).isEqualTo(AlertStateStatus.FIRING);
@@ -198,8 +267,8 @@ class NativeAlertProcessorTest {
             }
         };
 
-        new NativeAlertProcessor(service, new AlertRuleEvaluator(), new 
AlertStateMachine(), states, snapshots,
-                mock(AlertRepository.class), 
mock(NotificationOutboxService.class), suppression()).process(List.of(current));
+        processor(service, states, snapshots, mock(AlertRepository.class), 
mock(NotificationOutboxService.class),
+                suppression()).process(List.of(current));
 
         assertThat(saved.values()).singleElement().satisfies(state -> {
             assertThat(state.status()).isEqualTo(AlertStateStatus.FIRING);
@@ -225,9 +294,8 @@ class NativeAlertProcessorTest {
             
when(states.find(any(AlertStateKey.class))).thenReturn(Optional.empty());
             when(states.save(any(AlertStateKey.class), 
any(AlertRuleState.class))).thenReturn(true);
 
-            new NativeAlertProcessor(service, new AlertRuleEvaluator(), new 
AlertStateMachine(), states, snapshots,
-                    mock(AlertRepository.class), 
mock(NotificationOutboxService.class), suppression())
-                    .process(List.of(current));
+            processor(service, states, snapshots, mock(AlertRepository.class), 
mock(NotificationOutboxService.class),
+                    suppression()).process(List.of(current));
 
             org.mockito.ArgumentCaptor<AlertRuleState> saved = 
org.mockito.ArgumentCaptor.forClass(AlertRuleState.class);
             verify(states).save(any(AlertStateKey.class), saved.capture());
@@ -273,17 +341,26 @@ class NativeAlertProcessorTest {
         });
         NotificationOutboxService outbox = 
mock(NotificationOutboxService.class);
 
-        new NativeAlertProcessor(service, new AlertRuleEvaluator(), new 
AlertStateMachine(), states,
-                mock(MetricSnapshotRepository.class), alerts, outbox, 
suppression()).process(List.of(sample("orders")));
+        processor(service, states, mock(MetricSnapshotRepository.class), 
alerts, outbox, suppression())
+                .process(List.of(sample("orders")));
 
         verify(outbox).enqueue(any(SystemAlertVO.class), 
org.mockito.ArgumentMatchers.same(rule),
                 org.mockito.ArgumentMatchers.anyMap());
     }
 
+    private static NativeAlertProcessor processor(AlertService service, 
AlertStateRepository states,
+            MetricSnapshotRepository snapshots, AlertRepository alerts, 
NotificationOutboxService outbox,
+            AlertNotificationSuppressionService suppression) {
+        NativeAlertEvaluationService evaluationService = new 
NativeAlertEvaluationService(new AlertRuleEvaluator(),
+                new AlertStateMachine(), states, snapshots, alerts, outbox, 
suppression);
+        return new NativeAlertProcessor(service, evaluationService, new 
AlertStateMachine(), states, alerts, outbox,
+                suppression);
+    }
+
     private static NativeAlertProcessor processor(AlertService service, 
AlertStateRepository states,
             AlertRepository alerts) {
-        return new NativeAlertProcessor(service, new AlertRuleEvaluator(), new 
AlertStateMachine(), states,
-                mock(MetricSnapshotRepository.class), alerts, 
mock(NotificationOutboxService.class), suppression());
+        return processor(service, states, 
mock(MetricSnapshotRepository.class), alerts,
+                mock(NotificationOutboxService.class), suppression());
     }
 
     @Test
@@ -302,8 +379,8 @@ class NativeAlertProcessorTest {
         SystemAlertVO clusterCause = 
SystemAlertVO.builder().id(11L).title("Broker unavailable").build();
         
when(suppression.findSuppressingClusterAlert(any(SystemAlertVO.class))).thenReturn(Optional.of(clusterCause));
 
-        new NativeAlertProcessor(service, new AlertRuleEvaluator(), new 
AlertStateMachine(), states,
-                mock(MetricSnapshotRepository.class), alerts, outbox, 
suppression).process(List.of(sample("orders")));
+        processor(service, states, mock(MetricSnapshotRepository.class), 
alerts, outbox, suppression)
+                .process(List.of(sample("orders")));
 
         org.mockito.ArgumentCaptor<SystemAlertVO> event = 
org.mockito.ArgumentCaptor.forClass(SystemAlertVO.class);
         verify(alerts).saveAlert(event.capture());
@@ -328,8 +405,7 @@ class NativeAlertProcessorTest {
         NotificationOutboxService outbox = 
mock(NotificationOutboxService.class);
         AlertNotificationSuppressionService suppression = 
mock(AlertNotificationSuppressionService.class);
 
-        new NativeAlertProcessor(service, new AlertRuleEvaluator(), new 
AlertStateMachine(), states,
-                mock(MetricSnapshotRepository.class), alerts, outbox, 
suppression)
+        processor(service, states, mock(MetricSnapshotRepository.class), 
alerts, outbox, suppression)
                 .process(List.of(sample("orders", 0D)));
 
         verify(suppression, never()).findSuppressingClusterAlert(any());
@@ -355,8 +431,10 @@ class NativeAlertProcessorTest {
         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())
+        new NativeAlertProcessor(service,
+                new NativeAlertEvaluationService(new AlertRuleEvaluator(), new 
AlertStateMachine(), states,
+                        mock(MetricSnapshotRepository.class), alerts, outbox, 
suppression()),
+                new AlertStateMachine(), states, alerts, outbox, suppression())
                 .processSuccessfulCollection(new 
MetricCollectionScope(AlertDomain.BUSINESS, "local",
                         java.util.Set.of("consumer.lag.total")), List.of());
 
@@ -388,8 +466,11 @@ class NativeAlertProcessorTest {
         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())
+        new NativeAlertProcessor(service,
+                new NativeAlertEvaluationService(new AlertRuleEvaluator(), new 
AlertStateMachine(), states,
+                        mock(MetricSnapshotRepository.class), alerts, 
mock(NotificationOutboxService.class),
+                        suppression()),
+                new AlertStateMachine(), states, alerts, 
mock(NotificationOutboxService.class), suppression())
                 .processSuccessfulCollection(new 
MetricCollectionScope(AlertDomain.BUSINESS, "local",
                         java.util.Set.of("consumer.lag.total")), 
List.of(current));
 
@@ -412,8 +493,11 @@ class NativeAlertProcessorTest {
         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())
+        new NativeAlertProcessor(service,
+                new NativeAlertEvaluationService(new AlertRuleEvaluator(), new 
AlertStateMachine(), states,
+                        mock(MetricSnapshotRepository.class), alerts, 
mock(NotificationOutboxService.class),
+                        suppression()),
+                new AlertStateMachine(), states, 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,
@@ -430,7 +514,11 @@ class NativeAlertProcessorTest {
     }
 
     private static AlertRuleVO rule(String instanceId, String group, int 
consecutiveSamples) {
-        return 
AlertRuleVO.builder().id(1L).domain(AlertDomain.BUSINESS).name("Orders lag")
+        return rule(1L, instanceId, group, consecutiveSamples);
+    }
+
+    private static AlertRuleVO rule(Long id, String instanceId, String group, 
int consecutiveSamples) {
+        return 
AlertRuleVO.builder().id(id).domain(AlertDomain.BUSINESS).name("Orders lag")
                 
.metric("consumer.lag.total").operator(">").threshold(10).enabled(true)
                 
.instanceId(instanceId).consumerGroup(group).consecutiveSamples(consecutiveSamples).build();
     }

Reply via email to