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 66519d03d fix(alert,deploy): fence stale rule evaluations; align
upgrade schema (#6312)
66519d03d is described below
commit 66519d03d21393fcde0cc8298ecce31be2147333
Author: pilichoumao <[email protected]>
AuthorDate: Wed Oct 28 00:38:55 2026 +0800
fix(alert,deploy): fence stale rule evaluations; align upgrade schema
(#6312)
* fix(alert): reject stale rule evaluations under a row lock (#6312)
Evaluation sampled a rule, spent time collecting and aggregating, then wrote
runtime state, lifecycle events and notification outbox rows against that
sample -
so a rule that was disabled or reconfigured in between still got the stale
write.
Both the sampled-evaluation and the missing-state reconciliation paths now
take a
current SELECT ... FOR UPDATE read of the rule inside their existing
transaction
(findRuleByIdForUpdate) and skip the write when the rule is gone, disabled,
or its
evaluation identity differs from the snapshot. The lock is held through the
writes,
so a concurrent management update waits and then applies its normal
runtime-state
cleanup. A locking read also observes committed management changes under
MySQL
REPEATABLE READ, which a second ordinary snapshot read would not. The
fingerprint
accessor is renamed of -> evaluationIdentity to match AlertService.
The test's timing assertion was replaced with a positive observation of the
lock
wait (information_schema.innodb_trx under MySQL,
INFORMATION_SCHEMA.SESSIONS under
H2) so it cannot pass vacuously on a slow CI runner, and twelve duplicated
stubCurrentRules calls were dropped in favour of the shared helper.
* fix(deploy): align cloud credential upgrade schema (#6210)
deploy/mysql/upgrade-cloud-vendor.sql created the credential table with
`id VARCHAR(64)`, `created_at` / `updated_at` and a `credential_id
VARCHAR(64)`
foreign key, while schema.sql - and the entity, which declares
`@TableId(type = AUTO) Long id` with gmtCreate / gmtModified - uses a bigint
auto-increment primary key and the gmt_* columns. A database built by the
upgrade
script therefore did not match the one built from schema.sql, and
CloudCredentialSchemaMigration only adds indexes and foreign keys, so
nothing
corrected the columns afterwards. Align the script with schema.sql column by
column.
Batch integration of two fixes by the same contributor; #6210 is closed as
merged
into this one. Verified: 237 backend tests across the affected classes,
AlertRuleEvaluationConcurrencyTest 6/6 on three consecutive runs, and
dropping
FOR UPDATE from the locking read fails
disableShouldWaitForLockedEvaluationAndThenPurgeItsStateTest - which is
what shows
the rewritten lock-wait assertion is really observing the lock.
---
deploy/mysql/upgrade-cloud-vendor.sql | 10 +-
docs/studio-native-alerting-design.md | 2 +
.../rocketmq/studio/ops/alert/AlertRepository.java | 3 +
.../ops/alert/MybatisPlusAlertRepository.java | 11 +
.../ops/alert/NativeAlertEvaluationService.java | 7 +
.../studio/ops/alert/NativeAlertProcessor.java | 6 +-
.../studio/ops/alert/NativeAlertRulePolicy.java | 9 +
.../alert/AlertRuleEvaluationConcurrencyTest.java | 259 +++++++++++++++++++++
.../alert/NativeAlertClusterCorrelationTest.java | 3 +
.../NativeAlertEvaluationTransactionTest.java | 1 +
.../studio/ops/alert/NativeAlertProcessorTest.java | 23 +-
.../persistence/DemoDataSqlCompatibilityTest.java | 52 +++++
12 files changed, 379 insertions(+), 7 deletions(-)
diff --git a/deploy/mysql/upgrade-cloud-vendor.sql
b/deploy/mysql/upgrade-cloud-vendor.sql
index ca7ac15e9..5dc6646ba 100644
--- a/deploy/mysql/upgrade-cloud-vendor.sql
+++ b/deploy/mysql/upgrade-cloud-vendor.sql
@@ -2,6 +2,7 @@
-- 存量 MySQL 数据卷增量迁移:rmq_instance 增加厂商维度 + 新增 rmq_cloud_credential 凭据表
-- 适用:数据卷已初始化、docker-entrypoint-initdb.d 不会再执行的存量部署。
-- 全新数据卷由 server/src/main/resources/db/schema.sql 直接覆盖,无需本脚本。
+-- 仅补充缺失的列和表;不会转换已有的 UUID 主键、凭据引用或旧时间列。
-- 幂等:可重复执行。
--
-- 用法(远程容器内执行):
@@ -51,7 +52,7 @@ SET @credential_id_column_exists := (
AND column_name = 'credential_id'
);
SET @credential_id_sql := IF(@credential_id_column_exists = 0,
- "ALTER TABLE rmq_instance ADD COLUMN credential_id VARCHAR(64) COMMENT '引用
rmq_cloud_credential.id' AFTER cloud_instance_id",
+ "ALTER TABLE rmq_instance ADD COLUMN credential_id bigint(20) unsigned
COMMENT '引用 rmq_cloud_credential.id' AFTER cloud_instance_id",
'SELECT 1');
PREPARE credential_id_statement FROM @credential_id_sql;
EXECUTE credential_id_statement;
@@ -93,13 +94,14 @@ UPDATE rmq_instance SET vendor = 'APACHE' WHERE vendor IS
NULL OR vendor = '';
-- 7. 云厂商凭据表(secret_key 为 base64 编码,禁止明文;access_key 明文用于唯一键与打码展示)
CREATE TABLE IF NOT EXISTS rmq_cloud_credential (
- id VARCHAR(64) PRIMARY KEY,
+ `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT COMMENT '主键',
+ `gmt_create` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
+ `gmt_modified` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE
CURRENT_TIMESTAMP COMMENT '修改时间',
name VARCHAR(128) NOT NULL COMMENT '凭据显示名',
vendor VARCHAR(32) NOT NULL COMMENT 'ALIYUN/TENCENT',
access_key VARCHAR(255) NOT NULL,
secret_key VARCHAR(512) NOT NULL COMMENT 'base64 编码的 SK',
remark VARCHAR(255),
- created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
- updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+ PRIMARY KEY (`id`),
UNIQUE KEY uk_vendor_access_key (vendor, access_key)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
diff --git a/docs/studio-native-alerting-design.md
b/docs/studio-native-alerting-design.md
index 963a40df6..bbddc7004 100644
--- a/docs/studio-native-alerting-design.md
+++ b/docs/studio-native-alerting-design.md
@@ -191,6 +191,8 @@ consecutiveSamples=2, severity=CRITICAL
The evaluator runs after a collector writes a new sample. It computes an
aggregation over the bounded local snapshot window, then advances an alert
state keyed by a stable fingerprint.
+Before writing runtime state, lifecycle events, or notification outbox rows,
both sampled evaluation and missing-state reconciliation take a current `SELECT
... FOR UPDATE` read of the rule inside their existing transaction. A deleted
or disabled rule, or a rule whose evaluation conditions differ from the
collection snapshot, is skipped. The lock remains held through those writes, so
a concurrent management update waits and then applies its normal runtime-state
cleanup. A current locking [...]
+
```text
OK -- condition met --> PENDING -- consecutive samples met --> FIRING
FIRING -- condition clear --> RESOLVED
diff --git
a/server/src/main/java/org/apache/rocketmq/studio/ops/alert/AlertRepository.java
b/server/src/main/java/org/apache/rocketmq/studio/ops/alert/AlertRepository.java
index 79ac05ad7..8e9ea3f12 100644
---
a/server/src/main/java/org/apache/rocketmq/studio/ops/alert/AlertRepository.java
+++
b/server/src/main/java/org/apache/rocketmq/studio/ops/alert/AlertRepository.java
@@ -32,6 +32,9 @@ public interface AlertRepository {
Optional<AlertRuleVO> findRuleById(Long id);
+ /** Current locking read; the caller must hold a transaction through all
lifecycle writes. */
+ Optional<AlertRuleVO> findRuleByIdForUpdate(Long id);
+
List<AlertRuleVO> findRulesByIds(List<Long> ids);
AlertRuleVO insertRule(AlertRuleVO rule);
diff --git
a/server/src/main/java/org/apache/rocketmq/studio/ops/alert/MybatisPlusAlertRepository.java
b/server/src/main/java/org/apache/rocketmq/studio/ops/alert/MybatisPlusAlertRepository.java
index 1c6e7e13d..607327a13 100644
---
a/server/src/main/java/org/apache/rocketmq/studio/ops/alert/MybatisPlusAlertRepository.java
+++
b/server/src/main/java/org/apache/rocketmq/studio/ops/alert/MybatisPlusAlertRepository.java
@@ -31,6 +31,7 @@ import
org.apache.rocketmq.studio.persistence.mapper.RmqAlertNotificationOutboxM
import org.apache.rocketmq.studio.persistence.mapper.RmqSystemAlertMapper;
import org.springframework.stereotype.Repository;
import org.springframework.transaction.annotation.Transactional;
+import org.springframework.transaction.annotation.Propagation;
import org.springframework.util.StringUtils;
import lombok.RequiredArgsConstructor;
@@ -119,6 +120,16 @@ public class MybatisPlusAlertRepository implements
AlertRepository {
.map(MybatisPlusAlertRepository::toRuleVO);
}
+ @Override
+ @Transactional(propagation = Propagation.MANDATORY)
+ public Optional<AlertRuleVO> findRuleByIdForUpdate(Long id) {
+ if (id == null) {
+ return Optional.empty();
+ }
+ return Optional.ofNullable(ruleMapper.selectOne(new
QueryWrapper<RmqAlertRule>()
+ .eq("id", id).last("FOR
UPDATE"))).map(MybatisPlusAlertRepository::toRuleVO);
+ }
+
@Override
public List<AlertRuleVO> findRulesByIds(List<Long> ids) {
if (ids == null || ids.isEmpty()) {
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
index fd490ea5a..a66db867a 100644
---
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
@@ -50,6 +50,13 @@ public class NativeAlertEvaluationService {
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void evaluate(AlertRuleVO rule, MetricSample sample) {
MetricSample evaluatedSample =
NativeAlertSampleAggregator.aggregate(rule, sample, snapshotRepository);
+ // Aggregation can block while management commits. A current locking
read, rather than
+ // another snapshot read, also sees those commits under MySQL
REPEATABLE READ.
+ Optional<AlertRuleVO> current =
NativeAlertRulePolicy.lockMatchingRule(alertRepository, rule);
+ if (current.isEmpty()) {
+ return;
+ }
+ rule = current.get();
AlertEvaluationResult evaluation = evaluator.evaluate(rule,
evaluatedSample);
if (!evaluation.matches()) {
return;
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 e8d8ace88..dd32d59c1 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
@@ -172,10 +172,14 @@ public class NativeAlertProcessor {
// emit is gated on the save result: an alert that already
moved on must
// not receive a RESOLVED event.
isolatedTx.executeWithoutResult(txStatus -> {
+ Optional<AlertRuleVO> current =
NativeAlertRulePolicy.lockMatchingRule(alertRepository, rule);
+ if (current.isEmpty()) {
+ return;
+ }
if (!stateRepository.save(active.key(), update.state())) {
return;
}
- emitLifecycleEvent(rule, active.key(), update,
scope.domain(),
+ emitLifecycleEvent(current.get(), active.key(), update,
scope.domain(),
active.instanceId(), rule.getMetric(),
active.labels(), resolvedAt);
});
} catch (RuntimeException error) {
diff --git
a/server/src/main/java/org/apache/rocketmq/studio/ops/alert/NativeAlertRulePolicy.java
b/server/src/main/java/org/apache/rocketmq/studio/ops/alert/NativeAlertRulePolicy.java
index b8a1085be..9e419f874 100644
---
a/server/src/main/java/org/apache/rocketmq/studio/ops/alert/NativeAlertRulePolicy.java
+++
b/server/src/main/java/org/apache/rocketmq/studio/ops/alert/NativeAlertRulePolicy.java
@@ -22,6 +22,7 @@ import org.springframework.util.StringUtils;
import java.util.Locale;
import java.util.Map;
import java.util.Set;
+import java.util.Optional;
/** Enforces the scope required to safely evaluate Studio-native metric rules.
*/
final class NativeAlertRulePolicy {
@@ -50,6 +51,14 @@ final class NativeAlertRulePolicy {
private NativeAlertRulePolicy() {
}
+ /** Lock before writing, then reject the old collection snapshot if its
rule no longer applies. */
+ static Optional<AlertRuleVO> lockMatchingRule(AlertRepository repository,
AlertRuleVO snapshot) {
+ return repository.findRuleByIdForUpdate(snapshot.getId())
+ .filter(AlertRuleVO::isEnabled)
+ .filter(current ->
AlertRuleSemanticFingerprint.evaluationIdentity(current)
+
.equals(AlertRuleSemanticFingerprint.evaluationIdentity(snapshot)));
+ }
+
static boolean isNativeMetric(String metric) {
return StringUtils.hasText(metric) &&
NATIVE_METRICS.containsKey(metric.trim());
}
diff --git
a/server/src/test/java/org/apache/rocketmq/studio/ops/alert/AlertRuleEvaluationConcurrencyTest.java
b/server/src/test/java/org/apache/rocketmq/studio/ops/alert/AlertRuleEvaluationConcurrencyTest.java
new file mode 100644
index 000000000..e64585ba9
--- /dev/null
+++
b/server/src/test/java/org/apache/rocketmq/studio/ops/alert/AlertRuleEvaluationConcurrencyTest.java
@@ -0,0 +1,259 @@
+/*
+ * 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 java.time.Instant;
+import java.util.List;
+import java.util.Map;
+import java.util.UUID;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.Executors;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicReference;
+import java.util.function.Consumer;
+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.AfterEach;
+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.jdbc.core.JdbcTemplate;
+import org.springframework.test.context.bean.override.mockito.MockitoBean;
+import org.springframework.test.context.bean.override.mockito.MockitoSpyBean;
+import org.springframework.test.util.AopTestUtils;
+import org.springframework.transaction.PlatformTransactionManager;
+import org.springframework.transaction.support.TransactionTemplate;
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.doAnswer;
+import static org.mockito.Mockito.when;
+
+/** Runs with the CI MySQL datasource, or with the dev/H2 profile for local
verification. */
+@SpringBootTest(properties =
"studio.alerting.notification-dispatch-interval=PT24H")
+class AlertRuleEvaluationConcurrencyTest {
+ @Autowired
+ private AlertService alerts;
+ @Autowired
+ private NativeAlertEvaluationService evaluation;
+ @Autowired
+ private NativeAlertProcessor processor;
+ @Autowired
+ private JdbcTemplate jdbc;
+ @Autowired
+ private PlatformTransactionManager transactionManager;
+ @MockitoBean
+ private MetricSnapshotRepository snapshots;
+ @MockitoSpyBean
+ private AlertRepository repository;
+ @MockitoSpyBean
+ private AlertStateRepository states;
+ private AlertRuleVO rule;
+ private MetricSample sample;
+
+ @BeforeEach
+ void initializeTest() {
+ String group = "race-" + UUID.randomUUID();
+ rule =
alerts.createRule(AlertRuleVO.builder().name(group).domain(AlertDomain.BUSINESS)
+
.metric("consumer.lag.total").operator(">").threshold(10).instanceId("probe-instance")
+
.consumerGroup(group).enabled(true).windowSeconds(60).channels(List.of("email")).build());
+ sample = new MetricSample(rule.getMetric(), AlertDomain.BUSINESS,
rule.getInstanceId(), null,
+ Map.of("consumerGroup", group), 20D,
MetricAvailability.AVAILABLE, Instant.now());
+ when(snapshots.findRecent(any(), any())).thenReturn(List.of(sample));
+ }
+
+ @AfterEach
+ void cleanupTest() {
+ if (rule == null) {
+ return;
+ }
+ jdbc.update("DELETE FROM rmq_alert_notification_outbox WHERE alert_id
IN "
+ + "(SELECT id FROM rmq_system_alert WHERE rule_id=?)",
rule.getId());
+ jdbc.update("DELETE FROM rmq_system_alert WHERE rule_id=?",
rule.getId());
+ jdbc.update("DELETE FROM rmq_alert_state WHERE rule_id=?",
rule.getId());
+ jdbc.update("DELETE FROM rmq_alert_rule WHERE id=?", rule.getId());
+ }
+
+ private String databaseProduct() {
+ return
jdbc.execute((org.springframework.jdbc.core.ConnectionCallback<String>)
connection ->
+ connection.getMetaData().getDatabaseProductName());
+ }
+
+ private long sessionId() {
+ return jdbc.queryForObject("MySQL".equals(databaseProduct())
+ ? "SELECT CONNECTION_ID()" : "SELECT SESSION_ID()",
Long.class);
+ }
+
+ /**
+ * Observes the management write actually waiting on the evaluation's row
lock, instead of
+ * inferring it from "still unfinished after a fixed delay" - a slow
runner would pass such an
+ * assertion without ever reaching the statement. MySQL reports the wait in
+ * information_schema.innodb_trx, the dev/H2 profile in
INFORMATION_SCHEMA.SESSIONS, so the
+ * check stays deterministic on both datasources and is skipped on neither.
+ */
+ private boolean awaitManagementLockWait(long timeoutMillis) throws
InterruptedException {
+ String waitingSessions = "MySQL".equals(databaseProduct())
+ ? "SELECT COUNT(*) FROM information_schema.innodb_trx WHERE
trx_state = 'LOCK WAIT'"
+ : "SELECT COUNT(*) FROM INFORMATION_SCHEMA.SESSIONS WHERE
SESSION_STATE = 'BLOCKED'";
+ long deadline = System.nanoTime() +
TimeUnit.MILLISECONDS.toNanos(timeoutMillis);
+ while (System.nanoTime() < deadline) {
+ Integer waiting = jdbc.queryForObject(waitingSessions,
Integer.class);
+ if (waiting != null && waiting > 0) {
+ return true;
+ }
+ Thread.sleep(5);
+ }
+ return false;
+ }
+
+ private void assertRows(int expectedStates, int expectedEvents) {
+ assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM rmq_alert_state
WHERE rule_id=?",
+ Integer.class, rule.getId())).isEqualTo(expectedStates);
+ assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM rmq_system_alert
WHERE rule_id=?",
+ Integer.class, rule.getId())).isEqualTo(expectedEvents);
+ assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM
rmq_alert_notification_outbox WHERE alert_id IN "
+ + "(SELECT id FROM rmq_system_alert WHERE rule_id=?)",
Integer.class, rule.getId()))
+ .isEqualTo(expectedEvents);
+ }
+
+ private void afterPausedEvaluation(Consumer<AlertRuleVO> mutation, int
expectedRows) throws Exception {
+ CountDownLatch started = new CountDownLatch(1);
+ CountDownLatch resume = new CountDownLatch(1);
+ AtomicReference<Long> evaluationSession = new AtomicReference<>();
+ when(snapshots.findRecent(any(), any())).thenAnswer(inv -> {
+ evaluationSession.set(sessionId());
+ // Establish a real MVCC snapshot before the management
transaction commits.
+
assertThat(repository.findRuleById(rule.getId()).orElseThrow().isEnabled()).isTrue();
+ started.countDown();
+ assertThat(resume.await(10, TimeUnit.SECONDS)).isTrue();
+ return List.of(sample);
+ });
+ try (var executor = Executors.newSingleThreadExecutor()) {
+ var pending = executor.submit(() -> evaluation.evaluate(rule,
sample));
+ try {
+ assertThat(started.await(10, TimeUnit.SECONDS)).isTrue();
+ Long controlSession = new
TransactionTemplate(transactionManager).execute(status -> {
+ long id = sessionId();
+
mutation.accept(repository.findRuleById(rule.getId()).orElseThrow());
+ return id;
+ });
+
assertThat(controlSession).isNotEqualTo(evaluationSession.get());
+ resume.countDown();
+ pending.get(10, TimeUnit.SECONDS);
+ assertRows(expectedRows, expectedRows);
+ } finally {
+ resume.countDown();
+ }
+ }
+ }
+
+ @Test
+ void committedDisableShouldRejectOldEvaluationTest() throws Exception {
+ afterPausedEvaluation(current ->
alerts.toggleRule(AlertDomain.BUSINESS, current.getId(), false), 0);
+
assertThat(repository.findRuleById(rule.getId()).orElseThrow().isEnabled()).isFalse();
+ }
+
+ @Test
+ void committedDeleteShouldRejectOldEvaluationTest() throws Exception {
+ afterPausedEvaluation(current ->
alerts.deleteRule(AlertDomain.BUSINESS, current.getId()), 0);
+ assertThat(repository.findRuleById(rule.getId())).isEmpty();
+ }
+
+ @Test
+ void changedConditionsShouldRejectOldEvaluationTest() throws Exception {
+ afterPausedEvaluation(current -> {
+ current.setThreshold(100);
+ alerts.updateRule(AlertDomain.BUSINESS, current);
+ }, 0);
+ }
+
+ @Test
+ void presentationEditShouldUseCurrentNotificationFieldsTest() throws
Exception {
+ afterPausedEvaluation(current -> {
+ current.setName("Updated presentation");
+ current.setNotificationTemplate("updated-template");
+ alerts.updateRule(AlertDomain.BUSINESS, current);
+ }, 1);
+ assertThat(jdbc.queryForObject("SELECT title FROM rmq_system_alert
WHERE rule_id=?",
+ String.class, rule.getId())).isEqualTo("Updated presentation");
+ assertThat(jdbc.queryForObject("SELECT message_content FROM
rmq_alert_notification_outbox WHERE alert_id IN "
+ + "(SELECT id FROM rmq_system_alert WHERE rule_id=?)",
String.class, rule.getId()))
+ .isEqualTo("updated-template");
+ }
+
+ @Test
+ void disableShouldWaitForLockedEvaluationAndThenPurgeItsStateTest() throws
Exception {
+ CountDownLatch locked = new CountDownLatch(1);
+ CountDownLatch resume = new CountDownLatch(1);
+ CountDownLatch managementStarted = new CountDownLatch(1);
+ doAnswer(inv -> {
+ Object current = inv.callRealMethod();
+ locked.countDown();
+ assertThat(resume.await(10, TimeUnit.SECONDS)).isTrue();
+ return current;
+ }).when((AlertRepository)
AopTestUtils.getUltimateTargetObject(repository))
+ .findRuleByIdForUpdate(rule.getId());
+ try (var executor = Executors.newFixedThreadPool(2)) {
+ var pending = executor.submit(() -> evaluation.evaluate(rule,
sample));
+ try {
+ assertThat(locked.await(10, TimeUnit.SECONDS)).isTrue();
+ var disable = executor.submit(() -> {
+ managementStarted.countDown();
+ alerts.toggleRule(AlertDomain.BUSINESS, rule.getId(),
false);
+ });
+ assertThat(managementStarted.await(10,
TimeUnit.SECONDS)).isTrue();
+
assertThat(awaitManagementLockWait(TimeUnit.SECONDS.toMillis(10))).isTrue();
+ resume.countDown();
+ pending.get(10, TimeUnit.SECONDS);
+ disable.get(10, TimeUnit.SECONDS);
+ assertRows(0, 1); // Existing events/outbox are history;
disabling only purges runtime state.
+
assertThat(repository.findRuleById(rule.getId()).orElseThrow().isEnabled()).isFalse();
+ } finally {
+ resume.countDown();
+ }
+ }
+ }
+
+ @Test
+ void committedDisableShouldRejectStaleMissingStateReconciliationTest()
throws Exception {
+ evaluation.evaluate(rule, sample);
+ assertRows(1, 1);
+ CountDownLatch read = new CountDownLatch(1);
+ CountDownLatch resume = new CountDownLatch(1);
+ doAnswer(inv -> {
+ Object active = inv.callRealMethod();
+ read.countDown();
+ assertThat(resume.await(10, TimeUnit.SECONDS)).isTrue();
+ return active;
+ }).when((AlertStateRepository)
AopTestUtils.getUltimateTargetObject(states)).findActive(any(), any());
+ try (var executor = Executors.newSingleThreadExecutor()) {
+ var pending = executor.submit(() ->
processor.processSuccessfulCollection(new MetricCollectionScope(
+ AlertDomain.BUSINESS, rule.getInstanceId(),
java.util.Set.of(rule.getMetric())), List.of()));
+ try {
+ assertThat(read.await(10, TimeUnit.SECONDS)).isTrue();
+ alerts.toggleRule(AlertDomain.BUSINESS, rule.getId(), false);
+ resume.countDown();
+ pending.get(10, TimeUnit.SECONDS);
+ assertRows(0, 1);
+ } finally {
+ resume.countDown();
+ }
+ }
+ }
+}
diff --git
a/server/src/test/java/org/apache/rocketmq/studio/ops/alert/NativeAlertClusterCorrelationTest.java
b/server/src/test/java/org/apache/rocketmq/studio/ops/alert/NativeAlertClusterCorrelationTest.java
index 5d590afe5..945a73d73 100644
---
a/server/src/test/java/org/apache/rocketmq/studio/ops/alert/NativeAlertClusterCorrelationTest.java
+++
b/server/src/test/java/org/apache/rocketmq/studio/ops/alert/NativeAlertClusterCorrelationTest.java
@@ -67,6 +67,7 @@ class NativeAlertClusterCorrelationTest {
when(states.find(any())).thenReturn(Optional.empty());
when(states.save(any(), any())).thenReturn(true);
AlertRepository alerts = mock(AlertRepository.class);
+
when(alerts.findRuleByIdForUpdate(rule.getId())).thenReturn(Optional.of(rule));
when(alerts.saveAlert(any())).thenAnswer(invocation ->
invocation.getArgument(0));
NotificationOutboxService outbox =
mock(NotificationOutboxService.class);
AlertNotificationSuppressionService suppression =
mock(AlertNotificationSuppressionService.class);
@@ -143,6 +144,8 @@ class NativeAlertClusterCorrelationTest {
when(states.save(any(), any())).thenReturn(true);
List<SystemAlertVO> emitted = new ArrayList<>();
AlertRepository alerts = mock(AlertRepository.class);
+
when(alerts.findRuleByIdForUpdate(clusterRule.getId())).thenReturn(Optional.of(clusterRule));
+
when(alerts.findRuleByIdForUpdate(businessRule.getId())).thenReturn(Optional.of(businessRule));
when(alerts.saveAlert(any())).thenAnswer(invocation -> {
SystemAlertVO event = invocation.getArgument(0);
event.setId((long) emitted.size() + 1);
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
index 35a770281..6af0b6f77 100644
---
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
@@ -86,6 +86,7 @@ class NativeAlertEvaluationTransactionTest {
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();
+
when(alertRepository.findRuleByIdForUpdate(rule.getId())).thenReturn(Optional.of(rule));
MetricSample sample = new MetricSample("consumer.lag.total",
AlertDomain.BUSINESS, "local", null,
Map.of("consumerGroup", "orders"), 20D,
MetricAvailability.AVAILABLE, Instant.now());
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 ce0e77e09..9ff55fed2 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
@@ -172,7 +172,7 @@ class NativeAlertProcessorTest {
processor.process(List.of(sample("orders")));
assertThat(saved.values()).singleElement().extracting(AlertRuleState::status)
.isEqualTo(AlertStateStatus.PENDING);
- verifyNoInteractions(alerts);
+ verify(alerts, never()).saveAlert(any(SystemAlertVO.class));
processor.process(List.of(sample("orders")));
assertThat(saved.values()).singleElement().extracting(AlertRuleState::status)
@@ -271,7 +271,7 @@ class NativeAlertProcessorTest {
processor(service, states, alerts).process(List.of(sample("orders")));
- verifyNoInteractions(alerts);
+ verify(alerts, never()).saveAlert(any(SystemAlertVO.class));
}
@Test
@@ -432,9 +432,18 @@ class NativeAlertProcessorTest {
org.mockito.ArgumentMatchers.anyMap());
}
+ private static void stubCurrentRules(AlertService service, AlertRepository
repository) {
+
when(repository.findRuleByIdForUpdate(any(Long.class))).thenAnswer(invocation ->
+
java.util.stream.Stream.concat(service.listRules(AlertDomain.BUSINESS).stream(),
+
service.listRules(AlertDomain.CLUSTER).stream())
+ .filter(rule -> java.util.Objects.equals(rule.getId(),
invocation.getArgument(0)))
+ .findFirst());
+ }
+
private static NativeAlertProcessor processor(AlertService service,
AlertStateRepository states,
MetricSnapshotRepository snapshots, AlertRepository alerts,
NotificationOutboxService outbox,
AlertNotificationSuppressionService suppression) {
+ stubCurrentRules(service, alerts);
NativeAlertEvaluationService evaluationService = new
NativeAlertEvaluationService(new AlertRuleEvaluator(),
new AlertStateMachine(), states, snapshots, alerts, outbox,
suppression);
return new NativeAlertProcessor(service, evaluationService, new
AlertStateMachine(), states, alerts, outbox,
@@ -512,6 +521,7 @@ class NativeAlertProcessorTest {
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);
+ stubCurrentRules(service, alerts);
when(alerts.saveAlert(any(SystemAlertVO.class))).thenAnswer(invocation
-> invocation.getArgument(0));
NotificationOutboxService outbox =
mock(NotificationOutboxService.class);
@@ -553,6 +563,7 @@ class NativeAlertProcessorTest {
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);
+ stubCurrentRules(service, alerts);
when(alerts.saveAlert(any(SystemAlertVO.class))).thenAnswer(invocation
-> invocation.getArgument(0));
NotificationOutboxService outbox =
mock(NotificationOutboxService.class);
@@ -593,6 +604,7 @@ class NativeAlertProcessorTest {
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);
+ stubCurrentRules(service, alerts);
when(alerts.saveAlert(any(SystemAlertVO.class))).thenAnswer(invocation
-> invocation.getArgument(0));
NotificationOutboxService outbox =
mock(NotificationOutboxService.class);
@@ -626,6 +638,7 @@ class NativeAlertProcessorTest {
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);
+ stubCurrentRules(service, alerts);
NativeAlertProcessor processor = new NativeAlertProcessor(service,
new NativeAlertEvaluationService(new AlertRuleEvaluator(), new
AlertStateMachine(), states,
@@ -659,6 +672,7 @@ class NativeAlertProcessorTest {
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);
+ stubCurrentRules(service, alerts);
NotificationOutboxService outbox =
mock(NotificationOutboxService.class);
NativeAlertProcessor processor = new NativeAlertProcessor(service,
@@ -692,6 +706,7 @@ class NativeAlertProcessorTest {
when(states.findActive(any(MetricCollectionScope.class),
eq(List.of(rule)))).thenReturn(List.of(active));
when(states.save(eq(key), any(AlertRuleState.class))).thenReturn(true);
AlertRepository alerts = mock(AlertRepository.class);
+ stubCurrentRules(service, alerts);
when(alerts.saveAlert(any(SystemAlertVO.class))).thenAnswer(invocation
-> invocation.getArgument(0));
NotificationOutboxService outbox =
mock(NotificationOutboxService.class);
@@ -730,6 +745,7 @@ class NativeAlertProcessorTest {
AlertStateRepository states = mock(AlertStateRepository.class);
when(states.findActive(any(MetricCollectionScope.class),
eq(List.of(rule)))).thenReturn(List.of(active));
AlertRepository alerts = mock(AlertRepository.class);
+ stubCurrentRules(service, alerts);
NativeAlertProcessor processor = new NativeAlertProcessor(service,
new NativeAlertEvaluationService(new AlertRuleEvaluator(), new
AlertStateMachine(), states,
@@ -899,6 +915,7 @@ class NativeAlertProcessorTest {
"local", paymentsSample.labels());
when(states.findActive(any(MetricCollectionScope.class),
any())).thenReturn(List.of(active1, active2));
AlertRepository alerts = mock(AlertRepository.class);
+ stubCurrentRules(service, alerts);
when(alerts.saveAlert(any(SystemAlertVO.class)))
.thenThrow(new IllegalStateException("db write failed"))
.thenAnswer(invocation -> invocation.getArgument(0));
@@ -932,6 +949,7 @@ class NativeAlertProcessorTest {
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);
+ stubCurrentRules(service, alerts);
when(alerts.saveAlert(any(SystemAlertVO.class)))
.thenThrow(new IllegalStateException("event persist failed"));
NotificationOutboxService outbox =
mock(NotificationOutboxService.class);
@@ -970,6 +988,7 @@ class NativeAlertProcessorTest {
// optimistic race and returns false
when(states.save(eq(oldKey),
any(AlertRuleState.class))).thenReturn(false);
AlertRepository alerts = mock(AlertRepository.class);
+ stubCurrentRules(service, alerts);
NotificationOutboxService outbox =
mock(NotificationOutboxService.class);
NativeAlertProcessor processor = new NativeAlertProcessor(service,
diff --git
a/server/src/test/java/org/apache/rocketmq/studio/persistence/DemoDataSqlCompatibilityTest.java
b/server/src/test/java/org/apache/rocketmq/studio/persistence/DemoDataSqlCompatibilityTest.java
index 2f43f2376..f598ba5ac 100644
---
a/server/src/test/java/org/apache/rocketmq/studio/persistence/DemoDataSqlCompatibilityTest.java
+++
b/server/src/test/java/org/apache/rocketmq/studio/persistence/DemoDataSqlCompatibilityTest.java
@@ -30,6 +30,9 @@ import java.sql.DriverManager;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.sql.Statement;
+import java.sql.Types;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
@@ -87,6 +90,55 @@ class DemoDataSqlCompatibilityTest {
}
}
+ @Test
+ void cloudVendorUpgradeCreatesUsableNumericCredentialsTest() throws
Exception {
+ String script =
Files.readString(resolveDeployScript("upgrade-cloud-vendor.sql"),
StandardCharsets.UTF_8);
+ // H2 cannot execute the MySQL PREPARE/EXECUTE guards. Exercise the
actual DDL
+ // selected by those guards; full-script guard coverage requires MySQL.
+ Matcher credentialColumn = Pattern.compile("\"(ALTER TABLE
rmq_instance ADD COLUMN credential_id [^\"]+)\"")
+ .matcher(script);
+ assertThat(credentialColumn.find()).isTrue();
+ String createCredentials = script.substring(script.indexOf("CREATE
TABLE IF NOT EXISTS rmq_cloud_credential"));
+ try (Connection connection = DriverManager.getConnection(
+
"jdbc:h2:mem:cloud-vendor-sql;MODE=MySQL;DATABASE_TO_LOWER=TRUE", "sa", "");
+ Statement statement = connection.createStatement()) {
+ statement.executeUpdate("CREATE TABLE rmq_instance (id BIGINT
AUTO_INCREMENT PRIMARY KEY,"
+ + " cloud_instance_id VARCHAR(128))");
+ statement.executeUpdate(credentialColumn.group(1));
+ ScriptUtils.executeSqlScript(connection, new ByteArrayResource(
+ createCredentials.getBytes(StandardCharsets.UTF_8), "cloud
credential DDL"));
+
+ assertThat(hasColumn(connection, "rmq_cloud_credential",
"gmt_create")).isTrue();
+ assertThat(hasColumn(connection, "rmq_cloud_credential",
"gmt_modified")).isTrue();
+ assertThat(hasColumn(connection, "rmq_cloud_credential",
"created_at")).isFalse();
+ try (ResultSet columns = connection.getMetaData().getColumns(null,
null, "rmq_cloud_credential", "id")) {
+ assertThat(columns.next()).isTrue();
+
assertThat(columns.getInt("DATA_TYPE")).isEqualTo(Types.BIGINT);
+
assertThat(columns.getString("IS_AUTOINCREMENT")).isEqualTo("YES");
+ }
+ try (ResultSet columns = connection.getMetaData().getColumns(null,
null, "rmq_instance", "credential_id")) {
+ assertThat(columns.next()).isTrue();
+
assertThat(columns.getInt("DATA_TYPE")).isEqualTo(Types.BIGINT);
+ }
+ statement.executeUpdate("INSERT INTO rmq_cloud_credential (name,
vendor, access_key, secret_key)"
+ + " VALUES ('test-credential', 'ALIYUN',
'test-access-key', 'dGVzdA==')");
+ statement.executeUpdate("INSERT INTO rmq_instance (credential_id)"
+ + " SELECT id FROM rmq_cloud_credential WHERE name =
'test-credential'");
+ statement.executeUpdate("ALTER TABLE rmq_instance ADD CONSTRAINT
fk_test_credential"
+ + " FOREIGN KEY (credential_id) REFERENCES
rmq_cloud_credential(id)");
+ ScriptUtils.executeSqlScript(connection, new ByteArrayResource(
+ createCredentials.getBytes(StandardCharsets.UTF_8),
"repeated cloud credential DDL"));
+ try (ResultSet rows = statement.executeQuery("SELECT c.id,
c.gmt_create, c.gmt_modified"
+ + " FROM rmq_cloud_credential c JOIN rmq_instance i ON
i.credential_id = c.id")) {
+ assertThat(rows.next()).isTrue();
+ assertThat(rows.getLong("id")).isPositive();
+ assertThat(rows.getTimestamp("gmt_create")).isNotNull();
+ assertThat(rows.getTimestamp("gmt_modified")).isNotNull();
+ assertThat(rows.next()).isFalse();
+ }
+ }
+ }
+
@Test
void schemaShouldEnforceSingletonSettingsKeyTest() throws Exception {
try (Connection connection = DriverManager.getConnection(