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 3c1382a28 fix(alerts): isolate bulk rule item transactions (#7221)
3c1382a28 is described below
commit 3c1382a28823a3a3dcfbef152df5db68c9ff1c7a
Author: aias00 <[email protected]>
AuthorDate: Mon Oct 26 16:34:45 2026 +0800
fix(alerts): isolate bulk rule item transactions (#7221)
* fix(alerts): isolate bulk rule item transactions
---
.../rocketmq/studio/ops/alert/AlertService.java | 71 +++++++++++-----
.../alert/AlertBulkMutationTransactionTest.java | 97 ++++++++++++++++++++++
.../ops/alert/AlertRuleStateLifecycleTest.java | 3 +-
.../ops/alert/AlertRuleTransferServiceTest.java | 3 +-
.../ops/alert/AlertServiceDefaultRulesTest.java | 6 +-
.../studio/ops/alert/AlertServiceTest.java | 6 +-
6 files changed, 158 insertions(+), 28 deletions(-)
diff --git
a/server/src/main/java/org/apache/rocketmq/studio/ops/alert/AlertService.java
b/server/src/main/java/org/apache/rocketmq/studio/ops/alert/AlertService.java
index 0c26bfec7..7c958018e 100644
---
a/server/src/main/java/org/apache/rocketmq/studio/ops/alert/AlertService.java
+++
b/server/src/main/java/org/apache/rocketmq/studio/ops/alert/AlertService.java
@@ -26,6 +26,9 @@ import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
+import org.springframework.transaction.PlatformTransactionManager;
+import org.springframework.transaction.TransactionDefinition;
+import org.springframework.transaction.support.TransactionTemplate;
import org.springframework.dao.DuplicateKeyException;
import org.springframework.util.StringUtils;
@@ -62,6 +65,7 @@ public class AlertService {
private final AlertRuleAssetService alertRuleAssetService;
private final OperationAuditService operationAuditService;
private final MetricProfileService metricProfileService;
+ private final PlatformTransactionManager transactionManager;
public List<AlertRuleVO> listRules() {
@@ -334,7 +338,6 @@ public class AlertService {
recordAudit("DELETE_ALERT_RULE", "ALERT_RULE", String.valueOf(id),
null, null);
}
- @Transactional
public AlertRuleBulkResultVO bulkToggleRules(List<Long> ids, boolean
enabled) {
List<Long> normalizedIds = normalizeBulkIds(ids);
Map<Long, AlertRuleVO> rulesById = rulesById(normalizedIds);
@@ -349,16 +352,20 @@ public class AlertService {
}
try {
rule.setEnabled(enabled);
- // replaceRule performs an existence-checked update; a rule
deleted between the
- // snapshot load and this call is reported as a failure
instead of being recreated.
- if (!alertRepository.replaceRule(rule)) {
+ if (!executeBulkMutation(() -> {
+ // A concurrently deleted rule must not be recreated.
+ if (!alertRepository.replaceRule(rule)) {
+ return false;
+ }
+ if (!enabled) {
+ alertStateRepository.deleteByRuleId(id);
+ }
+ auditRule("TOGGLE_ALERT_RULE", rule, "enabled=" + enabled
+ ", bulk=true");
+ return true;
+ })) {
failures.put(id, "Alert rule not found");
continue;
}
- if (!enabled) {
- alertStateRepository.deleteByRuleId(id);
- }
- auditRule("TOGGLE_ALERT_RULE", rule, "enabled=" + enabled + ",
bulk=true");
succeeded.add(id);
updated.add(rule);
} catch (RuntimeException failure) {
@@ -369,26 +376,29 @@ public class AlertService {
.succeededIds(succeeded).failures(failures).updatedRules(updated).build();
}
- @Transactional
public AlertRuleBulkResultVO bulkToggleRules(AlertDomain domain,
List<Long> ids, boolean enabled) {
requireDomain(domain);
return bulkUpdateRules(domain, ids, rule -> rule.setEnabled(enabled),
"TOGGLE_ALERT_RULE", "enabled=" + enabled + ", bulk=true");
}
- @Transactional
public AlertRuleBulkResultVO bulkDeleteRules(List<Long> ids) {
List<Long> normalizedIds = normalizeBulkIds(ids);
List<Long> succeeded = new ArrayList<>();
Map<Long, String> failures = new LinkedHashMap<>();
for (Long id : normalizedIds) {
try {
- if (!alertRepository.deleteRule(id)) {
+ if (!executeBulkMutation(() -> {
+ if (!alertRepository.deleteRule(id)) {
+ return false;
+ }
+ alertStateRepository.deleteByRuleId(id);
+ recordAudit("DELETE_ALERT_RULE", "ALERT_RULE",
String.valueOf(id), null, "bulk=true");
+ return true;
+ })) {
failures.put(id, "Alert rule not found");
continue;
}
- alertStateRepository.deleteByRuleId(id);
- recordAudit("DELETE_ALERT_RULE", "ALERT_RULE",
String.valueOf(id), null, "bulk=true");
succeeded.add(id);
} catch (RuntimeException failure) {
failures.put(id, failure.getMessage() == null ? "Delete
failed" : failure.getMessage());
@@ -398,7 +408,6 @@ public class AlertService {
.succeededIds(succeeded).failures(failures).updatedRules(List.of()).build();
}
- @Transactional
public AlertRuleBulkResultVO bulkDeleteRules(AlertDomain domain,
List<Long> ids) {
requireDomain(domain);
List<Long> normalizedIds = normalizeBulkIds(ids);
@@ -412,12 +421,17 @@ public class AlertService {
continue;
}
try {
- if (!alertRepository.deleteRule(id)) {
+ if (!executeBulkMutation(() -> {
+ if (!alertRepository.deleteRule(id)) {
+ return false;
+ }
+ alertStateRepository.deleteByRuleId(id);
+ recordAudit("DELETE_ALERT_RULE", "ALERT_RULE",
String.valueOf(id), null, "bulk=true");
+ return true;
+ })) {
failures.put(id, "Alert rule not found");
continue;
}
- alertStateRepository.deleteByRuleId(id);
- recordAudit("DELETE_ALERT_RULE", "ALERT_RULE",
String.valueOf(id), null, "bulk=true");
succeeded.add(id);
} catch (RuntimeException failure) {
failures.put(id, failure.getMessage() == null ? "Delete
failed" : failure.getMessage());
@@ -442,14 +456,19 @@ public class AlertService {
}
try {
update.accept(rule);
- if (!alertRepository.replaceRule(rule)) {
+ if (!executeBulkMutation(() -> {
+ if (!alertRepository.replaceRule(rule)) {
+ return false;
+ }
+ if (!rule.isEnabled()) {
+ alertStateRepository.deleteByRuleId(id);
+ }
+ auditRule(auditOperation, rule, auditDetail);
+ return true;
+ })) {
failures.put(id, "Alert rule not found");
continue;
}
- if (!rule.isEnabled()) {
- alertStateRepository.deleteByRuleId(id);
- }
- auditRule(auditOperation, rule, auditDetail);
succeeded.add(id);
updated.add(rule);
} catch (RuntimeException failure) {
@@ -460,6 +479,14 @@ public class AlertService {
.succeededIds(succeeded).failures(failures).updatedRules(updated).build();
}
+ private boolean executeBulkMutation(java.util.function.BooleanSupplier
mutation) {
+ TransactionTemplate transaction = new
TransactionTemplate(transactionManager);
+ // Catch failures only after rollback; a failed item must not affect
other items,
+ // even when the bulk operation is invoked from an existing
transaction.
+
transaction.setPropagationBehavior(TransactionDefinition.PROPAGATION_REQUIRES_NEW);
+ return Boolean.TRUE.equals(transaction.execute(status ->
mutation.getAsBoolean()));
+ }
+
private AlertRuleVO findRuleInDomain(AlertDomain domain, Long id) {
validateRuleId(id);
return alertRepository.findRuleById(id)
diff --git
a/server/src/test/java/org/apache/rocketmq/studio/ops/alert/AlertBulkMutationTransactionTest.java
b/server/src/test/java/org/apache/rocketmq/studio/ops/alert/AlertBulkMutationTransactionTest.java
new file mode 100644
index 000000000..308d0fad9
--- /dev/null
+++
b/server/src/test/java/org/apache/rocketmq/studio/ops/alert/AlertBulkMutationTransactionTest.java
@@ -0,0 +1,97 @@
+/*
+ * 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.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.CsvSource;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.test.context.SpringBootTest;
+import org.springframework.jdbc.core.JdbcTemplate;
+import org.springframework.test.context.ActiveProfiles;
+import org.springframework.test.context.bean.override.mockito.MockitoSpyBean;
+
+import java.util.List;
+import java.util.UUID;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.Mockito.doAnswer;
+
+@SpringBootTest
+@ActiveProfiles("dev")
+class AlertBulkMutationTransactionTest {
+ @Autowired
+ private AlertService service;
+ @Autowired
+ private AlertRepository rules;
+ @Autowired
+ private JdbcTemplate jdbc;
+ @MockitoSpyBean
+ private AlertStateRepository states;
+
+ @ParameterizedTest
+ @CsvSource({"false,false", "false,true", "true,false", "true,true"})
+ void failedItemRollsBackWhileOtherItemsCommitTest(boolean scoped, boolean
delete) {
+ Long failedId = insertRuleTest();
+ Long healthyId = insertRuleTest();
+ try {
+ doAnswer(invocation -> {
+ invocation.callRealMethod();
+ throw new IllegalStateException("state cleanup failed");
+ }).when(states).deleteByRuleId(failedId);
+ List<Long> ids = List.of(failedId, healthyId);
+ AlertRuleBulkResultVO result;
+ if (delete) {
+ result = scoped ?
service.bulkDeleteRules(AlertDomain.BUSINESS, ids)
+ : service.bulkDeleteRules(ids);
+ } else {
+ result = scoped ?
service.bulkToggleRules(AlertDomain.BUSINESS, ids, false)
+ : service.bulkToggleRules(ids, false);
+ }
+ assertThat(result.getSucceededIds()).containsExactly(healthyId);
+ assertThat(result.getFailures()).containsOnlyKeys(failedId);
+ assertThat(result.getUpdatedRules()).extracting(AlertRuleVO::getId)
+ .containsExactlyElementsOf(delete ? List.of() :
List.of(healthyId));
+ assertThat(rules.findRuleById(failedId)).isPresent();
+
assertThat(rules.findRuleById(failedId).orElseThrow().isEnabled()).isTrue();
+ assertThat(stateCountTest(failedId)).isEqualTo(1);
+ if (delete) {
+ assertThat(rules.findRuleById(healthyId)).isEmpty();
+ } else {
+
assertThat(rules.findRuleById(healthyId).orElseThrow().isEnabled()).isFalse();
+ }
+ assertThat(stateCountTest(healthyId)).isZero();
+ } finally {
+ for (Long id : List.of(failedId, healthyId)) {
+ jdbc.update("DELETE FROM rmq_alert_state WHERE rule_id = ?",
id);
+ rules.deleteRule(id);
+ }
+ }
+ }
+
+ private Long insertRuleTest() {
+ AlertRuleVO rule =
rules.insertRule(AlertRuleVO.builder().name("bulk-tx-" + UUID.randomUUID())
+
.domain(AlertDomain.BUSINESS).metric("consumer.lag.total").operator(">")
+
.threshold(10).enabled(true).instanceId("local").consumerGroup("orders-" +
UUID.randomUUID()).build());
+ jdbc.update("INSERT INTO rmq_alert_state (rule_id, fingerprint,
status, consecutive_hits) "
+ + "VALUES (?, ?, 'FIRING', 1)", rule.getId(), "bulk-tx-state");
+ return rule.getId();
+ }
+
+ private int stateCountTest(Long id) {
+ return jdbc.queryForObject("SELECT COUNT(*) FROM rmq_alert_state WHERE
rule_id = ?", Integer.class, id);
+ }
+}
diff --git
a/server/src/test/java/org/apache/rocketmq/studio/ops/alert/AlertRuleStateLifecycleTest.java
b/server/src/test/java/org/apache/rocketmq/studio/ops/alert/AlertRuleStateLifecycleTest.java
index 1c2bbeb03..37c00cb59 100644
---
a/server/src/test/java/org/apache/rocketmq/studio/ops/alert/AlertRuleStateLifecycleTest.java
+++
b/server/src/test/java/org/apache/rocketmq/studio/ops/alert/AlertRuleStateLifecycleTest.java
@@ -45,7 +45,8 @@ class AlertRuleStateLifecycleTest {
@BeforeEach
void setUpTest() {
service = new AlertService(rules, states, new AlertRuleAssetService(),
- mock(OperationAuditService.class), new
MetricProfileService(new PrometheusProperties()));
+ mock(OperationAuditService.class), new
MetricProfileService(new PrometheusProperties()),
+
org.mockito.Mockito.mock(org.springframework.transaction.PlatformTransactionManager.class));
storedRule.set(rule(true, 100, "Lag"));
runtime.set(AlertRuleRuntimeVO.builder().ruleId(4L).fingerprint("active-episode")
.status(AlertStateStatus.FIRING).lastNotifiedAt(LocalDateTime.of(2026, 9, 1,
12, 0))
diff --git
a/server/src/test/java/org/apache/rocketmq/studio/ops/alert/AlertRuleTransferServiceTest.java
b/server/src/test/java/org/apache/rocketmq/studio/ops/alert/AlertRuleTransferServiceTest.java
index b9849bb84..8d790ce24 100644
---
a/server/src/test/java/org/apache/rocketmq/studio/ops/alert/AlertRuleTransferServiceTest.java
+++
b/server/src/test/java/org/apache/rocketmq/studio/ops/alert/AlertRuleTransferServiceTest.java
@@ -107,7 +107,8 @@ class AlertRuleTransferServiceTest {
AlertRepository repository = mock(AlertRepository.class);
OperationAuditService auditService = mock(OperationAuditService.class);
AlertService realService = new AlertService(repository,
mock(AlertStateRepository.class),
- new AlertRuleAssetService(), auditService, new
MetricProfileService(new PrometheusProperties()));
+ new AlertRuleAssetService(), auditService, new
MetricProfileService(new PrometheusProperties()),
+
org.mockito.Mockito.mock(org.springframework.transaction.PlatformTransactionManager.class));
AlertRuleTransferService realTransfer = new
AlertRuleTransferService(realService, metricCatalogService);
lenient().when(repository.insertRule(any(AlertRuleVO.class)))
.thenAnswer(invocation -> invocation.getArgument(0));
diff --git
a/server/src/test/java/org/apache/rocketmq/studio/ops/alert/AlertServiceDefaultRulesTest.java
b/server/src/test/java/org/apache/rocketmq/studio/ops/alert/AlertServiceDefaultRulesTest.java
index a31d3bce5..0d23e3e23 100644
---
a/server/src/test/java/org/apache/rocketmq/studio/ops/alert/AlertServiceDefaultRulesTest.java
+++
b/server/src/test/java/org/apache/rocketmq/studio/ops/alert/AlertServiceDefaultRulesTest.java
@@ -42,7 +42,8 @@ class AlertServiceDefaultRulesTest {
AlertService service = new AlertService(repository,
Mockito.mock(AlertStateRepository.class),
new AlertRuleAssetService(),
Mockito.mock(OperationAuditService.class),
- new MetricProfileService(new PrometheusProperties()));
+ new MetricProfileService(new PrometheusProperties()),
+
org.mockito.Mockito.mock(org.springframework.transaction.PlatformTransactionManager.class));
String yaml = service.exportPrometheusRulesYaml();
int ruleCount = countRules(yaml);
@@ -56,7 +57,8 @@ class AlertServiceDefaultRulesTest {
AlertService service = new AlertService(repository,
Mockito.mock(AlertStateRepository.class),
new AlertRuleAssetService(),
Mockito.mock(OperationAuditService.class),
- new MetricProfileService(new PrometheusProperties()));
+ new MetricProfileService(new PrometheusProperties()),
+
org.mockito.Mockito.mock(org.springframework.transaction.PlatformTransactionManager.class));
String yaml = service.exportPrometheusRulesYaml();
assertTrue(yaml.contains("rocketmq-broker.rules"));
diff --git
a/server/src/test/java/org/apache/rocketmq/studio/ops/alert/AlertServiceTest.java
b/server/src/test/java/org/apache/rocketmq/studio/ops/alert/AlertServiceTest.java
index cd726daa2..793ca52de 100644
---
a/server/src/test/java/org/apache/rocketmq/studio/ops/alert/AlertServiceTest.java
+++
b/server/src/test/java/org/apache/rocketmq/studio/ops/alert/AlertServiceTest.java
@@ -70,7 +70,8 @@ class AlertServiceTest {
@org.junit.jupiter.api.BeforeEach
void setUpTest() {
alertService = new AlertService(alertRepository, alertStateRepository,
new AlertRuleAssetService(),
- operationAuditService, new MetricProfileService(new
PrometheusProperties()));
+ operationAuditService, new MetricProfileService(new
PrometheusProperties()),
+
org.mockito.Mockito.mock(org.springframework.transaction.PlatformTransactionManager.class));
}
@Test
@@ -566,7 +567,8 @@ class AlertServiceTest {
PrometheusProperties properties = new PrometheusProperties();
properties.setProfile(profileId);
return new AlertService(alertRepository, alertStateRepository, new
AlertRuleAssetService(),
- operationAuditService, new MetricProfileService(properties));
+ operationAuditService, new MetricProfileService(properties),
+
org.mockito.Mockito.mock(org.springframework.transaction.PlatformTransactionManager.class));
}
private MetricProfileVO.MetricMappingVO activeLagMapping(String profileId)
{