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 5479af6f4 fix(lite): fail the TTL extension instead of partially 
updating the cluster (#4578)
5479af6f4 is described below

commit 5479af6f4d6203361c78e0b37581d169ad8d6bf8
Author: Zhao Jianing <[email protected]>
AuthorDate: Mon Sep 21 21:02:57 2026 +0800

    fix(lite): fail the TTL extension instead of partially updating the cluster 
(#4578)
    
    `RocketMQLiteTopicProvider.extendTTL` read and wrote each master inside one 
loop, and its private `liteTopicConfig` helper caught every exception and 
returned null, which the caller treated as "nothing to extend here" and 
skipped. A master that timed out or was mid-restart was left out of the write 
while `updated` still counted the others, so `POST /api/lite-topic/extendTTL` 
returned 200 and logged the broker count while `lite.topic.expiration` diverged 
across the cluster — and becaus [...]
    
    The loop is now two phases: every master's `TopicConfig` is read into a 
`LinkedHashMap` first, and only then is the new expiration written back. The 
helper was renamed `liteParentTopicConfig`, declares `throws Exception`, and 
treats only `MQBrokerException` with `TOPIC_NOT_EXIST`, a null config or a 
non-LITE topic as a legitimate skip; the "nothing to update" 404 moved from 
`updated == 0` to `pending.isEmpty()`, so it fires before any write.
    
    A failure during the write phase still leaves earlier masters updated — 
inherent to a non-transactional multi-broker write — but it now surfaces as an 
error rather than a success, and the update is idempotent, so a retry converges.
---
 .../provider/apache/RocketMQLiteTopicProvider.java | 52 +++++++++++++++-------
 .../apache/RocketMQLiteTopicProviderTest.java      | 32 +++++++++++++
 2 files changed, 67 insertions(+), 17 deletions(-)

diff --git 
a/server/src/main/java/org/apache/rocketmq/studio/provider/apache/RocketMQLiteTopicProvider.java
 
b/server/src/main/java/org/apache/rocketmq/studio/provider/apache/RocketMQLiteTopicProvider.java
index f7b160744..16fdd5ceb 100644
--- 
a/server/src/main/java/org/apache/rocketmq/studio/provider/apache/RocketMQLiteTopicProvider.java
+++ 
b/server/src/main/java/org/apache/rocketmq/studio/provider/apache/RocketMQLiteTopicProvider.java
@@ -16,10 +16,12 @@
  */
 package org.apache.rocketmq.studio.provider.apache;
 
+import org.apache.rocketmq.client.exception.MQBrokerException;
 import org.apache.rocketmq.common.MixAll;
 import org.apache.rocketmq.common.TopicConfig;
 import org.apache.rocketmq.common.attribute.TopicMessageType;
 import org.apache.rocketmq.common.lite.LiteUtil;
+import org.apache.rocketmq.remoting.protocol.ResponseCode;
 import org.apache.rocketmq.remoting.protocol.admin.OffsetWrapper;
 import org.apache.rocketmq.remoting.protocol.body.ClusterInfo;
 import org.apache.rocketmq.remoting.protocol.body.Connection;
@@ -339,12 +341,21 @@ public class RocketMQLiteTopicProvider implements 
LiteTopicProvider {
         }
         long minutes = Math.min(Math.max(Math.round(ttlMillis / 60000.0), 1), 
MAX_LITE_TTL_MINUTES);
         execute(admin -> {
-            int updated = 0;
+            // Read every master's config before writing any of them: a master 
that cannot be
+            // examined must fail the request up front instead of being 
silently skipped, which
+            // would leave the cluster with mixed lite.topic.expiration 
attributes while the
+            // console reports a fully successful extension.
+            Map<String, TopicConfig> pending = new LinkedHashMap<>();
             for (String master : masterAddresses(admin)) {
-                TopicConfig config = liteTopicConfig(admin, master, 
topicPattern);
-                if (config == null) {
-                    continue;
+                TopicConfig config = liteParentTopicConfig(admin, master, 
topicPattern);
+                if (config != null) {
+                    pending.put(master, config);
                 }
+            }
+            if (pending.isEmpty()) {
+                throw new BusinessException(404, "Lite parent topic not found: 
" + topicPattern);
+            }
+            for (Map.Entry<String, TopicConfig> entry : pending.entrySet()) {
                 // Attributes read back from the broker use bare keys 
("lite.topic.expiration"),
                 // while the update protocol only accepts change entries 
("+key=value"); a bare
                 // key is rejected with "add/alter attribute format is wrong". 
The broker merges
@@ -353,30 +364,37 @@ public class RocketMQLiteTopicProvider implements 
LiteTopicProvider {
                 // Only the TTL is altered.
                 Map<String, String> change = new HashMap<>();
                 change.put("+lite.topic.expiration", String.valueOf(minutes));
-                config.setAttributes(change);
-                admin.createAndUpdateTopicConfig(master, config);
-                updated++;
-            }
-            if (updated == 0) {
-                throw new BusinessException(404, "Lite parent topic not found: 
" + topicPattern);
+                entry.getValue().setAttributes(change);
+                admin.createAndUpdateTopicConfig(entry.getKey(), 
entry.getValue());
             }
             log.info("Extended LiteTopic TTL to {}ms ({} min) for parent topic 
{} on {} broker(s)",
-                    ttlMillis, minutes, topicPattern, updated);
+                    ttlMillis, minutes, topicPattern, pending.size());
             return null;
         });
     }
 
-    private TopicConfig liteTopicConfig(MQAdminExt admin, String brokerAddr, 
String topic) {
+    /**
+     * Reads the parent topic's config for the TTL update loop, distinguishing 
the outcomes the
+     * loop must treat differently: a topic that is absent on this master (or 
not a LITE topic)
+     * is a legitimate skip, while any other read failure propagates so the 
update cannot be
+     * applied to only part of the cluster and still report success.
+     */
+    private TopicConfig liteParentTopicConfig(MQAdminExt admin, String 
brokerAddr, String topic)
+            throws Exception {
+        TopicConfig config;
         try {
-            TopicConfig config = admin.examineTopicConfig(brokerAddr, topic);
-            if (config == null || 
!TopicMessageType.LITE.equals(config.getTopicMessageType())) {
+            config = admin.examineTopicConfig(brokerAddr, topic);
+        } catch (MQBrokerException failure) {
+            if (failure.getResponseCode() == ResponseCode.TOPIC_NOT_EXIST) {
+                log.debug("Parent topic {} is not configured on {}", topic, 
brokerAddr);
                 return null;
             }
-            return config;
-        } catch (Exception failure) {
-            log.debug("Parent topic {} is not configured on {}: {}", topic, 
brokerAddr, failure.getMessage());
+            throw failure;
+        }
+        if (config == null || 
!TopicMessageType.LITE.equals(config.getTopicMessageType())) {
             return null;
         }
+        return config;
     }
 
     // ─── Quota ────────────────────────────────────────────────────────
diff --git 
a/server/src/test/java/org/apache/rocketmq/studio/provider/apache/RocketMQLiteTopicProviderTest.java
 
b/server/src/test/java/org/apache/rocketmq/studio/provider/apache/RocketMQLiteTopicProviderTest.java
index 0fbf4c004..e8b213d35 100644
--- 
a/server/src/test/java/org/apache/rocketmq/studio/provider/apache/RocketMQLiteTopicProviderTest.java
+++ 
b/server/src/test/java/org/apache/rocketmq/studio/provider/apache/RocketMQLiteTopicProviderTest.java
@@ -16,10 +16,13 @@
  */
 package org.apache.rocketmq.studio.provider.apache;
 
+import org.apache.rocketmq.client.exception.MQBrokerException;
 import org.apache.rocketmq.common.TopicConfig;
 import org.apache.rocketmq.common.attribute.TopicMessageType;
 import org.apache.rocketmq.common.lite.LiteUtil;
 import org.apache.rocketmq.remoting.RPCHook;
+import org.apache.rocketmq.remoting.exception.RemotingTimeoutException;
+import org.apache.rocketmq.remoting.protocol.ResponseCode;
 import org.apache.rocketmq.remoting.protocol.admin.OffsetWrapper;
 import org.apache.rocketmq.remoting.protocol.body.ClusterInfo;
 import org.apache.rocketmq.remoting.protocol.body.Connection;
@@ -301,6 +304,35 @@ class RocketMQLiteTopicProviderTest {
         verify(admin, never()).createAndUpdateTopicConfig(anyString(), any());
     }
 
+    @Test
+    void extendTtlFailsInsteadOfPartiallyUpdatingWhenAMasterCannotBeRead() 
throws Exception {
+        String unreachableMaster = "127.0.0.1:10912";
+        when(admin.examineBrokerClusterInfo()).thenReturn(cluster(BROKER_A, 
unreachableMaster));
+        when(admin.examineTopicConfig(BROKER_A, 
PARENT)).thenReturn(liteTopicConfig(PARENT, 30));
+        when(admin.examineTopicConfig(unreachableMaster, PARENT))
+                .thenThrow(new RemotingTimeoutException("broker restarting"));
+
+        // The extension must fail before any master is written: the 
alternative is a cluster
+        // with mixed lite.topic.expiration attributes and a console that 
reports success.
+        assertThatThrownBy(() -> provider.extendTTL(PARENT, 
TimeUnit.MINUTES.toMillis(120)))
+                .isInstanceOf(RemotingTimeoutException.class);
+        verify(admin, never()).createAndUpdateTopicConfig(anyString(), any());
+    }
+
+    @Test
+    void extendTtlStillUpdatesReachableMastersWhenTheTopicIsAbsentOnAPeer() 
throws Exception {
+        String peerMaster = "127.0.0.1:10912";
+        when(admin.examineBrokerClusterInfo()).thenReturn(cluster(BROKER_A, 
peerMaster));
+        when(admin.examineTopicConfig(BROKER_A, 
PARENT)).thenReturn(liteTopicConfig(PARENT, 30));
+        when(admin.examineTopicConfig(peerMaster, PARENT))
+                .thenThrow(new MQBrokerException(ResponseCode.TOPIC_NOT_EXIST, 
"topic not exist"));
+
+        provider.extendTTL(PARENT, TimeUnit.MINUTES.toMillis(120));
+
+        verify(admin).createAndUpdateTopicConfig(eq(BROKER_A), 
any(TopicConfig.class));
+        verify(admin, never()).createAndUpdateTopicConfig(eq(peerMaster), 
any());
+    }
+
     @Test
     void getQuotaAggregatesBrokerLiteCapacityAndConfigLimits() throws 
Exception {
         when(admin.examineBrokerClusterInfo()).thenReturn(cluster(BROKER_A));

Reply via email to