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 f274d6a0a fix: protect notification outbox deliveries from lease loss 
(#2670)
f274d6a0a is described below

commit f274d6a0a39fc9229e49b3df87251e354fcc9e76
Author: xdz997 <[email protected]>
AuthorDate: Wed Sep 2 19:59:44 2026 +0800

    fix: protect notification outbox deliveries from lease loss (#2670)
---
 docs/studio-native-alerting-design.md              |   7 +-
 .../studio/cluster/metrics/AlertingProperties.java |   6 +
 .../ops/alert/NotificationOutboxService.java       | 211 ++++++++++++++++--
 .../mapper/RmqAlertNotificationOutboxMapper.java   |   5 +
 server/src/main/resources/application.yml          |   3 +
 .../ops/alert/NotificationOutboxServiceTest.java   | 244 +++++++++++++++++++++
 6 files changed, 452 insertions(+), 24 deletions(-)

diff --git a/docs/studio-native-alerting-design.md 
b/docs/studio-native-alerting-design.md
index 9d8d2afc2..06c0e69a6 100644
--- a/docs/studio-native-alerting-design.md
+++ b/docs/studio-native-alerting-design.md
@@ -246,12 +246,17 @@ rmq_metric_collector_lease
   collector_name, owner_id, expires_at
 ```
 
-Only the active lease holder collects and evaluates. Notification outbox rows 
use claimant-bound state transitions so a stale worker cannot overwrite a newer 
claimant. Delivery is at-least-once: receivers should use the event and channel 
identity to deduplicate a request that times out after reaching the remote 
service.
+Only the active lease holder collects and evaluates. Notification outbox rows 
use claimant-bound state transitions so a stale worker cannot overwrite a newer 
claimant. A delivery worker renews its claim while a webhook or SMTP call is in 
flight; the renewal is conditional on the claim token, and a worker that loses 
the lease stops updating that row. The delivery state is committed before its 
audit entry, so an audit-store failure cannot turn a completed external send 
into another retry.  [...]
 
 ## Notifications and Silences
 
 The supported notification channels are DingTalk, the SMS webhook configured 
in General Settings, and Email. A real event creates outbox rows for enabled 
supported channels. Independent notification-channel and notification-policy 
CRUD are future work.
 
+The dispatcher claims a row for one minute by default and renews the claim 
every 20 seconds while the external
+delivery is running. Deployments with slower receivers can tune 
`studio.alerting.notification-claim-timeout` and
+`studio.alerting.notification-claim-renewal-interval`; the renewal interval 
must remain shorter than the claim timeout.
+`studio.alerting.notification-heartbeat-threads` bounds the daemon workers 
used for these renewals.
+
 Email delivery uses Spring's standard SMTP configuration. Configure 
`STUDIO_ALERTING_SMTP_HOST`,
 `STUDIO_ALERTING_SMTP_PORT`, `STUDIO_ALERTING_SMTP_USERNAME`, 
`STUDIO_ALERTING_SMTP_PASSWORD`,
 `STUDIO_ALERTING_SMTP_AUTH`, and `STUDIO_ALERTING_SMTP_STARTTLS`; recipient 
addresses are configured in
diff --git 
a/server/src/main/java/org/apache/rocketmq/studio/cluster/metrics/AlertingProperties.java
 
b/server/src/main/java/org/apache/rocketmq/studio/cluster/metrics/AlertingProperties.java
index 763e048fa..a7773976c 100644
--- 
a/server/src/main/java/org/apache/rocketmq/studio/cluster/metrics/AlertingProperties.java
+++ 
b/server/src/main/java/org/apache/rocketmq/studio/cluster/metrics/AlertingProperties.java
@@ -40,4 +40,10 @@ public class AlertingProperties {
     private int notificationCleanupBatchSize = 500;
     /** Maximum number of cleanup batches executed during one scheduled pass. 
*/
     private int notificationCleanupMaxBatches = 10;
+    /** How long a notification dispatcher may hold an outbox row without 
renewing it. */
+    private String notificationClaimTimeout = "PT1M";
+    /** How often an in-flight notification claim is renewed. */
+    private String notificationClaimRenewalInterval = "PT20S";
+    /** Bounded daemon threads used for notification claim renewal. */
+    private int notificationHeartbeatThreads = 2;
 }
diff --git 
a/server/src/main/java/org/apache/rocketmq/studio/ops/alert/NotificationOutboxService.java
 
b/server/src/main/java/org/apache/rocketmq/studio/ops/alert/NotificationOutboxService.java
index fce7b9336..b94fe069f 100644
--- 
a/server/src/main/java/org/apache/rocketmq/studio/ops/alert/NotificationOutboxService.java
+++ 
b/server/src/main/java/org/apache/rocketmq/studio/ops/alert/NotificationOutboxService.java
@@ -11,6 +11,7 @@ import com.fasterxml.jackson.databind.JsonNode;
 import com.fasterxml.jackson.databind.ObjectMapper;
 import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
 import com.baomidou.mybatisplus.core.conditions.update.UpdateWrapper;
+import jakarta.annotation.PreDestroy;
 import lombok.extern.slf4j.Slf4j;
 import org.apache.rocketmq.studio.audit.OperationAuditService;
 import org.apache.rocketmq.studio.cluster.metrics.AlertingProperties;
@@ -42,6 +43,12 @@ import java.util.Set;
 import java.util.ArrayList;
 import java.util.function.Supplier;
 import java.util.UUID;
+import java.util.concurrent.Executors;
+import java.util.concurrent.ScheduledExecutorService;
+import java.util.concurrent.ScheduledFuture;
+import java.util.concurrent.ThreadFactory;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicBoolean;
 import java.net.URLEncoder;
 import java.nio.charset.StandardCharsets;
 import java.util.Base64;
@@ -56,7 +63,10 @@ import jakarta.mail.internet.InternetAddress;
 public class NotificationOutboxService {
     private static final int MAX_ATTEMPTS = 5;
     private static final int BATCH_SIZE = 20;
-    private static final Duration CLAIM_TIMEOUT = Duration.ofMinutes(1);
+    private static final Duration DEFAULT_CLAIM_TIMEOUT = 
Duration.ofMinutes(1);
+    private static final Duration DEFAULT_CLAIM_RENEWAL_INTERVAL = 
Duration.ofSeconds(20);
+    private static final int DEFAULT_HEARTBEAT_THREADS = 2;
+    private static final int MAX_HEARTBEAT_THREADS = 4;
     private static final ObjectMapper JSON = new ObjectMapper();
 
     private final RmqAlertNotificationOutboxMapper mapper;
@@ -67,12 +77,15 @@ public class NotificationOutboxService {
     private final RestTemplate restTemplate;
     private final Supplier<JavaMailSender> mailSender;
     private final AlertingProperties alertingProperties;
+    private final Duration claimTimeout;
+    private final Duration claimRenewalInterval;
+    private final ScheduledExecutorService heartbeatExecutor;
 
     NotificationOutboxService(RmqAlertNotificationOutboxMapper mapper, 
SettingsRepository settingsRepository,
             AlertSilenceService silenceService, AlertRepository 
alertRepository,
             OperationAuditService operationAuditService) {
         this(mapper, settingsRepository, silenceService, alertRepository, 
operationAuditService, newClient(),
-                () -> null, new AlertingProperties());
+                () -> null, new AlertingProperties(), null);
     }
 
     @Autowired
@@ -81,21 +94,21 @@ public class NotificationOutboxService {
             OperationAuditService operationAuditService, 
ObjectProvider<JavaMailSender> mailSender,
             AlertingProperties alertingProperties) {
         this(mapper, settingsRepository, silenceService, alertRepository, 
operationAuditService, newClient(),
-                mailSender::getIfAvailable, alertingProperties);
+                mailSender::getIfAvailable, alertingProperties, null);
     }
 
     NotificationOutboxService(RmqAlertNotificationOutboxMapper mapper, 
SettingsRepository settingsRepository,
             AlertSilenceService silenceService, AlertRepository 
alertRepository,
             OperationAuditService operationAuditService, RestTemplate 
restTemplate) {
         this(mapper, settingsRepository, silenceService, alertRepository, 
operationAuditService, restTemplate,
-                () -> null, new AlertingProperties());
+                () -> null, new AlertingProperties(), null);
     }
 
     NotificationOutboxService(RmqAlertNotificationOutboxMapper mapper, 
SettingsRepository settingsRepository,
             AlertSilenceService silenceService, AlertRepository 
alertRepository,
             OperationAuditService operationAuditService, AlertingProperties 
alertingProperties) {
         this(mapper, settingsRepository, silenceService, alertRepository, 
operationAuditService, newClient(),
-                () -> null, alertingProperties);
+                () -> null, alertingProperties, null);
     }
 
     NotificationOutboxService(RmqAlertNotificationOutboxMapper mapper, 
SettingsRepository settingsRepository,
@@ -103,13 +116,14 @@ public class NotificationOutboxService {
             OperationAuditService operationAuditService, RestTemplate 
restTemplate,
             Supplier<JavaMailSender> mailSender) {
         this(mapper, settingsRepository, silenceService, alertRepository, 
operationAuditService, restTemplate,
-                mailSender, new AlertingProperties());
+                mailSender, new AlertingProperties(), null);
     }
 
     NotificationOutboxService(RmqAlertNotificationOutboxMapper mapper, 
SettingsRepository settingsRepository,
             AlertSilenceService silenceService, AlertRepository 
alertRepository,
             OperationAuditService operationAuditService, RestTemplate 
restTemplate,
-            Supplier<JavaMailSender> mailSender, AlertingProperties 
alertingProperties) {
+            Supplier<JavaMailSender> mailSender, AlertingProperties 
alertingProperties,
+            ScheduledExecutorService heartbeatExecutor) {
         this.mapper = mapper;
         this.settingsRepository = settingsRepository;
         this.silenceService = silenceService;
@@ -118,6 +132,18 @@ public class NotificationOutboxService {
         this.restTemplate = restTemplate;
         this.mailSender = mailSender;
         this.alertingProperties = alertingProperties == null ? new 
AlertingProperties() : alertingProperties;
+        this.claimTimeout = 
positiveDuration(this.alertingProperties.getNotificationClaimTimeout(),
+                DEFAULT_CLAIM_TIMEOUT, "notificationClaimTimeout");
+        Duration configuredRenewal = positiveDuration(
+                this.alertingProperties.getNotificationClaimRenewalInterval(),
+                DEFAULT_CLAIM_RENEWAL_INTERVAL, 
"notificationClaimRenewalInterval");
+        this.claimRenewalInterval = safeRenewalInterval(configuredRenewal, 
claimTimeout);
+        this.heartbeatExecutor = heartbeatExecutor == null ? 
newHeartbeatExecutor(this.alertingProperties) : heartbeatExecutor;
+    }
+
+    @PreDestroy
+    void closeHeartbeatExecutor() {
+        heartbeatExecutor.shutdownNow();
     }
 
     public void enqueue(SystemAlertVO alert, AlertRuleVO rule) {
@@ -215,7 +241,7 @@ public class NotificationOutboxService {
             throw new 
org.apache.rocketmq.studio.common.exception.BusinessException(400,
                     "Only failed notification deliveries can be retried");
         }
-        recordDelivery(row, "RETRY_ALERT_NOTIFICATION_MANUALLY", "SUCCESS", 
null);
+        recordDeliverySafely(row, "RETRY_ALERT_NOTIFICATION_MANUALLY", 
"SUCCESS", null);
     }
 
     public NotificationDeliveryBulkRetryResult 
retryFailedDeliveries(List<Long> deliveryIds) {
@@ -260,15 +286,20 @@ public class NotificationOutboxService {
 
     @Scheduled(fixedDelayString = 
"${studio.alerting.notification-dispatch-interval:PT10S}")
     public void dispatch() {
-        LocalDateTime now = utcNow();
-        LocalDateTime staleBefore = now.minus(CLAIM_TIMEOUT);
-        List<RmqAlertNotificationOutbox> due = mapper.findDispatchable(now, 
staleBefore, BATCH_SIZE);
+        LocalDateTime dispatchStartedAt = utcNow();
+        LocalDateTime staleBefore = dispatchStartedAt.minus(claimTimeout);
+        List<RmqAlertNotificationOutbox> due = 
mapper.findDispatchable(dispatchStartedAt, staleBefore, BATCH_SIZE);
         for (RmqAlertNotificationOutbox row : due) {
             String claimToken = UUID.randomUUID().toString();
-            if (mapper.claimForDispatch(row.getId(), now, staleBefore, now, 
claimToken) != 1) {
+            // A previous delivery in this batch may have taken a long time. 
Use a fresh timestamp for
+            // each claim so a row cannot be born with an already-expired 
lease.
+            LocalDateTime claimStartedAt = utcNow();
+            LocalDateTime claimStaleBefore = 
claimStartedAt.minus(claimTimeout);
+            if (mapper.claimForDispatch(row.getId(), claimStartedAt, 
claimStaleBefore, claimStartedAt,
+                    claimToken) != 1) {
                 continue;
             }
-            send(row, now, claimToken);
+            send(row, claimToken);
         }
     }
 
@@ -305,12 +336,19 @@ public class NotificationOutboxService {
         return total;
     }
 
-    private void send(RmqAlertNotificationOutbox row, LocalDateTime now, 
String claimToken) {
+    private void send(RmqAlertNotificationOutbox row, String claimToken) {
+        LeaseHeartbeat heartbeat = null;
+        boolean externalSendCompleted = false;
         try {
+            heartbeat = new LeaseHeartbeat(row.getId(), claimToken);
+            if (!heartbeat.isLeaseOwned()) {
+                return;
+            }
+            LocalDateTime attemptStartedAt = utcNow();
             SystemAlertVO alert = loadAlert(row.getAlertId());
             LocalDateTime silenceEndsAt = silenceService.activeUntil(
                     
AlertRuleVO.builder().id(alert.getRuleId()).domain(alert.getDomain()).build(),
-                    alert.getInstanceId(), alert.getLabels(), now);
+                    alert.getInstanceId(), alert.getLabels(), 
attemptStartedAt);
             if (silenceEndsAt != null) {
                 deferUntilSilenceEnds(row, silenceEndsAt, claimToken);
                 return;
@@ -321,22 +359,42 @@ public class NotificationOutboxService {
             } else {
                 sendWebhook(settings, alert, row.getChannel(), message(row, 
alert));
             }
+            externalSendCompleted = true;
+            if (!heartbeat.isLeaseOwned()) {
+                log.warn("Notification delivery {} lost its claim after the 
external send completed", row.getId());
+                return;
+            }
+            LocalDateTime deliveredAt = utcNow();
             if (!updateClaimed(row, claimToken, new 
UpdateWrapper<RmqAlertNotificationOutbox>()
-                    .set("status", 
NotificationOutboxStatus.DELIVERED.name()).set("delivered_at", now)
+                    .set("status", 
NotificationOutboxStatus.DELIVERED.name()).set("delivered_at", deliveredAt)
                     .set("sending_started_at", null)
-                    .set("last_error", null))) {
+                    .set("last_error", null).set("claim_token", null))) {
                 return;
             }
-            recordDelivery(row, "DELIVER_ALERT_NOTIFICATION", "SUCCESS", null);
+            recordDeliverySafely(row, "DELIVER_ALERT_NOTIFICATION", "SUCCESS", 
null);
         } catch (Exception error) {
-            retry(row, now, claimToken, error.getMessage());
+            if (externalSendCompleted) {
+                // The receiver has already accepted the notification. A 
transient database failure while
+                // recording that outcome must leave the row for stale-claim 
recovery, not trigger another send.
+                log.warn("Notification delivery {} completed externally but 
its state update failed: {}", row.getId(),
+                        error.getMessage());
+            } else if (heartbeat == null || heartbeat.isLeaseOwned()) {
+                retry(row, utcNow(), claimToken, error.getMessage());
+            } else {
+                log.warn("Notification delivery {} failed after its claim was 
lost: {}", row.getId(),
+                        error.getMessage());
+            }
+        } finally {
+            if (heartbeat != null) {
+                heartbeat.close();
+            }
         }
     }
 
     private void deferUntilSilenceEnds(RmqAlertNotificationOutbox row, 
LocalDateTime silenceEndsAt, String claimToken) {
         updateClaimed(row, claimToken, new 
UpdateWrapper<RmqAlertNotificationOutbox>()
                 .set("status", 
NotificationOutboxStatus.PENDING.name()).set("next_attempt_at", silenceEndsAt)
-                .set("sending_started_at", null));
+                .set("sending_started_at", null).set("claim_token", null));
     }
 
     private void sendWebhook(GeneralSettingsVO settings, SystemAlertVO alert, 
String channel, String content) {
@@ -455,10 +513,10 @@ public class NotificationOutboxService {
                         : NotificationOutboxStatus.RETRY_WAIT).name())
                 .set("next_attempt_at", now.plusSeconds(Math.min(300, 5L << 
Math.min(attempts - 1, 5))))
                 .set("sending_started_at", null)
-                .set("last_error", abbreviate(error)))) {
+                .set("last_error", abbreviate(error)).set("claim_token", 
null))) {
             return;
         }
-        recordDelivery(row, exhausted ? "FAIL_ALERT_NOTIFICATION" : 
"RETRY_ALERT_NOTIFICATION",
+        recordDeliverySafely(row, exhausted ? "FAIL_ALERT_NOTIFICATION" : 
"RETRY_ALERT_NOTIFICATION",
                 exhausted ? "FAILURE" : "RETRYING", abbreviate(error));
         log.warn("Alert notification {} for event {}: {}", exhausted ? 
"failed" : "will retry", row.getAlertId(), error);
     }
@@ -468,6 +526,15 @@ public class NotificationOutboxService {
                 "alertId=" + row.getAlertId() + ", channel=" + 
row.getChannel(), result, error);
     }
 
+    private void recordDeliverySafely(RmqAlertNotificationOutbox row, String 
operation, String result, String error) {
+        try {
+            recordDelivery(row, operation, result, error);
+        } catch (RuntimeException auditFailure) {
+            log.warn("Failed to record notification delivery audit for {}: 
{}", row.getId(),
+                    auditFailure.getMessage());
+        }
+    }
+
     private static String abbreviate(String value) {
         if (value == null) return "Delivery failed";
         return value.length() > 1000 ? value.substring(0, 1000) : value;
@@ -475,7 +542,105 @@ public class NotificationOutboxService {
 
     private boolean updateClaimed(RmqAlertNotificationOutbox row, String 
claimToken,
             UpdateWrapper<RmqAlertNotificationOutbox> updates) {
-        return mapper.update(null, updates.eq("id", 
row.getId()).eq("claim_token", claimToken)) == 1;
+        return mapper.update(null, updates.eq("id", row.getId()).eq("status", 
NotificationOutboxStatus.SENDING.name())
+                .eq("claim_token", claimToken)) == 1;
+    }
+
+    private static Duration positiveDuration(String configured, Duration 
fallback, String propertyName) {
+        if (configured == null || configured.isBlank()) {
+            return fallback;
+        }
+        try {
+            Duration parsed = Duration.parse(configured.trim());
+            if (!parsed.isZero() && !parsed.isNegative()) {
+                return parsed;
+            }
+        } catch (RuntimeException ignored) {
+            // Fall through to the safe default below.
+        }
+        log.warn("Invalid {} {}; using {}", propertyName, configured, 
fallback);
+        return fallback;
+    }
+
+    private static Duration safeRenewalInterval(Duration configured, Duration 
timeout) {
+        if (configured.compareTo(timeout) < 0) {
+            return configured;
+        }
+        Duration fallback = timeout.dividedBy(2);
+        if (fallback.isZero() || fallback.isNegative()) {
+            fallback = Duration.ofMillis(1);
+        }
+        log.warn("Notification claim renewal interval {} must be shorter than 
claim timeout {}; using {}",
+                configured, timeout, fallback);
+        return fallback;
+    }
+
+    private static ScheduledExecutorService 
newHeartbeatExecutor(AlertingProperties properties) {
+        int configuredThreads = properties == null ? DEFAULT_HEARTBEAT_THREADS
+                : properties.getNotificationHeartbeatThreads();
+        int threads = Math.max(1, Math.min(MAX_HEARTBEAT_THREADS, 
configuredThreads));
+        ThreadFactory factory = runnable -> {
+            Thread thread = new Thread(runnable, 
"studio-notification-lease-heartbeat");
+            thread.setDaemon(true);
+            return thread;
+        };
+        return Executors.newScheduledThreadPool(threads, factory);
+    }
+
+    private final class LeaseHeartbeat implements AutoCloseable {
+        private final Long rowId;
+        private final String claimToken;
+        private final AtomicBoolean leaseOwned = new AtomicBoolean(true);
+        private final AtomicBoolean closed = new AtomicBoolean(false);
+        private final ScheduledFuture<?> future;
+
+        private LeaseHeartbeat(Long rowId, String claimToken) {
+            this.rowId = rowId;
+            this.claimToken = claimToken;
+            this.future = schedule();
+        }
+
+        private ScheduledFuture<?> schedule() {
+            try {
+                long intervalMillis = Math.max(1, 
claimRenewalInterval.toMillis());
+                return heartbeatExecutor.scheduleAtFixedRate(this::renew, 
intervalMillis, intervalMillis,
+                        TimeUnit.MILLISECONDS);
+            } catch (RuntimeException schedulingFailure) {
+                loseLease("unable to schedule renewal: " + 
schedulingFailure.getMessage());
+                return null;
+            }
+        }
+
+        private void renew() {
+            if (closed.get() || !leaseOwned.get()) {
+                return;
+            }
+            LocalDateTime renewedAt = utcNow();
+            try {
+                if (mapper.renewClaim(rowId, claimToken, renewedAt) != 1) {
+                    loseLease("the database no longer recognizes this claim");
+                }
+            } catch (RuntimeException renewalFailure) {
+                loseLease("renewal failed: " + renewalFailure.getMessage());
+            }
+        }
+
+        private void loseLease(String reason) {
+            if (leaseOwned.compareAndSet(true, false)) {
+                log.warn("Notification delivery {} lost its claim: {}", rowId, 
reason);
+            }
+        }
+
+        private boolean isLeaseOwned() {
+            return leaseOwned.get();
+        }
+
+        @Override
+        public void close() {
+            if (closed.compareAndSet(false, true) && future != null) {
+                future.cancel(false);
+            }
+        }
     }
 
     private static LocalDateTime utcNow() {
diff --git 
a/server/src/main/java/org/apache/rocketmq/studio/persistence/mapper/RmqAlertNotificationOutboxMapper.java
 
b/server/src/main/java/org/apache/rocketmq/studio/persistence/mapper/RmqAlertNotificationOutboxMapper.java
index 33a1af646..0642a6d0c 100644
--- 
a/server/src/main/java/org/apache/rocketmq/studio/persistence/mapper/RmqAlertNotificationOutboxMapper.java
+++ 
b/server/src/main/java/org/apache/rocketmq/studio/persistence/mapper/RmqAlertNotificationOutboxMapper.java
@@ -45,6 +45,11 @@ public interface RmqAlertNotificationOutboxMapper extends 
BaseMapper<RmqAlertNot
             @Param("staleBefore") LocalDateTime staleBefore, 
@Param("claimedAt") LocalDateTime claimedAt,
             @Param("claimToken") String claimToken);
 
+    @Update("UPDATE rmq_alert_notification_outbox SET sending_started_at = 
#{renewedAt} "
+            + "WHERE id = #{id} AND status = 'SENDING' AND claim_token = 
#{claimToken}")
+    int renewClaim(@Param("id") Long id, @Param("claimToken") String 
claimToken,
+            @Param("renewedAt") LocalDateTime renewedAt);
+
     @Select("<script>"
             + "SELECT o.id, o.alert_id AS alertId, o.channel, o.status, 
o.attempt_count AS attemptCount, "
             + "o.next_attempt_at AS nextAttemptAt, o.last_error AS lastError, 
o.delivered_at AS deliveredAt, "
diff --git a/server/src/main/resources/application.yml 
b/server/src/main/resources/application.yml
index 235837078..9a586acbb 100644
--- a/server/src/main/resources/application.yml
+++ b/server/src/main/resources/application.yml
@@ -92,6 +92,9 @@ studio:
     notification-cleanup-interval: 
${STUDIO_ALERTING_NOTIFICATION_CLEANUP_INTERVAL:PT1H}
     notification-cleanup-batch-size: 
${STUDIO_ALERTING_NOTIFICATION_CLEANUP_BATCH_SIZE:500}
     notification-cleanup-max-batches: 
${STUDIO_ALERTING_NOTIFICATION_CLEANUP_MAX_BATCHES:10}
+    notification-claim-timeout: 
${STUDIO_ALERTING_NOTIFICATION_CLAIM_TIMEOUT:PT1M}
+    notification-claim-renewal-interval: 
${STUDIO_ALERTING_NOTIFICATION_CLAIM_RENEWAL_INTERVAL:PT20S}
+    notification-heartbeat-threads: 
${STUDIO_ALERTING_NOTIFICATION_HEARTBEAT_THREADS:2}
   llm:
     token: ${RMQ_LLM_TOKEN:}
     anthropic-base-url: ${RMQ_ANTHROPIC_BASE_URL:}
diff --git 
a/server/src/test/java/org/apache/rocketmq/studio/ops/alert/NotificationOutboxServiceTest.java
 
b/server/src/test/java/org/apache/rocketmq/studio/ops/alert/NotificationOutboxServiceTest.java
index e99a5da25..541d5b2d2 100644
--- 
a/server/src/test/java/org/apache/rocketmq/studio/ops/alert/NotificationOutboxServiceTest.java
+++ 
b/server/src/test/java/org/apache/rocketmq/studio/ops/alert/NotificationOutboxServiceTest.java
@@ -30,12 +30,23 @@ import java.util.Arrays;
 import java.util.List;
 import java.util.Locale;
 import java.util.Optional;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.concurrent.Future;
+import java.util.concurrent.ScheduledExecutorService;
+import java.util.concurrent.ScheduledFuture;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicReference;
 
 import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyLong;
 import static org.mockito.ArgumentMatchers.anyString;
 import static org.mockito.ArgumentMatchers.argThat;
+import static org.mockito.ArgumentMatchers.eq;
 import static org.mockito.Mockito.mock;
 import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.times;
 import static org.mockito.Mockito.verify;
 import static org.mockito.Mockito.when;
 import static org.assertj.core.api.Assertions.assertThat;
@@ -518,4 +529,237 @@ class NotificationOutboxServiceTest {
         assertThat(result.getSucceededIds()).containsExactly(8L);
         assertThat(result.getFailures()).containsKey(9L);
     }
+
+    @Test
+    void renewsClaimWhileEmailDeliveryIsStillInFlightTest() throws Exception {
+        RmqAlertNotificationOutboxMapper mapper = 
mock(RmqAlertNotificationOutboxMapper.class);
+        SettingsRepository settings = mock(SettingsRepository.class);
+        AlertRepository alerts = mock(AlertRepository.class);
+        JavaMailSender mailSender = mock(JavaMailSender.class);
+        ScheduledExecutorService heartbeatExecutor = 
mock(ScheduledExecutorService.class);
+        ScheduledFuture<?> heartbeatFuture = mock(ScheduledFuture.class);
+        AtomicReference<Runnable> renewal = new AtomicReference<>();
+        when(heartbeatExecutor.scheduleAtFixedRate(any(Runnable.class), 
anyLong(), anyLong(), any(TimeUnit.class)))
+                .thenAnswer(invocation -> {
+                    renewal.set(invocation.getArgument(0));
+                    return heartbeatFuture;
+                });
+
+        RmqAlertNotificationOutbox row = new RmqAlertNotificationOutbox();
+        row.setId(8L);
+        row.setAlertId(9L);
+        row.setChannel("email");
+        row.setStatus("PENDING");
+        row.setAttemptCount(0);
+        when(mapper.findDispatchable(any(LocalDateTime.class), 
any(LocalDateTime.class), any(Integer.class)))
+                .thenReturn(List.of(row));
+        when(mapper.claimForDispatch(any(), any(LocalDateTime.class), 
any(LocalDateTime.class),
+                any(LocalDateTime.class), anyString())).thenReturn(1);
+        when(mapper.renewClaim(eq(8L), anyString(), 
any(LocalDateTime.class))).thenReturn(1);
+        when(mapper.update(any(), any())).thenReturn(1);
+        
when(alerts.findAlertById(9L)).thenReturn(Optional.of(SystemAlertVO.builder().id(9L)
+                
.level(AlertLevel.warning).title("Lag").description("high").instanceId("local").build()));
+        
when(settings.loadGeneralSettings()).thenReturn(GeneralSettingsVO.builder()
+                .emailRecipients("[email protected]").build());
+
+        CountDownLatch sendStarted = new CountDownLatch(1);
+        CountDownLatch releaseSend = new CountDownLatch(1);
+        org.mockito.Mockito.doAnswer(invocation -> {
+            sendStarted.countDown();
+            if (!releaseSend.await(5, TimeUnit.SECONDS)) {
+                throw new IllegalStateException("test send timed out");
+            }
+            return null;
+        }).when(mailSender).send(any(SimpleMailMessage.class));
+
+        AlertingProperties properties = new AlertingProperties();
+        properties.setNotificationClaimTimeout("PT1M");
+        properties.setNotificationClaimRenewalInterval("PT10S");
+        NotificationOutboxService service = new 
NotificationOutboxService(mapper, settings,
+                mock(AlertSilenceService.class), alerts, 
mock(OperationAuditService.class), new RestTemplate(),
+                () -> mailSender, properties, heartbeatExecutor);
+
+        ExecutorService dispatcher = Executors.newSingleThreadExecutor();
+        try {
+            Future<?> dispatch = dispatcher.submit(service::dispatch);
+            org.junit.jupiter.api.Assertions.assertTrue(sendStarted.await(5, 
TimeUnit.SECONDS));
+            org.junit.jupiter.api.Assertions.assertNotNull(renewal.get());
+            renewal.get().run();
+            releaseSend.countDown();
+            dispatch.get(5, TimeUnit.SECONDS);
+        } finally {
+            releaseSend.countDown();
+            dispatcher.shutdownNow();
+            service.closeHeartbeatExecutor();
+        }
+
+        verify(mapper).renewClaim(eq(8L), anyString(), 
any(LocalDateTime.class));
+        verify(mapper).update(any(), any());
+        verify(heartbeatFuture).cancel(false);
+    }
+
+    @Test
+    void doesNotRetryAfterDeliveryWhenAuditRecordingFailsTest() {
+        RmqAlertNotificationOutboxMapper mapper = 
mock(RmqAlertNotificationOutboxMapper.class);
+        SettingsRepository settings = mock(SettingsRepository.class);
+        AlertRepository alerts = mock(AlertRepository.class);
+        OperationAuditService audit = mock(OperationAuditService.class);
+        ScheduledExecutorService heartbeatExecutor = 
mock(ScheduledExecutorService.class);
+        ScheduledFuture<?> heartbeatFuture = mock(ScheduledFuture.class);
+        when(heartbeatExecutor.scheduleAtFixedRate(any(Runnable.class), 
anyLong(), anyLong(), any(TimeUnit.class)))
+                .thenAnswer(invocation -> heartbeatFuture);
+        org.mockito.Mockito.doAnswer(invocation -> {
+            if 
("DELIVER_ALERT_NOTIFICATION".equals(invocation.getArgument(0))) {
+                throw new IllegalStateException("audit database unavailable");
+            }
+            return null;
+        }).when(audit).record(anyString(), anyString(), anyString(), any(), 
anyString(), anyString(), any());
+
+        RmqAlertNotificationOutbox row = new RmqAlertNotificationOutbox();
+        row.setId(8L);
+        row.setAlertId(9L);
+        row.setChannel("dingtalk");
+        row.setStatus("PENDING");
+        row.setAttemptCount(0);
+        when(mapper.findDispatchable(any(LocalDateTime.class), 
any(LocalDateTime.class), any(Integer.class)))
+                .thenReturn(List.of(row));
+        when(mapper.claimForDispatch(any(), any(LocalDateTime.class), 
any(LocalDateTime.class),
+                any(LocalDateTime.class), anyString())).thenReturn(1);
+        when(mapper.update(any(), any())).thenReturn(1);
+        
when(alerts.findAlertById(9L)).thenReturn(Optional.of(SystemAlertVO.builder().id(9L)
+                
.level(AlertLevel.warning).title("Lag").description("high").instanceId("local").build()));
+        
when(settings.loadGeneralSettings()).thenReturn(GeneralSettingsVO.builder()
+                .dingtalkWebhook("https://example.com/hook";).build());
+
+        RestTemplate client = new RestTemplate();
+        MockRestServiceServer server = 
MockRestServiceServer.bindTo(client).build();
+        server.expect(once(), requestTo("https://example.com/hook";))
+                .andRespond(withSuccess("{\"errcode\":0}", 
MediaType.APPLICATION_JSON));
+
+        NotificationOutboxService service = new 
NotificationOutboxService(mapper, settings,
+                mock(AlertSilenceService.class), alerts, audit, client, () -> 
null,
+                new AlertingProperties(), heartbeatExecutor);
+        try {
+            service.dispatch();
+        } finally {
+            service.closeHeartbeatExecutor();
+        }
+
+        server.verify();
+        verify(mapper, times(1)).update(any(), any());
+        verify(audit).record("DELIVER_ALERT_NOTIFICATION", 
"ALERT_NOTIFICATION", "8", null,
+                "alertId=9, channel=dingtalk", "SUCCESS", null);
+    }
+
+    @Test
+    void abandonsStateUpdateWhenDeliveryLeaseIsLostDuringSendTest() throws 
Exception {
+        RmqAlertNotificationOutboxMapper mapper = 
mock(RmqAlertNotificationOutboxMapper.class);
+        SettingsRepository settings = mock(SettingsRepository.class);
+        AlertRepository alerts = mock(AlertRepository.class);
+        OperationAuditService audit = mock(OperationAuditService.class);
+        ScheduledExecutorService heartbeatExecutor = 
mock(ScheduledExecutorService.class);
+        ScheduledFuture<?> heartbeatFuture = mock(ScheduledFuture.class);
+        AtomicReference<Runnable> renewal = new AtomicReference<>();
+        when(heartbeatExecutor.scheduleAtFixedRate(any(Runnable.class), 
anyLong(), anyLong(), any(TimeUnit.class)))
+                .thenAnswer(invocation -> {
+                    renewal.set(invocation.getArgument(0));
+                    return heartbeatFuture;
+                });
+
+        RmqAlertNotificationOutbox row = new RmqAlertNotificationOutbox();
+        row.setId(8L);
+        row.setAlertId(9L);
+        row.setChannel("email");
+        row.setStatus("PENDING");
+        row.setAttemptCount(0);
+        when(mapper.findDispatchable(any(LocalDateTime.class), 
any(LocalDateTime.class), any(Integer.class)))
+                .thenReturn(List.of(row));
+        when(mapper.claimForDispatch(any(), any(LocalDateTime.class), 
any(LocalDateTime.class),
+                any(LocalDateTime.class), anyString())).thenReturn(1);
+        when(mapper.renewClaim(eq(8L), anyString(), 
any(LocalDateTime.class))).thenReturn(0);
+        
when(alerts.findAlertById(9L)).thenReturn(Optional.of(SystemAlertVO.builder().id(9L)
+                
.level(AlertLevel.warning).title("Lag").description("high").instanceId("local").build()));
+        
when(settings.loadGeneralSettings()).thenReturn(GeneralSettingsVO.builder()
+                .emailRecipients("[email protected]").build());
+
+        CountDownLatch sendStarted = new CountDownLatch(1);
+        CountDownLatch releaseSend = new CountDownLatch(1);
+        JavaMailSender mailSender = mock(JavaMailSender.class);
+        org.mockito.Mockito.doAnswer(invocation -> {
+            sendStarted.countDown();
+            if (!releaseSend.await(5, TimeUnit.SECONDS)) {
+                throw new IllegalStateException("test send timed out");
+            }
+            return null;
+        }).when(mailSender).send(any(SimpleMailMessage.class));
+
+        NotificationOutboxService service = new 
NotificationOutboxService(mapper, settings,
+                mock(AlertSilenceService.class), alerts, audit, new 
RestTemplate(), () -> mailSender,
+                new AlertingProperties(), heartbeatExecutor);
+        ExecutorService dispatcher = Executors.newSingleThreadExecutor();
+        try {
+            Future<?> dispatch = dispatcher.submit(service::dispatch);
+            org.junit.jupiter.api.Assertions.assertTrue(sendStarted.await(5, 
TimeUnit.SECONDS));
+            org.junit.jupiter.api.Assertions.assertNotNull(renewal.get());
+            renewal.get().run();
+            releaseSend.countDown();
+            dispatch.get(5, TimeUnit.SECONDS);
+        } finally {
+            releaseSend.countDown();
+            dispatcher.shutdownNow();
+            service.closeHeartbeatExecutor();
+        }
+
+        verify(mapper).renewClaim(eq(8L), anyString(), 
any(LocalDateTime.class));
+        verify(mapper, never()).update(any(), any());
+        verify(audit, never()).record(any(), any(), any(), any(), any(), 
any(), any());
+        verify(heartbeatFuture).cancel(false);
+    }
+
+    @Test
+    void doesNotRetryWhenDeliveryStateWriteFailsAfterExternalSuccessTest() {
+        RmqAlertNotificationOutboxMapper mapper = 
mock(RmqAlertNotificationOutboxMapper.class);
+        SettingsRepository settings = mock(SettingsRepository.class);
+        AlertRepository alerts = mock(AlertRepository.class);
+        OperationAuditService audit = mock(OperationAuditService.class);
+        ScheduledExecutorService heartbeatExecutor = 
mock(ScheduledExecutorService.class);
+        ScheduledFuture<?> heartbeatFuture = mock(ScheduledFuture.class);
+        when(heartbeatExecutor.scheduleAtFixedRate(any(Runnable.class), 
anyLong(), anyLong(), any(TimeUnit.class)))
+                .thenAnswer(invocation -> heartbeatFuture);
+
+        RmqAlertNotificationOutbox row = new RmqAlertNotificationOutbox();
+        row.setId(8L);
+        row.setAlertId(9L);
+        row.setChannel("dingtalk");
+        row.setStatus("PENDING");
+        row.setAttemptCount(0);
+        when(mapper.findDispatchable(any(LocalDateTime.class), 
any(LocalDateTime.class), any(Integer.class)))
+                .thenReturn(List.of(row));
+        when(mapper.claimForDispatch(any(), any(LocalDateTime.class), 
any(LocalDateTime.class),
+                any(LocalDateTime.class), anyString())).thenReturn(1);
+        when(mapper.update(any(), any())).thenThrow(new 
IllegalStateException("outbox database unavailable"));
+        
when(alerts.findAlertById(9L)).thenReturn(Optional.of(SystemAlertVO.builder().id(9L)
+                
.level(AlertLevel.warning).title("Lag").description("high").instanceId("local").build()));
+        
when(settings.loadGeneralSettings()).thenReturn(GeneralSettingsVO.builder()
+                .dingtalkWebhook("https://example.com/hook";).build());
+
+        RestTemplate client = new RestTemplate();
+        MockRestServiceServer server = 
MockRestServiceServer.bindTo(client).build();
+        server.expect(once(), requestTo("https://example.com/hook";))
+                .andRespond(withSuccess("{\"errcode\":0}", 
MediaType.APPLICATION_JSON));
+
+        NotificationOutboxService service = new 
NotificationOutboxService(mapper, settings,
+                mock(AlertSilenceService.class), alerts, audit, client, () -> 
null,
+                new AlertingProperties(), heartbeatExecutor);
+        try {
+            service.dispatch();
+        } finally {
+            service.closeHeartbeatExecutor();
+        }
+
+        server.verify();
+        verify(mapper).update(any(), any());
+        verify(audit, never()).record(any(), any(), any(), any(), any(), 
any(), any());
+        verify(heartbeatFuture).cancel(false);
+    }
 }

Reply via email to