This is an automated email from the ASF dual-hosted git repository.

lizhimins pushed a commit to branch develop
in repository https://gitbox.apache.org/repos/asf/rocketmq.git


The following commit(s) were added to refs/heads/develop by this push:
     new 8227ecd1ac [ISSUE #11170] Preserve transaction subscriptions before 
first heartbeat (#11171)
8227ecd1ac is described below

commit 8227ecd1acab927d3400b115644a60f489ff6169
Author: qianye <[email protected]>
AuthorDate: Mon Sep 21 16:01:43 2026 +0800

    [ISSUE #11170] Preserve transaction subscriptions before first heartbeat 
(#11171)
---
 .../transaction/ClusterTransactionService.java     |  13 ++-
 .../transaction/ClusterTransactionServiceTest.java | 123 +++++++++++++++++++++
 2 files changed, 135 insertions(+), 1 deletion(-)

diff --git 
a/proxy/src/main/java/org/apache/rocketmq/proxy/service/transaction/ClusterTransactionService.java
 
b/proxy/src/main/java/org/apache/rocketmq/proxy/service/transaction/ClusterTransactionService.java
index 1ec4286463..dd1ebc526b 100644
--- 
a/proxy/src/main/java/org/apache/rocketmq/proxy/service/transaction/ClusterTransactionService.java
+++ 
b/proxy/src/main/java/org/apache/rocketmq/proxy/service/transaction/ClusterTransactionService.java
@@ -82,6 +82,10 @@ public class ClusterTransactionService extends 
AbstractTransactionService {
                     clusterDataSet = Sets.newHashSet();
                 }
                 clusterDataSet.addAll(getClusterDataFromTopic(ctx, topic));
+                long now = System.currentTimeMillis();
+                for (ClusterData clusterData : clusterDataSet) {
+                    clusterData.lastActiveTimestamp = now;
+                }
                 return clusterDataSet;
             });
         } catch (Exception e) {
@@ -131,14 +135,19 @@ public class ClusterTransactionService extends 
AbstractTransactionService {
                 if (clusterDataSet.isEmpty()) {
                     return null;
                 }
+                long now = System.currentTimeMillis();
                 if (!this.producerManager.groupOnline(groupName)) {
-                    return null;
+                    // A transaction send may precede the producer's first 
heartbeat.
+                    long timeoutMillis = 
ConfigurationManager.getProxyConfig().getChannelExpiredTimeout();
+                    clusterDataSet.removeIf(clusterData -> now - 
clusterData.lastActiveTimestamp >= timeoutMillis);
+                    return clusterDataSet.isEmpty() ? null : clusterDataSet;
                 }
 
                 ProducerData producerData = new ProducerData();
                 producerData.setGroupName(groupName);
 
                 for (ClusterData clusterData : clusterDataSet) {
+                    clusterData.lastActiveTimestamp = now;
                     List<HeartbeatData> heartbeatDataList = 
clusterHeartbeatData.get(clusterData.cluster);
                     if (heartbeatDataList == null) {
                         heartbeatDataList = new ArrayList<>();
@@ -228,6 +237,8 @@ public class ClusterTransactionService extends 
AbstractTransactionService {
 
     static class ClusterData {
         private final String cluster;
+        // Epoch milliseconds, refreshed on subscription updates or when the 
owning group is observed online.
+        private long lastActiveTimestamp = System.currentTimeMillis();
 
         public ClusterData(String cluster) {
             this.cluster = cluster;
diff --git 
a/proxy/src/test/java/org/apache/rocketmq/proxy/service/transaction/ClusterTransactionServiceTest.java
 
b/proxy/src/test/java/org/apache/rocketmq/proxy/service/transaction/ClusterTransactionServiceTest.java
index 91af74cbef..fdbe3f3e92 100644
--- 
a/proxy/src/test/java/org/apache/rocketmq/proxy/service/transaction/ClusterTransactionServiceTest.java
+++ 
b/proxy/src/test/java/org/apache/rocketmq/proxy/service/transaction/ClusterTransactionServiceTest.java
@@ -17,12 +17,16 @@
 
 package org.apache.rocketmq.proxy.service.transaction;
 
+import java.lang.reflect.Field;
 import java.time.Duration;
+import java.util.ArrayList;
 import java.util.HashMap;
 import java.util.HashSet;
 import java.util.List;
+import java.util.Map;
 import java.util.Set;
 import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.TimeUnit;
 import java.util.stream.Collectors;
 import org.apache.rocketmq.broker.client.ProducerManager;
 import org.apache.rocketmq.common.MixAll;
@@ -44,6 +48,7 @@ import org.mockito.Mockito;
 
 import static org.awaitility.Awaitility.await;
 import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
 import static org.junit.Assert.assertTrue;
 import static org.mockito.ArgumentMatchers.any;
 import static org.mockito.ArgumentMatchers.anyLong;
@@ -108,6 +113,124 @@ public class ClusterTransactionServiceTest extends 
BaseServiceTest {
         assertEquals(0, 
this.clusterTransactionService.getGroupClusterData().size());
     }
 
+    @Test
+    public void testTransactionSubscriptionSurvivesFirstHeartbeatDelay() {
+        List<HeartbeatData> heartbeats = recordHeartbeats();
+        this.clusterTransactionService.addTransactionSubscription(ctx, GROUP, 
TOPIC);
+
+        this.clusterTransactionService.scanProducerHeartBeat();
+        
assertTrue(this.clusterTransactionService.getGroupClusterData().containsKey(GROUP));
+        assertTrue(heartbeats.isEmpty());
+
+        when(this.producerManager.groupOnline(GROUP)).thenReturn(true);
+        this.clusterTransactionService.scanProducerHeartBeat();
+
+        assertEquals(1, heartbeats.size());
+        assertEquals(GROUP, 
heartbeats.get(0).getProducerDataSet().iterator().next().getGroupName());
+    }
+
+    @Test
+    public void testNeverRegisteredSubscriptionExpiresWithoutHeartbeats() 
throws Exception {
+        List<HeartbeatData> heartbeats = recordHeartbeats();
+        this.clusterTransactionService.addTransactionSubscription(ctx, GROUP, 
TOPIC);
+
+        this.clusterTransactionService.scanProducerHeartBeat();
+        this.clusterTransactionService.scanProducerHeartBeat();
+        
assertTrue(this.clusterTransactionService.getGroupClusterData().containsKey(GROUP));
+        assertTrue(heartbeats.isEmpty());
+
+        expireSubscriptions();
+        this.clusterTransactionService.scanProducerHeartBeat();
+
+        
assertFalse(this.clusterTransactionService.getGroupClusterData().containsKey(GROUP));
+        assertTrue(heartbeats.isEmpty());
+    }
+
+    @Test
+    public void testAddingExistingSubscriptionRefreshesGracePeriod() throws 
Exception {
+        List<HeartbeatData> heartbeats = recordHeartbeats();
+        this.clusterTransactionService.addTransactionSubscription(ctx, GROUP, 
TOPIC);
+        expireSubscriptions();
+
+        // Set.addAll keeps the existing ClusterData when another topic uses 
the same cluster.
+        this.clusterTransactionService.addTransactionSubscription(ctx, GROUP, 
TOPIC + "-another");
+        this.clusterTransactionService.scanProducerHeartBeat();
+
+        assertEquals(1, 
this.clusterTransactionService.getGroupClusterData().get(GROUP).size());
+        assertTrue(heartbeats.isEmpty());
+    }
+
+    @Test
+    public void testOnlineSubscriptionRefreshesGracePeriod() throws Exception {
+        List<HeartbeatData> heartbeats = recordHeartbeats();
+        this.clusterTransactionService.addTransactionSubscription(ctx, GROUP, 
TOPIC);
+        expireSubscriptions();
+
+        when(this.producerManager.groupOnline(GROUP)).thenReturn(true);
+        this.clusterTransactionService.scanProducerHeartBeat();
+        assertEquals(1, heartbeats.size());
+
+        when(this.producerManager.groupOnline(GROUP)).thenReturn(false);
+        this.clusterTransactionService.scanProducerHeartBeat();
+        
assertTrue(this.clusterTransactionService.getGroupClusterData().containsKey(GROUP));
+        assertEquals(1, heartbeats.size());
+
+        expireSubscriptions();
+        this.clusterTransactionService.scanProducerHeartBeat();
+        
assertFalse(this.clusterTransactionService.getGroupClusterData().containsKey(GROUP));
+        assertEquals(1, heartbeats.size());
+    }
+
+    @Test
+    public void testReplacingSubscriptionRefreshesGracePeriod() throws 
Exception {
+        List<HeartbeatData> heartbeats = recordHeartbeats();
+        this.clusterTransactionService.addTransactionSubscription(ctx, GROUP, 
TOPIC);
+        expireSubscriptions();
+
+        this.clusterTransactionService.replaceTransactionSubscription(ctx, 
GROUP, Lists.newArrayList(TOPIC));
+        this.clusterTransactionService.scanProducerHeartBeat();
+
+        
assertTrue(this.clusterTransactionService.getGroupClusterData().containsKey(GROUP));
+        assertTrue(heartbeats.isEmpty());
+    }
+
+    @Test
+    public void 
testUnsubscribeDuringGracePeriodRemovesSubscriptionImmediately() {
+        List<HeartbeatData> heartbeats = recordHeartbeats();
+        this.clusterTransactionService.addTransactionSubscription(ctx, GROUP, 
TOPIC);
+        this.clusterTransactionService.scanProducerHeartBeat();
+
+        this.clusterTransactionService.unSubscribeAllTransactionTopic(ctx, 
GROUP);
+        
assertFalse(this.clusterTransactionService.getGroupClusterData().containsKey(GROUP));
+
+        when(this.producerManager.groupOnline(GROUP)).thenReturn(true);
+        this.clusterTransactionService.scanProducerHeartBeat();
+        assertTrue(heartbeats.isEmpty());
+    }
+
+    private List<HeartbeatData> recordHeartbeats() {
+        List<HeartbeatData> heartbeats = new ArrayList<>();
+        this.clusterTransactionService = new 
ClusterTransactionService(this.topicRouteService, this.producerManager,
+            this.mqClientAPIFactory) {
+            @Override
+            protected void sendHeartBeatToCluster(String clusterName, 
List<HeartbeatData> heartbeatDataList,
+                Map<String, String> brokerAddrNameMap) {
+                heartbeats.addAll(heartbeatDataList);
+            }
+        };
+        return heartbeats;
+    }
+
+    private void expireSubscriptions() throws Exception {
+        long expiredAt = System.currentTimeMillis() - 
ConfigurationManager.getProxyConfig().getChannelExpiredTimeout()
+            - TimeUnit.SECONDS.toMillis(1);
+        Field lastActiveTimestamp = 
ClusterTransactionService.ClusterData.class.getDeclaredField("lastActiveTimestamp");
+        lastActiveTimestamp.setAccessible(true);
+        for (ClusterTransactionService.ClusterData data : 
this.clusterTransactionService.getGroupClusterData().get(GROUP)) {
+            lastActiveTimestamp.setLong(data, expiredAt);
+        }
+    }
+
     @Test
     public void testScanProducerHeartBeat() throws Exception {
         when(this.producerManager.groupOnline(anyString())).thenReturn(true);

Reply via email to