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 97a797543b [ISSUE #10906] Prevent duplicate MQClientInstance creation 
(#10907)
97a797543b is described below

commit 97a797543bad7697a79ea227671f3047266b6620
Author: qianye <[email protected]>
AuthorDate: Wed Aug 12 14:02:31 2026 +0800

    [ISSUE #10906] Prevent duplicate MQClientInstance creation (#10907)
---
 .../rocketmq/client/impl/MQClientManager.java      |  27 +-
 .../client/impl/factory/MQClientInstance.java      | 204 ++++++----
 .../rocketmq/client/impl/MQClientManagerTest.java  | 440 +++++++++++++++++++++
 .../apache/rocketmq/common/ServiceThreadTest.java  |  58 ++-
 .../test/client/consumer/pop/BatchAckIT.java       |  24 +-
 .../client/producer/querymsg/QueryMsgByKeyIT.java  |  13 +-
 6 files changed, 638 insertions(+), 128 deletions(-)

diff --git 
a/client/src/main/java/org/apache/rocketmq/client/impl/MQClientManager.java 
b/client/src/main/java/org/apache/rocketmq/client/impl/MQClientManager.java
index ca6f461745..e6f17727ff 100644
--- a/client/src/main/java/org/apache/rocketmq/client/impl/MQClientManager.java
+++ b/client/src/main/java/org/apache/rocketmq/client/impl/MQClientManager.java
@@ -51,16 +51,15 @@ public class MQClientManager {
         String clientId = clientConfig.buildMQClientId();
         MQClientInstance instance = this.factoryTable.get(clientId);
         if (null == instance) {
-            instance =
-                new MQClientInstance(clientConfig.cloneClientConfig(),
-                    this.factoryIndexGenerator.getAndIncrement(), clientId, 
rpcHook);
-            MQClientInstance prev = this.factoryTable.putIfAbsent(clientId, 
instance);
-            if (prev != null) {
-                instance = prev;
-                log.warn("Returned Previous MQClientInstance for 
clientId:[{}]", clientId);
-            } else {
-                log.info("Created new MQClientInstance for clientId:[{}]", 
clientId);
-            }
+            ClientConfig clonedClientConfig = clientConfig.cloneClientConfig();
+            // MQClientInstance construction must not call back into 
factoryTable. ConcurrentHashMap rejects
+            // recursive updates from a mapping function with 
IllegalStateException.
+            instance = this.factoryTable.computeIfAbsent(clientId, key -> {
+                MQClientInstance newInstance = new 
MQClientInstance(clonedClientConfig,
+                    this.factoryIndexGenerator.getAndIncrement(), key, 
rpcHook);
+                log.info("Created new MQClientInstance for clientId:[{}]", 
key);
+                return newInstance;
+            });
         }
 
         return instance;
@@ -82,10 +81,18 @@ public class MQClientManager {
         return accumulator;
     }
 
+    /**
+     * Removes the mapped factory without checking its identity. Lifecycle 
cleanup should prefer
+     * {@link #removeClientFactory(String, MQClientInstance)} to avoid 
removing a replacement instance.
+     */
     public void removeClientFactory(final String clientId) {
         this.factoryTable.remove(clientId);
     }
 
+    public void removeClientFactory(final String clientId, final 
MQClientInstance instance) {
+        this.factoryTable.remove(clientId, instance);
+    }
+
     public ConcurrentMap<String, MQClientInstance> getFactoryTable() {
         return factoryTable;
     }
diff --git 
a/client/src/main/java/org/apache/rocketmq/client/impl/factory/MQClientInstance.java
 
b/client/src/main/java/org/apache/rocketmq/client/impl/factory/MQClientInstance.java
index cd45fed2a3..5208e49fe7 100644
--- 
a/client/src/main/java/org/apache/rocketmq/client/impl/factory/MQClientInstance.java
+++ 
b/client/src/main/java/org/apache/rocketmq/client/impl/factory/MQClientInstance.java
@@ -148,86 +148,114 @@ public class MQClientInstance {
     }
 
     public MQClientInstance(ClientConfig clientConfig, int instanceIndex, 
String clientId, RPCHook rpcHook) {
-        this.clientConfig = clientConfig;
-        this.nettyClientConfig = new NettyClientConfig();
-        
this.nettyClientConfig.setClientCallbackExecutorThreads(clientConfig.getClientCallbackExecutorThreads());
-        this.nettyClientConfig.setUseTLS(clientConfig.isUseTLS());
-        
this.nettyClientConfig.setSocksProxyConfig(clientConfig.getSocksProxyConfig());
-        this.nettyClientConfig.setScanAvailableNameSrv(false);
-        ClientRemotingProcessor clientRemotingProcessor = new 
ClientRemotingProcessor(this);
-        ChannelEventListener channelEventListener;
-        if (clientConfig.isEnableHeartbeatChannelEventListener()) {
-            channelEventListener = new ChannelEventListener() {
-                
-                private final ConcurrentMap<String, HashMap<Long, String>> 
brokerAddrTable = MQClientInstance.this.brokerAddrTable;
-                
-                @Override
-                public void onChannelConnect(String remoteAddr, Channel 
channel) {
-                }
+        MQClientAPIImpl clientAPI = null;
+        try {
+            this.clientConfig = clientConfig;
+            this.nettyClientConfig = new NettyClientConfig();
+            
this.nettyClientConfig.setClientCallbackExecutorThreads(clientConfig.getClientCallbackExecutorThreads());
+            this.nettyClientConfig.setUseTLS(clientConfig.isUseTLS());
+            
this.nettyClientConfig.setSocksProxyConfig(clientConfig.getSocksProxyConfig());
+            this.nettyClientConfig.setScanAvailableNameSrv(false);
+            ClientRemotingProcessor clientRemotingProcessor = new 
ClientRemotingProcessor(this);
+            ChannelEventListener channelEventListener;
+            if (clientConfig.isEnableHeartbeatChannelEventListener()) {
+                channelEventListener = new ChannelEventListener() {
+
+                    private final ConcurrentMap<String, HashMap<Long, String>> 
brokerAddrTable = MQClientInstance.this.brokerAddrTable;
+
+                    @Override
+                    public void onChannelConnect(String remoteAddr, Channel 
channel) {
+                    }
 
-                @Override
-                public void onChannelClose(String remoteAddr, Channel channel) 
{
-                }
+                    @Override
+                    public void onChannelClose(String remoteAddr, Channel 
channel) {
+                    }
 
-                @Override
-                public void onChannelException(String remoteAddr, Channel 
channel) {
-                }
+                    @Override
+                    public void onChannelException(String remoteAddr, Channel 
channel) {
+                    }
 
-                @Override
-                public void onChannelIdle(String remoteAddr, Channel channel) {
-                }
+                    @Override
+                    public void onChannelIdle(String remoteAddr, Channel 
channel) {
+                    }
 
-                @Override
-                public void onChannelActive(String remoteAddr, Channel 
channel) {
-                    for (Map.Entry<String, HashMap<Long, String>> addressEntry 
: brokerAddrTable.entrySet()) {
-                        for (Map.Entry<Long, String> entry : 
addressEntry.getValue().entrySet()) {
-                            String addr = entry.getValue();
-                            if (addr.equals(remoteAddr)) {
-                                long id = entry.getKey();
-                                String brokerName = addressEntry.getKey();
-                                if (sendHeartbeatToBroker(id, brokerName, 
addr, false)) {
-                                    rebalanceImmediately();
+                    @Override
+                    public void onChannelActive(String remoteAddr, Channel 
channel) {
+                        for (Map.Entry<String, HashMap<Long, String>> 
addressEntry : brokerAddrTable.entrySet()) {
+                            for (Map.Entry<Long, String> entry : 
addressEntry.getValue().entrySet()) {
+                                String addr = entry.getValue();
+                                if (addr.equals(remoteAddr)) {
+                                    long id = entry.getKey();
+                                    String brokerName = addressEntry.getKey();
+                                    if (sendHeartbeatToBroker(id, brokerName, 
addr, false)) {
+                                        rebalanceImmediately();
+                                    }
+                                    break;
                                 }
-                                break;
                             }
                         }
                     }
-                }
-            };
-        } else {
-            channelEventListener = null;
-        }
-        this.mQClientAPIImpl = new MQClientAPIImpl(this.nettyClientConfig, 
clientRemotingProcessor, rpcHook, clientConfig, channelEventListener);
+                };
+            } else {
+                channelEventListener = null;
+            }
+            this.mQClientAPIImpl = new MQClientAPIImpl(this.nettyClientConfig, 
clientRemotingProcessor, rpcHook, clientConfig, channelEventListener);
+            clientAPI = this.mQClientAPIImpl;
 
-        if (this.clientConfig.getNamesrvAddr() != null) {
-            
this.mQClientAPIImpl.updateNameServerAddressList(this.clientConfig.getNamesrvAddr());
-            log.info("user specified name server address: {}", 
this.clientConfig.getNamesrvAddr());
-        }
+            if (this.clientConfig.getNamesrvAddr() != null) {
+                
this.mQClientAPIImpl.updateNameServerAddressList(this.clientConfig.getNamesrvAddr());
+                log.info("user specified name server address: {}", 
this.clientConfig.getNamesrvAddr());
+            }
+
+            this.clientId = clientId;
 
-        this.clientId = clientId;
+            this.mQAdminImpl = new MQAdminImpl(this);
 
-        this.mQAdminImpl = new MQAdminImpl(this);
+            this.pullMessageService = new PullMessageService(this);
 
-        this.pullMessageService = new PullMessageService(this);
+            this.rebalanceService = new RebalanceService(this);
 
-        this.rebalanceService = new RebalanceService(this);
+            this.defaultMQProducer = new 
DefaultMQProducer(MixAll.CLIENT_INNER_PRODUCER_GROUP);
+            this.defaultMQProducer.resetClientConfig(clientConfig);
 
-        this.defaultMQProducer = new 
DefaultMQProducer(MixAll.CLIENT_INNER_PRODUCER_GROUP);
-        this.defaultMQProducer.resetClientConfig(clientConfig);
+            this.consumerStatsManager = new 
ConsumerStatsManager(this.scheduledExecutorService);
+
+            if (this.clientConfig.isEnableConcurrentHeartbeat()) {
+                this.concurrentHeartbeatExecutor = 
Executors.newFixedThreadPool(
+                    clientConfig.getConcurrentHeartbeatThreadPoolSize(),
+                    new 
ThreadFactoryImpl("MQClientConcurrentHeartbeatThread_", true));
+            }
 
-        this.consumerStatsManager = new 
ConsumerStatsManager(this.scheduledExecutorService);
+            log.info("Created a new client Instance, InstanceIndex:{}, 
ClientID:{}, ClientConfig:{}, ClientVersion:{}, SerializerType:{}",
+                instanceIndex,
+                this.clientId,
+                this.clientConfig,
+                MQVersion.getVersionDesc(MQVersion.CURRENT_VERSION), 
RemotingCommand.getSerializeTypeConfigInThisServer());
+        } catch (RuntimeException | Error e) {
+            cleanupAfterConstructionFailure(clientAPI, e);
+            throw e;
+        }
+    }
 
-        if (this.clientConfig.isEnableConcurrentHeartbeat()) {
-            this.concurrentHeartbeatExecutor = Executors.newFixedThreadPool(
-                clientConfig.getConcurrentHeartbeatThreadPoolSize(),
-                new ThreadFactoryImpl("MQClientConcurrentHeartbeatThread_", 
true));
+    private void cleanupAfterConstructionFailure(MQClientAPIImpl clientAPI, 
Throwable cause) {
+        runCleanup(this.scheduledExecutorService::shutdownNow, cause);
+        if (this.concurrentHeartbeatExecutor != null) {
+            runCleanup(this.concurrentHeartbeatExecutor::shutdownNow, cause);
         }
+        if (clientAPI != null) {
+            runCleanup(clientAPI::shutdown, cause);
+        }
+    }
 
-        log.info("Created a new client Instance, InstanceIndex:{}, 
ClientID:{}, ClientConfig:{}, ClientVersion:{}, SerializerType:{}",
-            instanceIndex,
-            this.clientId,
-            this.clientConfig,
-            MQVersion.getVersionDesc(MQVersion.CURRENT_VERSION), 
RemotingCommand.getSerializeTypeConfigInThisServer());
+    private static void runCleanup(Runnable cleanup, Throwable cause) {
+        try {
+            cleanup.run();
+        } catch (Throwable t) {
+            // Cleanup on Error paths is best effort; always preserve the 
original failure.
+            if (t != cause) {
+                cause.addSuppressed(t);
+            }
+        }
     }
 
     public static TopicPublishInfo topicRouteData2TopicPublishInfo(final 
String topic, final TopicRouteData route) {
@@ -313,22 +341,31 @@ public class MQClientInstance {
             switch (this.serviceState) {
                 case CREATE_JUST:
                     this.serviceState = ServiceState.START_FAILED;
-                    // If not specified,looking address from name server
-                    if (null == this.clientConfig.getNamesrvAddr()) {
-                        this.mQClientAPIImpl.fetchNameServerAddr();
+                    try {
+                        // If not specified,looking address from name server
+                        if (null == this.clientConfig.getNamesrvAddr()) {
+                            this.mQClientAPIImpl.fetchNameServerAddr();
+                        }
+                        // Start request-response channel
+                        this.mQClientAPIImpl.start();
+                        // Start various schedule tasks
+                        this.startScheduledTask();
+                        // Start pull service
+                        this.pullMessageService.start();
+                        // Start rebalance service
+                        this.rebalanceService.start();
+                        // Start push service
+                        
this.defaultMQProducer.getDefaultMQProducerImpl().start(false);
+                        log.info("the client factory [{}] start OK", 
this.clientId);
+                        this.serviceState = ServiceState.RUNNING;
+                    } catch (MQClientException | RuntimeException | Error e) {
+                        // Do not apply the normal shutdown registration 
guards here: a factory that never reached
+                        // RUNNING cannot serve any registered client, and its 
partially started resources must stop.
+                        // Existing holders still observe START_FAILED; a 
later manager lookup may create a replacement.
+                        cleanupAfterStartFailure(e);
+                        
MQClientManager.getInstance().removeClientFactory(this.clientId, this);
+                        throw e;
                     }
-                    // Start request-response channel
-                    this.mQClientAPIImpl.start();
-                    // Start various schedule tasks
-                    this.startScheduledTask();
-                    // Start pull service
-                    this.pullMessageService.start();
-                    // Start rebalance service
-                    this.rebalanceService.start();
-                    // Start push service
-                    
this.defaultMQProducer.getDefaultMQProducerImpl().start(false);
-                    log.info("the client factory [{}] start OK", 
this.clientId);
-                    this.serviceState = ServiceState.RUNNING;
                     break;
                 case START_FAILED:
                     throw new MQClientException("The Factory object[" + 
this.getClientId() + "] has been created before, and failed.", null);
@@ -338,6 +375,17 @@ public class MQClientInstance {
         }
     }
 
+    private void cleanupAfterStartFailure(Throwable cause) {
+        runCleanup(this.scheduledExecutorService::shutdownNow, cause);
+        if (this.concurrentHeartbeatExecutor != null) {
+            runCleanup(this.concurrentHeartbeatExecutor::shutdownNow, cause);
+        }
+        runCleanup(() -> 
this.defaultMQProducer.getDefaultMQProducerImpl().shutdown(false), cause);
+        runCleanup(() -> this.pullMessageService.shutdown(true), cause);
+        runCleanup(this.rebalanceService::shutdown, cause);
+        runCleanup(this.mQClientAPIImpl::shutdown, cause);
+    }
+
     private void startScheduledTask() {
         if (null == this.clientConfig.getNamesrvAddr()) {
             this.scheduledExecutorService.scheduleAtFixedRate(() -> {
@@ -1077,7 +1125,7 @@ public class MQClientInstance {
                         this.concurrentHeartbeatExecutor.shutdown();
                     }
 
-                    
MQClientManager.getInstance().removeClientFactory(this.clientId);
+                    
MQClientManager.getInstance().removeClientFactory(this.clientId, this);
                     log.info("the client factory [{}] shutdown OK", 
this.clientId);
                     break;
                 case CREATE_JUST:
diff --git 
a/client/src/test/java/org/apache/rocketmq/client/impl/MQClientManagerTest.java 
b/client/src/test/java/org/apache/rocketmq/client/impl/MQClientManagerTest.java
new file mode 100644
index 0000000000..82aea4c421
--- /dev/null
+++ 
b/client/src/test/java/org/apache/rocketmq/client/impl/MQClientManagerTest.java
@@ -0,0 +1,440 @@
+/*
+ * 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.client.impl;
+
+import java.lang.reflect.Constructor;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.IdentityHashMap;
+import java.util.List;
+import java.util.Set;
+import java.util.concurrent.BrokenBarrierException;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.CyclicBarrier;
+import java.util.concurrent.ExecutionException;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.concurrent.Future;
+import java.util.concurrent.ScheduledExecutorService;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.TimeoutException;
+import java.util.concurrent.atomic.AtomicInteger;
+import org.apache.commons.lang3.reflect.FieldUtils;
+import org.apache.rocketmq.client.ClientConfig;
+import org.apache.rocketmq.client.exception.MQClientException;
+import org.apache.rocketmq.client.impl.consumer.PullMessageService;
+import org.apache.rocketmq.client.impl.factory.MQClientInstance;
+import org.apache.rocketmq.client.impl.producer.DefaultMQProducerImpl;
+import org.apache.rocketmq.common.ServiceState;
+import org.junit.After;
+import org.junit.Test;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.junit.Assert.assertSame;
+import static org.junit.Assert.assertThrows;
+import static org.junit.Assert.fail;
+import static org.mockito.Mockito.doThrow;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verify;
+
+public class MQClientManagerTest {
+    private static final String SCHEDULER_THREAD_NAME = 
"MQClientFactoryScheduledThread";
+    private static final long TIMEOUT_SECONDS = 30;
+
+    private final Set<MQClientInstance> instancesToDispose =
+        Collections.newSetFromMap(new IdentityHashMap<MQClientInstance, 
Boolean>());
+
+    @After
+    public void tearDown() throws Exception {
+        for (MQClientInstance instance : instancesToDispose) {
+            dispose(instance);
+        }
+        instancesToDispose.clear();
+    }
+
+    @Test
+    public void concurrentSameClientIdCreatesOneInstance() throws Exception {
+        int callers = 16;
+        MQClientManager manager = newManager();
+        FieldUtils.writeDeclaredField(manager, "factoryTable", new 
BarrierGetMap<>(callers), true);
+        ThreadGroup threadGroup = new ThreadGroup("same-client-id-" + 
System.nanoTime());
+        ExecutorService executor = newExecutor(threadGroup, callers);
+        ClientConfig config = newConfig("same-client-id");
+
+        try {
+            List<Future<MQClientInstance>> futures = new ArrayList<>();
+            for (int i = 0; i < callers; i++) {
+                futures.add(executor.submit(() -> 
manager.getOrCreateMQClientInstance(config)));
+            }
+
+            Set<MQClientInstance> returned = Collections.newSetFromMap(
+                new IdentityHashMap<MQClientInstance, Boolean>());
+            for (Future<MQClientInstance> future : futures) {
+                returned.add(future.get(TIMEOUT_SECONDS, TimeUnit.SECONDS));
+            }
+
+            assertThat(returned).hasSize(1);
+            MQClientInstance instance = returned.iterator().next();
+            track(instance);
+            assertSame(instance, 
manager.getFactoryTable().get(config.buildMQClientId()));
+            assertThat(factoryIndex(manager).get()).isEqualTo(1);
+            awaitSchedulerThreadCount(threadGroup, 1);
+        } finally {
+            shutdown(executor);
+        }
+    }
+
+    @Test
+    public void differentClientIdsAreConstructedConcurrently() throws 
Exception {
+        MQClientManager manager = newManager();
+        CyclicBarrier constructorBarrier = new CyclicBarrier(2);
+        ThreadGroup threadGroup = new ThreadGroup("different-client-id-" + 
System.nanoTime());
+        ExecutorService executor = newExecutor(threadGroup, 2);
+        ClientConfig firstConfig = concurrentConstructorConfig(
+            "different-a-" + System.nanoTime(), constructorBarrier);
+        ClientConfig secondConfig = concurrentConstructorConfig(
+            differentHashBinInstanceName(firstConfig.buildMQClientId(), 
"different-b"), constructorBarrier);
+
+        try {
+            Future<MQClientInstance> firstFuture = executor.submit(
+                () -> manager.getOrCreateMQClientInstance(firstConfig));
+            Future<MQClientInstance> secondFuture = executor.submit(
+                () -> manager.getOrCreateMQClientInstance(secondConfig));
+
+            MQClientInstance first = firstFuture.get(TIMEOUT_SECONDS, 
TimeUnit.SECONDS);
+            MQClientInstance second = secondFuture.get(TIMEOUT_SECONDS, 
TimeUnit.SECONDS);
+            track(first);
+            track(second);
+            assertThat(first).isNotSameAs(second);
+            assertThat(manager.getFactoryTable()).hasSize(2);
+            assertThat(factoryIndex(manager).get()).isEqualTo(2);
+            awaitSchedulerThreadCount(threadGroup, 2);
+        } finally {
+            shutdown(executor);
+        }
+    }
+
+    @Test
+    public void constructorFailureRollsBackResourcesAndAllowsRetry() throws 
Exception {
+        MQClientManager manager = newManager();
+        ThreadGroup threadGroup = new ThreadGroup("constructor-failure-" + 
System.nanoTime());
+        ExecutorService executor = newExecutor(threadGroup, 1);
+        ClientConfig config = newConfig("constructor-failure");
+        // The heartbeat executor is currently created after 
ConsumerStatsManager registers its tasks. A zero-sized
+        // pool therefore injects a late constructor failure and verifies 
rollback of already-started resources.
+        config.setEnableConcurrentHeartbeat(true);
+        config.setConcurrentHeartbeatThreadPoolSize(0);
+
+        try {
+            Future<MQClientInstance> failed = executor.submit(
+                () -> manager.getOrCreateMQClientInstance(config));
+            try {
+                failed.get(TIMEOUT_SECONDS, TimeUnit.SECONDS);
+                fail("Expected constructor failure");
+            } catch (ExecutionException e) {
+                
assertThat(e.getCause()).isInstanceOf(IllegalArgumentException.class);
+            }
+
+            
assertThat(manager.getFactoryTable()).doesNotContainKey(config.buildMQClientId());
+            awaitSchedulerThreadCount(threadGroup, 0);
+
+            config.setConcurrentHeartbeatThreadPoolSize(1);
+            MQClientInstance retried = executor.submit(
+                () -> 
manager.getOrCreateMQClientInstance(config)).get(TIMEOUT_SECONDS, 
TimeUnit.SECONDS);
+            track(retried);
+            assertSame(retried, 
manager.getFactoryTable().get(config.buildMQClientId()));
+            awaitSchedulerThreadCount(threadGroup, 1);
+        } finally {
+            shutdown(executor);
+        }
+    }
+
+    @Test
+    public void removeAllowsInstanceToBeRecreated() throws Exception {
+        MQClientManager manager = newManager();
+        ClientConfig config = newConfig("remove-recreate");
+        MQClientInstance first = manager.getOrCreateMQClientInstance(config);
+        track(first);
+
+        manager.removeClientFactory(config.buildMQClientId());
+        MQClientInstance second = manager.getOrCreateMQClientInstance(config);
+        track(second);
+
+        assertThat(second).isNotSameAs(first);
+        assertSame(second, 
manager.getFactoryTable().get(config.buildMQClientId()));
+        manager.removeClientFactory(config.buildMQClientId(), first);
+        assertSame(second, 
manager.getFactoryTable().get(config.buildMQClientId()));
+    }
+
+    @Test
+    public void startAndShutdownRemainIdempotentAndAllowRecreation() throws 
Exception {
+        MQClientManager manager = MQClientManager.getInstance();
+        ClientConfig config = newConfig("start-shutdown");
+        ThreadGroup threadGroup = new ThreadGroup("start-shutdown-" + 
System.nanoTime());
+        ExecutorService executor = newExecutor(threadGroup, 1);
+
+        try {
+            MQClientInstance first = executor.submit(
+                () -> 
manager.getOrCreateMQClientInstance(config)).get(TIMEOUT_SECONDS, 
TimeUnit.SECONDS);
+            track(first);
+            first.start();
+            first.start();
+            assertThat(serviceState(first)).isEqualTo(ServiceState.RUNNING);
+
+            first.shutdown();
+            first.shutdown();
+            
assertThat(serviceState(first)).isEqualTo(ServiceState.SHUTDOWN_ALREADY);
+            
assertThat(manager.getFactoryTable()).doesNotContainKey(config.buildMQClientId());
+            assertExecutorTerminated(scheduler(first));
+
+            MQClientInstance second = executor.submit(
+                () -> 
manager.getOrCreateMQClientInstance(config)).get(TIMEOUT_SECONDS, 
TimeUnit.SECONDS);
+            track(second);
+            assertThat(second).isNotSameAs(first);
+            assertSame(second, 
manager.getFactoryTable().get(config.buildMQClientId()));
+        } finally {
+            shutdown(executor);
+        }
+    }
+
+    @Test
+    public void startFailureRollsBackResourcesAndAllowsReplacement() throws 
Exception {
+        MQClientManager manager = MQClientManager.getInstance();
+        ClientConfig config = newConfig("start-failure");
+        ThreadGroup threadGroup = new ThreadGroup("start-failure-" + 
System.nanoTime());
+        ExecutorService executor = newExecutor(threadGroup, 1);
+
+        try {
+            MQClientInstance failedInstance = executor.submit(
+                () -> 
manager.getOrCreateMQClientInstance(config)).get(TIMEOUT_SECONDS, 
TimeUnit.SECONDS);
+            track(failedInstance);
+            MQClientAPIImpl originalClientAPI = clientAPI(failedInstance);
+            originalClientAPI.shutdown();
+            PullMessageService originalPullMessageService = 
(PullMessageService) FieldUtils.readDeclaredField(
+                failedInstance, "pullMessageService", true);
+            originalPullMessageService.shutdown(true);
+            MQClientAPIImpl clientAPI = mock(MQClientAPIImpl.class);
+            PullMessageService failingPullMessageService = 
mock(PullMessageService.class);
+            doThrow(new IllegalStateException("injected start 
failure")).when(failingPullMessageService).start();
+            // Test-only fault injection deliberately replaces final 
collaborators by field name. This avoids adding
+            // production injection hooks, but these assignments must be 
updated if the fields are renamed.
+            FieldUtils.writeDeclaredField(failedInstance, "mQClientAPIImpl", 
clientAPI, true);
+            FieldUtils.writeDeclaredField(failedInstance, 
"pullMessageService", failingPullMessageService, true);
+
+            assertThrows(IllegalStateException.class, failedInstance::start);
+            verify(clientAPI).start();
+            verify(clientAPI).shutdown();
+            verify(failingPullMessageService).start();
+            verify(failingPullMessageService).shutdown(true);
+            
assertThat(serviceState(failedInstance)).isEqualTo(ServiceState.START_FAILED);
+            
assertThat(manager.getFactoryTable()).doesNotContainKey(config.buildMQClientId());
+            assertExecutorTerminated(scheduler(failedInstance));
+            awaitSchedulerThreadCount(threadGroup, 0);
+            assertThrows(MQClientException.class, failedInstance::start);
+
+            MQClientInstance replacement = executor.submit(
+                () -> 
manager.getOrCreateMQClientInstance(config)).get(TIMEOUT_SECONDS, 
TimeUnit.SECONDS);
+            track(replacement);
+            assertThat(replacement).isNotSameAs(failedInstance);
+            assertSame(replacement, 
manager.getFactoryTable().get(config.buildMQClientId()));
+        } finally {
+            shutdown(executor);
+        }
+    }
+
+    @Test
+    public void staleShutdownDoesNotRemoveReplacement() throws Exception {
+        MQClientManager manager = MQClientManager.getInstance();
+        ClientConfig config = newConfig("stale-shutdown");
+        MQClientInstance first = manager.getOrCreateMQClientInstance(config);
+        track(first);
+        first.start();
+
+        manager.removeClientFactory(config.buildMQClientId());
+        MQClientInstance replacement = 
manager.getOrCreateMQClientInstance(config);
+        track(replacement);
+        first.shutdown();
+
+        assertSame(replacement, 
manager.getFactoryTable().get(config.buildMQClientId()));
+    }
+
+    private MQClientInstance track(MQClientInstance instance) {
+        instancesToDispose.add(instance);
+        return instance;
+    }
+
+    private static MQClientManager newManager() throws Exception {
+        Constructor<MQClientManager> constructor = 
MQClientManager.class.getDeclaredConstructor();
+        constructor.setAccessible(true);
+        return constructor.newInstance();
+    }
+
+    private static ClientConfig newConfig(String suffix) {
+        ClientConfig config = new ClientConfig();
+        config.setInstanceName(suffix + "-" + System.nanoTime());
+        config.setNamesrvAddr("127.0.0.1:9876");
+        return config;
+    }
+
+    private static ClientConfig concurrentConstructorConfig(String 
instanceName, CyclicBarrier barrier) {
+        ClientConfig config = new ClientConfig() {
+            @Override
+            public ClientConfig cloneClientConfig() {
+                ClientConfig cloned = new ClientConfig() {
+                    @Override
+                    public int getClientCallbackExecutorThreads() {
+                        await(barrier);
+                        return super.getClientCallbackExecutorThreads();
+                    }
+                };
+                cloned.resetClientConfig(this);
+                return cloned;
+            }
+        };
+        config.setInstanceName(instanceName);
+        config.setNamesrvAddr("127.0.0.1:9876");
+        return config;
+    }
+
+    private static String differentHashBinInstanceName(String firstClientId, 
String prefix) {
+        // ConcurrentHashMap currently spreads h as h ^ (h >>> 16) and starts 
with 16 bins. Selecting a different
+        // initial bin makes the constructor barrier prove that creation is 
not globally serialized. Update this
+        // helper if the JDK's ConcurrentHashMap hashing or initial table size 
changes.
+        int firstBin = spread(firstClientId.hashCode()) & 15;
+        for (int i = 0; ; i++) {
+            String candidate = prefix + "-" + i;
+            ClientConfig config = new ClientConfig();
+            config.setInstanceName(candidate);
+            if ((spread(config.buildMQClientId().hashCode()) & 15) != 
firstBin) {
+                return candidate;
+            }
+        }
+    }
+
+    private static int spread(int hashCode) {
+        return hashCode ^ (hashCode >>> 16);
+    }
+
+    private static AtomicInteger factoryIndex(MQClientManager manager) throws 
IllegalAccessException {
+        return (AtomicInteger) FieldUtils.readDeclaredField(manager, 
"factoryIndexGenerator", true);
+    }
+
+    private static ServiceState serviceState(MQClientInstance instance) throws 
IllegalAccessException {
+        return (ServiceState) FieldUtils.readDeclaredField(instance, 
"serviceState", true);
+    }
+
+    private static ScheduledExecutorService scheduler(MQClientInstance 
instance) throws IllegalAccessException {
+        return (ScheduledExecutorService) FieldUtils.readDeclaredField(
+            instance, "scheduledExecutorService", true);
+    }
+
+    private static MQClientAPIImpl clientAPI(MQClientInstance instance) throws 
IllegalAccessException {
+        return (MQClientAPIImpl) FieldUtils.readDeclaredField(instance, 
"mQClientAPIImpl", true);
+    }
+
+    private static ExecutorService newExecutor(ThreadGroup threadGroup, int 
threads) {
+        AtomicInteger index = new AtomicInteger();
+        return Executors.newFixedThreadPool(threads,
+            task -> new Thread(threadGroup, task, "MQClientManagerTestCaller-" 
+ index.getAndIncrement()));
+    }
+
+    private static void shutdown(ExecutorService executor) throws 
InterruptedException {
+        executor.shutdownNow();
+        assertThat(executor.awaitTermination(TIMEOUT_SECONDS, 
TimeUnit.SECONDS)).isTrue();
+    }
+
+    private static void awaitSchedulerThreadCount(ThreadGroup threadGroup, int 
expected) {
+        org.awaitility.Awaitility.await().atMost(TIMEOUT_SECONDS, 
TimeUnit.SECONDS).untilAsserted(() ->
+            assertThat(countThreads(threadGroup, 
SCHEDULER_THREAD_NAME)).isEqualTo(expected));
+    }
+
+    private static int countThreads(ThreadGroup threadGroup, String 
threadName) {
+        int capacity = Math.max(16, threadGroup.activeCount() * 2);
+        while (true) {
+            Thread[] threads = new Thread[capacity];
+            int count = threadGroup.enumerate(threads, true);
+            if (count < capacity) {
+                int matches = 0;
+                for (int i = 0; i < count; i++) {
+                    if (threadName.equals(threads[i].getName()) && 
threads[i].isAlive()) {
+                        matches++;
+                    }
+                }
+                return matches;
+            }
+            capacity *= 2;
+        }
+    }
+
+    private static void assertExecutorTerminated(ScheduledExecutorService 
executor) throws InterruptedException {
+        assertThat(executor.isShutdown()).isTrue();
+        assertThat(executor.awaitTermination(TIMEOUT_SECONDS, 
TimeUnit.SECONDS)).isTrue();
+    }
+
+    private static void dispose(MQClientInstance instance) throws Exception {
+        try {
+            instance.shutdown();
+        } finally {
+            ScheduledExecutorService scheduler = scheduler(instance);
+            scheduler.shutdownNow();
+            scheduler.awaitTermination(TIMEOUT_SECONDS, TimeUnit.SECONDS);
+
+            ExecutorService heartbeatExecutor = (ExecutorService) 
FieldUtils.readDeclaredField(
+                instance, "concurrentHeartbeatExecutor", true);
+            if (heartbeatExecutor != null) {
+                heartbeatExecutor.shutdownNow();
+                heartbeatExecutor.awaitTermination(TIMEOUT_SECONDS, 
TimeUnit.SECONDS);
+            }
+
+            DefaultMQProducerImpl producer = (DefaultMQProducerImpl) 
FieldUtils.readDeclaredField(
+                FieldUtils.readDeclaredField(instance, "defaultMQProducer", 
true),
+                "defaultMQProducerImpl", true);
+            producer.shutdown(false);
+            ((PullMessageService) FieldUtils.readDeclaredField(instance, 
"pullMessageService", true)).shutdown(true);
+            clientAPI(instance).shutdown();
+            
MQClientManager.getInstance().removeClientFactory(instance.getClientId(), 
instance);
+        }
+    }
+
+    private static void await(CyclicBarrier barrier) {
+        try {
+            barrier.await(TIMEOUT_SECONDS, TimeUnit.SECONDS);
+        } catch (InterruptedException e) {
+            Thread.currentThread().interrupt();
+            throw new AssertionError(e);
+        } catch (BrokenBarrierException | TimeoutException e) {
+            throw new AssertionError(e);
+        }
+    }
+
+    private static class BarrierGetMap<K, V> extends ConcurrentHashMap<K, V> {
+        private final CyclicBarrier barrier;
+
+        BarrierGetMap(int parties) {
+            this.barrier = new CyclicBarrier(parties);
+        }
+
+        @Override
+        public V get(Object key) {
+            V value = super.get(key);
+            if (value == null) {
+                await(barrier);
+            }
+            return value;
+        }
+    }
+}
diff --git 
a/common/src/test/java/org/apache/rocketmq/common/ServiceThreadTest.java 
b/common/src/test/java/org/apache/rocketmq/common/ServiceThreadTest.java
index e27fd497bd..9cb70f5210 100644
--- a/common/src/test/java/org/apache/rocketmq/common/ServiceThreadTest.java
+++ b/common/src/test/java/org/apache/rocketmq/common/ServiceThreadTest.java
@@ -17,6 +17,7 @@
 
 package org.apache.rocketmq.common;
 
+import java.util.concurrent.CyclicBarrier;
 import java.util.concurrent.ExecutorService;
 import java.util.concurrent.Executors;
 import java.util.concurrent.TimeUnit;
@@ -159,17 +160,15 @@ public class ServiceThreadTest {
      */
     @Test(timeout = 30000)
     public void serviceThreadShouldNotLoseWakeupUnderStress() throws Exception 
{
-        final int stressIterations = 10000;
+        final int stressIterations = 1000;
         final int wakerThreads = 4;
-        final long waitTimeoutMs = 20;
-        final long lostWakeupThresholdMs = 18;
-
-        StressServiceThread service = new StressServiceThread();
-        AtomicInteger activeIteration = new AtomicInteger(-1);
-        AtomicInteger completedIteration = new AtomicInteger(-1);
-        AtomicInteger lostWakeups = new AtomicInteger(0);
-        AtomicInteger maxElapsedMs = new AtomicInteger(0);
-        AtomicBoolean running = new AtomicBoolean(true);
+        final long waitTimeoutMs = TimeUnit.MINUTES.toMillis(2);
+        final long coordinationTimeoutSeconds = 20;
+
+        CyclicBarrier iterationStart = new CyclicBarrier(wakerThreads + 1);
+        CyclicBarrier iterationComplete = new CyclicBarrier(wakerThreads + 1);
+        AtomicReference<StressServiceThread> activeService = new 
AtomicReference<>();
+        AtomicInteger completedIterations = new AtomicInteger(0);
         AtomicReference<Throwable> failure = new AtomicReference<>();
         ExecutorService executor = Executors.newFixedThreadPool(wakerThreads + 
1);
 
@@ -177,32 +176,28 @@ public class ServiceThreadTest {
             executor.submit(() -> {
                 try {
                     for (int i = 0; i < stressIterations; i++) {
-                        activeIteration.set(i);
-                        long elapsed = service.awaitOnce(waitTimeoutMs);
-                        maxElapsedMs.accumulateAndGet((int) elapsed, 
Math::max);
-                        if (elapsed >= lostWakeupThresholdMs) {
-                            lostWakeups.incrementAndGet();
-                            running.set(false);
-                            break;
-                        }
-                        completedIteration.set(i);
-                        Thread.yield();
+                        StressServiceThread service = new 
StressServiceThread();
+                        activeService.set(service);
+                        iterationStart.await(coordinationTimeoutSeconds, 
TimeUnit.SECONDS);
+                        service.awaitOnce(waitTimeoutMs);
+                        completedIterations.incrementAndGet();
+                        iterationComplete.await(coordinationTimeoutSeconds, 
TimeUnit.SECONDS);
                     }
                 } catch (Throwable t) {
                     failure.compareAndSet(null, t);
-                } finally {
-                    running.set(false);
                 }
             });
 
             for (int w = 0; w < wakerThreads; w++) {
                 executor.submit(() -> {
-                    while (running.get()) {
-                        int iteration = activeIteration.get();
-                        if (iteration >= 0 && completedIteration.get() < 
iteration) {
-                            service.wakeup();
+                    try {
+                        for (int i = 0; i < stressIterations; i++) {
+                            iterationStart.await(coordinationTimeoutSeconds, 
TimeUnit.SECONDS);
+                            activeService.get().wakeup();
+                            
iterationComplete.await(coordinationTimeoutSeconds, TimeUnit.SECONDS);
                         }
-                        Thread.yield();
+                    } catch (Throwable t) {
+                        failure.compareAndSet(null, t);
                     }
                 });
             }
@@ -214,10 +209,9 @@ public class ServiceThreadTest {
             if (error != null) {
                 throw new AssertionError("stress test failed", error);
             }
-            assertEquals("ServiceThread lost wakeups under stress 
(maxElapsedMs=" + maxElapsedMs.get() + ")",
-                0, lostWakeups.get());
+            assertEquals("ServiceThread must complete every notified wait", 
stressIterations,
+                completedIterations.get());
         } finally {
-            running.set(false);
             executor.shutdownNow();
         }
     }
@@ -283,10 +277,8 @@ public class ServiceThreadTest {
         public void run() {
         }
 
-        long awaitOnce(long intervalMillis) {
-            long begin = System.nanoTime();
+        void awaitOnce(long intervalMillis) {
             waitForRunning(intervalMillis);
-            return TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - begin);
         }
     }
 }
diff --git 
a/test/src/test/java/org/apache/rocketmq/test/client/consumer/pop/BatchAckIT.java
 
b/test/src/test/java/org/apache/rocketmq/test/client/consumer/pop/BatchAckIT.java
index ec9153ccc9..fdc8118d64 100644
--- 
a/test/src/test/java/org/apache/rocketmq/test/client/consumer/pop/BatchAckIT.java
+++ 
b/test/src/test/java/org/apache/rocketmq/test/client/consumer/pop/BatchAckIT.java
@@ -47,6 +47,8 @@ import static org.awaitility.Awaitility.await;
 import static org.junit.Assert.assertEquals;
 
 public class BatchAckIT extends BasePop {
+    private static final int QUEUE_COUNT = 8;
+    private static final Duration POP_ASSERT_TIMEOUT = Duration.ofSeconds(30);
 
     protected String topic;
     protected String group;
@@ -60,7 +62,8 @@ public class BatchAckIT extends BasePop {
         brokerAddr = brokerController1.getBrokerAddr();
         topic = MQRandomUtils.getRandomTopic();
         group = initConsumerGroup();
-        IntegrationTestBase.initTopic(topic, NAMESRV_ADDR, BROKER1_NAME, 8, 
CQType.SimpleCQ, TopicMessageType.NORMAL);
+        IntegrationTestBase.initTopic(topic, NAMESRV_ADDR, BROKER1_NAME, 
QUEUE_COUNT, CQType.SimpleCQ,
+            TopicMessageType.NORMAL);
         producer = getProducer(NAMESRV_ADDR, topic);
         client = getRMQPopClient();
         messageQueue = new MessageQueue(topic, BROKER1_NAME, -1);
@@ -113,8 +116,10 @@ public class BatchAckIT extends BasePop {
     public void testBatchAck(Supplier<PopResult> popResultSupplier) throws 
Throwable {
         // Send 10 messages but do not ack, let them enter the retry topic
         producer.send(10);
+        awaitStoredMessageCount(10);
         AtomicInteger firstMsgRcvNum = new AtomicInteger();
-        await().atMost(Duration.ofSeconds(3)).untilAsserted(() -> {
+        // A single POP long poll can take up to three seconds, so leave 
enough time for retries on a busy CI runner.
+        await().atMost(POP_ASSERT_TIMEOUT).untilAsserted(() -> {
             PopResult popResult = popResultSupplier.get();
             if (popResult.getPopStatus().equals(PopStatus.FOUND)) {
                 firstMsgRcvNum.addAndGet(popResult.getMsgFoundList().size());
@@ -125,8 +130,9 @@ public class BatchAckIT extends BasePop {
         TimeUnit.SECONDS.sleep(6);
 
         producer.send(20);
+        awaitStoredMessageCount(30);
         List<String> extraInfoList = new ArrayList<>();
-        await().atMost(Duration.ofSeconds(3)).untilAsserted(() -> {
+        await().atMost(POP_ASSERT_TIMEOUT).untilAsserted(() -> {
             PopResult popResult = popResultSupplier.get();
             if (popResult.getPopStatus().equals(PopStatus.FOUND)) {
                 for (MessageExt messageExt : popResult.getMsgFoundList()) {
@@ -145,6 +151,18 @@ public class BatchAckIT extends BasePop {
         assertEquals(PopStatus.POLLING_NOT_FOUND, popResult.getPopStatus());
     }
 
+    private void awaitStoredMessageCount(int expectedCount) {
+        // Sending completes before consume-queue dispatch necessarily catches 
up. Starting an orderly POP too early can
+        // lock a partially dispatched queue and prevent the remainder from 
being returned by a subsequent POP.
+        await().atMost(POP_ASSERT_TIMEOUT).untilAsserted(() -> {
+            long storedMessageCount = 0;
+            for (int queueId = 0; queueId < QUEUE_COUNT; queueId++) {
+                storedMessageCount += 
brokerController1.getMessageStore().getMaxOffsetInQueue(topic, queueId);
+            }
+            assertEquals(expectedCount, storedMessageCount);
+        });
+    }
+
     private CompletableFuture<PopResult> popMessageAsync() {
         return client.popMessageAsync(
             brokerAddr, messageQueue, Duration.ofSeconds(3).toMillis(), 30, 
group, 3000, false,
diff --git 
a/test/src/test/java/org/apache/rocketmq/test/client/producer/querymsg/QueryMsgByKeyIT.java
 
b/test/src/test/java/org/apache/rocketmq/test/client/producer/querymsg/QueryMsgByKeyIT.java
index 69dd26cf84..87ae5285e8 100644
--- 
a/test/src/test/java/org/apache/rocketmq/test/client/producer/querymsg/QueryMsgByKeyIT.java
+++ 
b/test/src/test/java/org/apache/rocketmq/test/client/producer/querymsg/QueryMsgByKeyIT.java
@@ -17,6 +17,7 @@
 
 package org.apache.rocketmq.test.client.producer.querymsg;
 
+import java.time.Duration;
 import java.util.List;
 
 import org.apache.rocketmq.client.exception.MQClientException;
@@ -33,6 +34,7 @@ import org.junit.Before;
 import org.junit.Test;
 
 import static com.google.common.truth.Truth.assertThat;
+import static org.awaitility.Awaitility.await;
 
 public class QueryMsgByKeyIT extends BaseConf {
     private static Logger logger = 
LoggerFactory.getLogger(QueryMsgByKeyIT.class);
@@ -154,9 +156,12 @@ public class QueryMsgByKeyIT extends BaseConf {
 
         long begin = System.currentTimeMillis() - 500000;
         long end = System.currentTimeMillis() + 500000;
-        List<MessageExt> list = producerA.getProducer().queryMessage(topicA, 
keyA, msgSize * 10, begin, end).getMessageList();
-
-        assertThat(list).isNotNull();
-        assertThat(list.size()).isEqualTo(1);
+        // Message indexes are built asynchronously, so querying immediately 
after send can temporarily return no message.
+        
await().ignoreException(MQClientException.class).atMost(Duration.ofSeconds(30)).untilAsserted(()
 -> {
+            List<MessageExt> list = producerA.getProducer()
+                .queryMessage(topicA, keyA, msgSize * 10, begin, 
end).getMessageList();
+            assertThat(list).isNotNull();
+            assertThat(list.size()).isEqualTo(1);
+        });
     }
 }

Reply via email to