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 71b4fe56c fix(alert): keep a metric-less rule out of the reconcile
membership test (#4748)
71b4fe56c is described below
commit 71b4fe56c53adb71713fa1ace9eeb1f09aee7250
Author: 烤化の初雪 <[email protected]>
AuthorDate: Thu Sep 24 17:46:32 2026 +0800
fix(alert): keep a metric-less rule out of the reconcile membership test
(#4748)
test(alert): pin the RESOLVED event and notification of the surviving rule
The fix's contract is not only that the reconcile pass keeps running: the
other rule of the scope must still resolve, record its RESOLVED lifecycle
event and queue its recovery notification. The first commit's test
asserted the state transition alone, so a pass that aborted silently after
the filter - the plausible wrong fix - would have satisfied it.
fix(alert): keep a rule without a metric out of the reconcile membership
test
The reconcile pass filtered the domain's rules with
scope.metricKeys().contains(StringUtils.trimWhitespace(rule.getMetric())).
A rule stored without a metric - legacy rows, and rules imported through
the JSON transfer where metric is not validated - makes that a
contains(null) call on the immutable set the scope holds, which throws
NullPointerException instead of answering false. The collection
scheduler catches RuntimeException and only logs it, so the whole
reconcile pass for that scope was skipped: every active alert of the
instance and domain stayed FIRING/ACKED forever, with no RESOLVED event
and no recovery notification.
Filter such a rule out before the membership test; it can never match a
collected sample, so it has no active state to reconcile.
---
.../studio/ops/alert/NativeAlertProcessor.java | 13 ++++++-
.../studio/ops/alert/NativeAlertProcessorTest.java | 42 ++++++++++++++++++++++
2 files changed, 54 insertions(+), 1 deletion(-)
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 5aca1d1ec..1371ad381 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
@@ -76,6 +76,17 @@ public class NativeAlertProcessor {
&& sample.labels().isEmpty());
}
+ /**
+ * A rule stored without a metric - legacy rows, and rules imported
through the JSON transfer
+ * where {@code metric} is not validated - belongs to no collection scope.
An absent metric
+ * must not reach the membership test either: {@link
MetricCollectionScope#metricKeys()} is an
+ * immutable set, whose {@code contains(null)} throws instead of answering
false.
+ */
+ private static String normalizedMetric(AlertRuleVO rule) {
+ String metric = StringUtils.trimWhitespace(rule.getMetric());
+ return metric == null ? "" : metric;
+ }
+
private void processSamples(List<MetricSample> samples) {
Map<AlertDomain, List<AlertRuleVO>> rulesByDomain = new
EnumMap<>(AlertDomain.class);
int failedEvaluations = 0;
@@ -102,7 +113,7 @@ public class NativeAlertProcessor {
List<AlertRuleVO> rules =
alertService.listRules(scope.domain()).stream()
.filter(rule -> rule.getId() != null)
.filter(AlertRuleVO::isEnabled)
- .filter(rule ->
scope.metricKeys().contains(StringUtils.trimWhitespace(rule.getMetric())))
+ .filter(rule ->
scope.metricKeys().contains(normalizedMetric(rule)))
.filter(rule -> !StringUtils.hasText(rule.getInstanceId())
||
scope.instanceId().equals(StringUtils.trimWhitespace(rule.getInstanceId())))
.toList();
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 869f2bbd6..be5313cd3 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
@@ -452,6 +452,48 @@ class NativeAlertProcessorTest {
verify(outbox).enqueue(any(SystemAlertVO.class), eq(rule),
eq(oldSample.labels()));
}
+ @Test
+ void reconcilesRemainingRulesWhenAnotherRuleHasNoMetricTest() {
+ AlertService service = mock(AlertService.class);
+ // A stored rule without a metric (legacy rows, and rules created
through the API or the
+ // JSON import, where `metric` carries no validation) is not part of
any collection scope,
+ // so it must be filtered out instead of aborting the reconcile pass
for the whole scope.
+ AlertRuleVO metricLess =
AlertRuleVO.builder().id(9L).domain(AlertDomain.BUSINESS).name("No metric")
+
.operator(">").threshold(10D).enabled(true).instanceId("local").consecutiveSamples(1).build();
+ AlertRuleVO rule = rule("local", "orders", 1);
+
when(service.listRules(AlertDomain.BUSINESS)).thenReturn(List.of(metricLess,
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);
+
+ NativeAlertProcessor processor = new NativeAlertProcessor(service,
+ new NativeAlertEvaluationService(new AlertRuleEvaluator(), new
AlertStateMachine(), states,
+ mock(MetricSnapshotRepository.class), alerts, outbox,
suppression()),
+ new AlertStateMachine(), states, alerts, outbox,
suppression(), mockTxManager());
+ assertThatCode(() -> processor.processSuccessfulCollection(new
MetricCollectionScope(AlertDomain.BUSINESS,
+ "local", java.util.Set.of("consumer.lag.total")),
List.of())).doesNotThrowAnyException();
+
+ 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());
+
assertThat(state.getValue().status()).isEqualTo(AlertStateStatus.RESOLVED);
+ // the recovery half of the same claim: without the fix the pass
aborts before reaching any
+ // of this, so no RESOLVED event is recorded and no recovery
notification is queued
+ verify(alerts).saveAlert(event.capture());
+
assertThat(event.getValue().getTransition()).isEqualTo(AlertStateTransition.RESOLVED.name());
+ verify(outbox).enqueue(any(SystemAlertVO.class), eq(rule),
eq(oldSample.labels()));
+ }
+
@Test
void
resolvesActiveStateForBlankInstanceIdRuleMissingFromCollectionScopeTest() {
AlertService service = mock(AlertService.class);