This is an automated email from the ASF dual-hosted git repository.

Aias00 pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/hertzbeat.git


The following commit(s) were added to refs/heads/master by this push:
     new 6394a72926 [bugfix] Alarm group convergence must not resolve while 
members are still firing (#4316)
6394a72926 is described below

commit 6394a72926500a82b310ca3bd83271954d7bce9c
Author: Nikhil Ramashasthri <[email protected]>
AuthorDate: Mon Aug 17 07:41:42 2026 -0400

    [bugfix] Alarm group convergence must not resolve while members are still 
firing (#4316)
    
    Co-authored-by: aias00 <[email protected]>
---
 .../hertzbeat/alert/reduce/AlarmGroupReduce.java   | 40 ++++++----
 .../alert/reduce/AlarmGroupReduceTest.java         | 85 ++++++++++++++++++++++
 2 files changed, 110 insertions(+), 15 deletions(-)

diff --git 
a/hertzbeat-alerter/src/main/java/org/apache/hertzbeat/alert/reduce/AlarmGroupReduce.java
 
b/hertzbeat-alerter/src/main/java/org/apache/hertzbeat/alert/reduce/AlarmGroupReduce.java
index 597ef9a58a..fac5f7b268 100644
--- 
a/hertzbeat-alerter/src/main/java/org/apache/hertzbeat/alert/reduce/AlarmGroupReduce.java
+++ 
b/hertzbeat-alerter/src/main/java/org/apache/hertzbeat/alert/reduce/AlarmGroupReduce.java
@@ -171,7 +171,7 @@ public class AlarmGroupReduce implements DisposableBean {
                 .factory());
     }
 
-    private void runCheckAndSendGroups() {
+    void runCheckAndSendGroups() {
         beforeCheckAndSendGroupsRun();
         try {
             long now = System.currentTimeMillis();
@@ -179,7 +179,6 @@ public class AlarmGroupReduce implements DisposableBean {
                 if (shouldSendGroup(cache, now)) {
                     sendGroupAlert(cache);
                     cache.setLastSendTime(now);
-                    cache.getAlertFingerprints().clear();
                 }
             });
         } catch (Exception e) {
@@ -262,22 +261,19 @@ public class AlarmGroupReduce implements DisposableBean {
             return newCache;
         });
         String fingerprint = alert.getFingerprint();
-        // Check if this is a duplicate alert
+        // Preserve the original startAt when updating an alert that is still 
tracked
         SingleAlert existingAlert = 
cache.getAlertFingerprints().get(fingerprint);
         if (existingAlert != null) {
-            // Update existing alert timestamp
             alert.setStartAt(existingAlert.getStartAt());
-            cache.getAlertFingerprints().put(fingerprint, alert);
-            return;
         }
-        
-        // Add new alert
+        // Add or update the alert. The cache retains every currently-active 
alert of the
+        // group, so the group status is always computed over the full member 
set rather
+        // than only the alerts received within the current send window.
         cache.getAlertFingerprints().put(fingerprint, alert);
-        
+
         if (shouldSendGroupImmediately(cache)) {
             sendGroupAlert(cache);
             cache.setLastSendTime(System.currentTimeMillis());
-            cache.getAlertFingerprints().clear();
         }
     }
     
@@ -288,21 +284,27 @@ public class AlarmGroupReduce implements DisposableBean {
         
         long now = System.currentTimeMillis();
         String status = 
determineGroupStatus(cache.getAlertFingerprints().values());
-        
+
+        boolean hasResolvedAlert = 
cache.getAlertFingerprints().values().stream()
+                .anyMatch(alert -> 
CommonConstants.ALERT_STATUS_RESOLVED.equals(alert.getStatus()));
+
         // For firing alerts, check repeat interval
         if (CommonConstants.ALERT_STATUS_FIRING.equals(status)) {
             AlertGroupConverge ruleConfig = 
groupDefines.get(cache.getGroupDefineName());
             long repeatInterval = ruleConfig.getRepeatInterval() != null
                     ? ruleConfig.getRepeatInterval() * MS_PER_SECOND : 
DEFAULT_REPEAT_INTERVAL;
-            
-            // Skip if within repeat interval
-            if (cache.getLastRepeatTime() > 0 
+
+            // Skip if within repeat interval. The throttle only suppresses 
repeated firing
+            // notifications; it must never swallow a pending resolved 
transition, so we still
+            // send when the batch carries a member that has just recovered.
+            if (!hasResolvedAlert
+                && cache.getLastRepeatTime() > 0
                 && now - cache.getLastRepeatTime() < repeatInterval) {
                 return;
             }
             cache.setLastRepeatTime(now);
         }
-        
+
         GroupAlert groupAlert = GroupAlert.builder()
                 .groupKey(cache.getGroupKey())
                 .groupLabels(cache.getGroupLabels())
@@ -313,6 +315,14 @@ public class AlarmGroupReduce implements DisposableBean {
                 .build();
 
         alarmInhibitReduce.inhibitAlarm(groupAlert);
+
+        // The resolved members have now been emitted, so drop them from the 
group. Firing
+        // members are retained until they recover, keeping the group firing 
while any member
+        // is still active instead of flushing the whole cache after every 
send.
+        if (hasResolvedAlert) {
+            cache.getAlertFingerprints().values().removeIf(
+                    alert -> 
CommonConstants.ALERT_STATUS_RESOLVED.equals(alert.getStatus()));
+        }
     }
     
     private boolean shouldSendGroup(GroupAlertCache cache, long now) {
diff --git 
a/hertzbeat-alerter/src/test/java/org/apache/hertzbeat/alert/reduce/AlarmGroupReduceTest.java
 
b/hertzbeat-alerter/src/test/java/org/apache/hertzbeat/alert/reduce/AlarmGroupReduceTest.java
index f6bf2c3598..4611d52138 100644
--- 
a/hertzbeat-alerter/src/test/java/org/apache/hertzbeat/alert/reduce/AlarmGroupReduceTest.java
+++ 
b/hertzbeat-alerter/src/test/java/org/apache/hertzbeat/alert/reduce/AlarmGroupReduceTest.java
@@ -24,12 +24,14 @@ import static org.junit.jupiter.api.Assertions.assertFalse;
 import static org.junit.jupiter.api.Assertions.assertTrue;
 import static org.mockito.ArgumentMatchers.any;
 import static org.mockito.ArgumentMatchers.argThat;
+import static org.mockito.Mockito.atLeastOnce;
 import static org.mockito.Mockito.never;
 import static org.mockito.Mockito.verify;
 import static org.mockito.Mockito.when;
 import java.util.Arrays;
 import java.util.Collections;
 import java.util.HashMap;
+import java.util.List;
 import java.util.Map;
 import java.util.concurrent.CountDownLatch;
 import java.util.concurrent.TimeUnit;
@@ -38,10 +40,12 @@ import java.util.concurrent.atomic.AtomicInteger;
 import org.apache.hertzbeat.alert.dao.AlertGroupConvergeDao;
 import org.apache.hertzbeat.common.config.VirtualThreadProperties;
 import org.apache.hertzbeat.common.entity.alerter.AlertGroupConverge;
+import org.apache.hertzbeat.common.entity.alerter.GroupAlert;
 import org.apache.hertzbeat.common.entity.alerter.SingleAlert;
 import org.junit.jupiter.api.AfterEach;
 import org.junit.jupiter.api.BeforeEach;
 import org.junit.jupiter.api.Test;
+import org.mockito.ArgumentCaptor;
 import org.mockito.Mock;
 import org.mockito.MockitoAnnotations;
 
@@ -146,6 +150,87 @@ class AlarmGroupReduceTest {
         assertEquals(1, maxConcurrent.get());
     }
 
+    /**
+     * Regression for issue #4160 (Bug 1): while one member of a group is 
still firing, the
+     * recovery of another member must not flip the whole group to resolved. 
The group has to
+     * stay firing until every member has actually cleared.
+     */
+    @Test
+    void whenOneMemberRecoversButAnotherStillFiring_groupMustNotResolve() {
+        AlertGroupConverge rule = groupRule();
+        alarmGroupReduce.refreshGroupDefines(Collections.singletonList(rule));
+
+        alarmGroupReduce.processGroupAlert(alert("cpu", "firing", "host1"));
+        alarmGroupReduce.processGroupAlert(alert("mem", "firing", "host1"));
+        // First group send: both members firing.
+        alarmGroupReduce.runCheckAndSendGroups();
+
+        // CPU recovers, memory is still firing.
+        alarmGroupReduce.processGroupAlert(alert("cpu", "resolved", "host1"));
+        alarmGroupReduce.runCheckAndSendGroups();
+
+        ArgumentCaptor<GroupAlert> captor = 
ArgumentCaptor.forClass(GroupAlert.class);
+        verify(alarmInhibitReduce, 
atLeastOnce()).inhibitAlarm(captor.capture());
+        List<GroupAlert> groups = captor.getAllValues();
+
+        // Memory never recovered, so no group push may ever carry a resolved 
group status.
+        assertTrue(groups.stream().noneMatch(g -> 
"resolved".equals(g.getStatus())),
+                "group wrongly resolved while a member alert was still 
firing");
+        // The CPU recovery is still communicated, inside a group that stays 
firing.
+        assertTrue(groups.stream().anyMatch(g -> "firing".equals(g.getStatus())
+                        && g.getAlerts().stream().anyMatch(
+                                a -> "cpu".equals(a.getFingerprint()) && 
"resolved".equals(a.getStatus()))),
+                "CPU recovery was not communicated within the still-firing 
group");
+    }
+
+    /**
+     * Regression for issue #4160 (Bug 2): a resolved transition that happens 
while the group is
+     * firing and inside the firing repeat-interval window must still be 
emitted. The firing
+     * throttle may only suppress repeated firing notifications, never a 
pending recovery.
+     */
+    @Test
+    void whenMemberRecoversInsideRepeatInterval_recoveryMustStillBeEmitted() {
+        AlertGroupConverge rule = groupRule();
+        alarmGroupReduce.refreshGroupDefines(Collections.singletonList(rule));
+
+        alarmGroupReduce.processGroupAlert(alert("cpu", "firing", "host1"));
+        alarmGroupReduce.processGroupAlert(alert("mem", "firing", "host1"));
+        // First send arms the firing repeat-interval throttle.
+        alarmGroupReduce.runCheckAndSendGroups();
+
+        // CPU keeps firing, memory recovers within the repeat interval.
+        alarmGroupReduce.processGroupAlert(alert("cpu", "firing", "host1"));
+        alarmGroupReduce.processGroupAlert(alert("mem", "resolved", "host1"));
+        alarmGroupReduce.runCheckAndSendGroups();
+
+        ArgumentCaptor<GroupAlert> captor = 
ArgumentCaptor.forClass(GroupAlert.class);
+        verify(alarmInhibitReduce, 
atLeastOnce()).inhibitAlarm(captor.capture());
+        List<GroupAlert> groups = captor.getAllValues();
+
+        assertTrue(groups.stream().anyMatch(g -> 
g.getAlerts().stream().anyMatch(
+                        a -> "mem".equals(a.getFingerprint()) && 
"resolved".equals(a.getStatus()))),
+                "memory recovery was silently dropped by the firing 
repeat-interval throttle");
+    }
+
+    private AlertGroupConverge groupRule() {
+        AlertGroupConverge rule = new AlertGroupConverge();
+        rule.setName("test-rule");
+        rule.setGroupLabels(Collections.singletonList("instance"));
+        rule.setGroupWait(0L);
+        rule.setGroupInterval(0L);
+        rule.setRepeatInterval(3600L);
+        return rule;
+    }
+
+    private SingleAlert alert(String fingerprint, String status, String 
instance) {
+        return SingleAlert.builder()
+                .fingerprint(fingerprint)
+                .status(status)
+                .labels(createLabels("instance", instance))
+                .annotations(new HashMap<>())
+                .build();
+    }
+
     private Map<String, String> createLabels(String... keyValues) {
         Map<String, String> labels = new HashMap<>();
         for (int i = 0; i < keyValues.length; i += 2) {


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to