Repository: apache/rocketmq (2026-07-25T13:39:15Z)

Issue:
[Enhancement] gRPC producer termination only unregisters the first publishing topic
### Before Creating the Enhancement Request

- [x] I have confirmed that this should be classified as an enhancement rather than a bug/feature.


### Summary

A gRPC producer can publish to multiple topics. Producer heartbeat registers the client for every publishing topic, but `notifyClientTermination` removes the client channel inside the topic loop.

### Motivation

The proxy should remove the gRPC channel once and unregister the producer from every publishing topic.

### Describe the Solution You'd Like

A gRPC producer can publish to multiple topics. Producer heartbeat registers the client for every publishing topic, but `notifyClientTermination` removes the client channel inside the topic loop.

### Describe Alternatives You've Considered

Remove the gRPC client channel once and reuse the same `ClientChannelInfo` to unregister every publishing topic when a producer terminates.

Add a two-topic regression test that verifies both topics are unregistered.

### Additional Context

_No response_


Discussion:
**Issue Evaluation**

Category: `type/enhancement` | Status: **Confirmed**

The reported issue has been verified against the current codebase.

**Root Cause:** In `ClientActivity.notifyClientTermination()` (proxy module), the `grpcChannelManager.removeChannel(clientId)` call is inside the topic loop. After the first iteration removes the channel, subsequent iterations get `null` from `removeChannel`, so only the first publishing topic gets unregistered.

**Location:** `proxy/src/main/java/org/apache/rocketmq/proxy/grpc/v2/client/ClientActivity.java`, lines 162-169

**Proposed Fix:** Move `removeChannel(clientId)` outside the loop. Create the `ClientChannelInfo` once, then iterate over all topics to unregister the producer from each.



**Impact:** Producers publishing to multiple topics will have all topics properly unregistered on termination, preventing stale registrations.
**Severity:** Medium — can cause resource leaks and incorrect routing state for multi-topic producers.

An automated fix proposal will be generated. Reply `/approve` to proceed with PR generation.

---
*Automated evaluation by RockteMQ-AI*
I'd like to take this. Will fix the gRPC producer termination to unregister all publishing topics instead of only the first one.



Patch:
diff --git a/proxy/src/main/java/org/apache/rocketmq/proxy/grpc/v2/client/ClientActivity.java b/proxy/src/main/java/org/apache/rocketmq/proxy/grpc/v2/client/ClientActivity.java
index b674d448c06..abc23a53a3e 100644
--- a/proxy/src/main/java/org/apache/rocketmq/proxy/grpc/v2/client/ClientActivity.java
+++ b/proxy/src/main/java/org/apache/rocketmq/proxy/grpc/v2/client/ClientActivity.java
@@ -156,16 +156,17 @@ public CompletableFuture<NotifyClientTerminationResponse> notifyClientTerminatio
             }
 
             switch (clientSettings.getClientType()) {
-                case PRODUCER:
-                    for (Resource topic : clientSettings.getPublishing().getTopicsList()) {
-                        String topicName = topic.getName();
-                        GrpcClientChannel channel = this.grpcChannelManager.removeChannel(clientId);
-                        if (channel != null) {
-                            ClientChannelInfo clientChannelInfo = new ClientChannelInfo(channel, clientId, languageCode, MQVersion.Version.V5_0_0.ordinal());
+                case PRODUCER: {
+                    GrpcClientChannel channel = this.grpcChannelManager.removeChannel(clientId);
+                    if (channel != null) {
+                        ClientChannelInfo clientChannelInfo = new ClientChannelInfo(channel, clientId, languageCode, MQVersion.Version.V5_0_0.ordinal());
+                        for (Resource topic : clientSettings.getPublishing().getTopicsList()) {
+                            String topicName = topic.getName();
                             this.messagingProcessor.unRegisterProducer(ctx, topicName, clientChannelInfo);
                         }
                     }
                     break;
+                }
                 case PUSH_CONSUMER:
                 case LITE_PUSH_CONSUMER:
                 case SIMPLE_CONSUMER:


Test patch:
diff --git a/proxy/src/test/java/org/apache/rocketmq/proxy/grpc/v2/client/ClientActivityTest.java b/proxy/src/test/java/org/apache/rocketmq/proxy/grpc/v2/client/ClientActivityTest.java
index 532c9795c87..e215c6efaba 100644
--- a/proxy/src/test/java/org/apache/rocketmq/proxy/grpc/v2/client/ClientActivityTest.java
+++ b/proxy/src/test/java/org/apache/rocketmq/proxy/grpc/v2/client/ClientActivityTest.java
@@ -224,18 +224,21 @@ protected void assertClientChannelInfo(ClientChannelInfo clientChannelInfo, Stri
     @Test
     public void testProducerNotifyClientTermination() throws Throwable {
         ProxyContext context = createContext();
+        String anotherTopic = "anotherTopic";
 
-        when(this.grpcClientSettingsManager.removeAndGetClientSettings(any())).thenReturn(Settings.newBuilder()
+        Settings settings = Settings.newBuilder()
             .setClientType(ClientType.PRODUCER)
             .setPublishing(Publishing.newBuilder()
                 .addTopics(Resource.newBuilder().setName(TOPIC).build())
+                .addTopics(Resource.newBuilder().setName(anotherTopic).build())
                 .build())
-            .build());
+            .build();
+        when(this.grpcClientSettingsManager.removeAndGetClientSettings(any())).thenReturn(settings);
+        ArgumentCaptor<String> topicArgumentCaptor = ArgumentCaptor.forClass(String.class);
         ArgumentCaptor<ClientChannelInfo> channelInfoArgumentCaptor = ArgumentCaptor.forClass(ClientChannelInfo.class);
-        doNothing().when(this.messagingProcessor).unRegisterProducer(any(), anyString(), channelInfoArgumentCaptor.capture());
         when(this.metadataService.getTopicMessageType(any(), anyString())).thenReturn(TopicMessageType.NORMAL);
 
-        this.sendProducerTelemetry(context);
+        this.sendClientTelemetry(context, settings);
         this.sendProducerHeartbeat(context);
 
         NotifyClientTerminationResponse response = this.clientActivity.notifyClientTermination(
@@ -245,8 +248,12 @@ public void testProducerNotifyClientTermination() throws Throwable {
         ).get();
 
         assertEquals(Code.OK, response.getStatus().getCode());
-        ClientChannelInfo clientChannelInfo = channelInfoArgumentCaptor.getValue();
-        assertClientChannelInfo(clientChannelInfo, TOPIC);
+        verify(this.messagingProcessor, times(2)).unRegisterProducer(any(), topicArgumentCaptor.capture(),
+            channelInfoArgumentCaptor.capture());
+        assertThat(topicArgumentCaptor.getAllValues()).containsExactly(TOPIC, anotherTopic);
+        assertThat(channelInfoArgumentCaptor.getAllValues().get(0))
+            .isSameAs(channelInfoArgumentCaptor.getAllValues().get(1));
+        assertClientChannelInfo(channelInfoArgumentCaptor.getValue(), TOPIC);
     }
 
     @Test