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

jt2594838 pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/iotdb.git


The following commit(s) were added to refs/heads/master by this push:
     new 389e374abc1 Repair missing subscription brokers from consumer metadata 
(#19021)
389e374abc1 is described below

commit 389e374abc15e876e68a1a413c1b5fb999bb8553
Author: Caideyipi <[email protected]>
AuthorDate: Tue Oct 27 10:36:11 2026 +0800

    Repair missing subscription brokers from consumer metadata (#19021)
    
    * Repair missing subscription brokers from consumer metadata
    
    * Fix subscription broker recovery rollback
    
    * Serialize subscription broker repair with region creation
    
    * Add cluster coverage for subscription broker recovery
---
 ...IoTDBConsensusSubscriptionBrokerRecoveryIT.java | 428 +++++++++++++++++++++
 .../apache/iotdb/consensus/iot/IoTConsensus.java   |  36 +-
 .../consensus/iot/IoTConsensusRecoveryTest.java    |  22 ++
 .../agent/SubscriptionBrokerAgent.java             |  27 ++
 .../agent/SubscriptionConsumerAgent.java           |  77 +++-
 .../broker/ConsensusSubscriptionBroker.java        |  46 +++
 .../ConsensusSubscriptionCommitManager.java        |  27 +-
 .../ConsensusSubscriptionSetupHandler.java         | 278 ++++++++-----
 .../receiver/SubscriptionReceiverV1.java           |   6 +
 ...ubscriptionConsumerAgentBrokerRecoveryTest.java | 279 ++++++++++++++
 .../ConsensusSubscriptionSetupHandlerTest.java     | 365 ++++++++++++++++++
 .../receiver/SubscriptionReceiverV1Test.java       |  33 ++
 12 files changed, 1489 insertions(+), 135 deletions(-)

diff --git 
a/integration-test/src/test/java/org/apache/iotdb/subscription/it/consensus/local/IoTDBConsensusSubscriptionBrokerRecoveryIT.java
 
b/integration-test/src/test/java/org/apache/iotdb/subscription/it/consensus/local/IoTDBConsensusSubscriptionBrokerRecoveryIT.java
new file mode 100644
index 00000000000..b46d6990722
--- /dev/null
+++ 
b/integration-test/src/test/java/org/apache/iotdb/subscription/it/consensus/local/IoTDBConsensusSubscriptionBrokerRecoveryIT.java
@@ -0,0 +1,428 @@
+/*
+ * 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.iotdb.subscription.it.consensus.local;
+
+import org.apache.iotdb.common.rpc.thrift.TEndPoint;
+import org.apache.iotdb.commons.client.ClientPoolFactory;
+import org.apache.iotdb.commons.client.IClientManager;
+import org.apache.iotdb.commons.client.sync.SyncConfigNodeIServiceClient;
+import org.apache.iotdb.commons.client.sync.SyncDataNodeInternalServiceClient;
+import org.apache.iotdb.commons.consensus.ConsensusGroupId;
+import org.apache.iotdb.commons.subscription.meta.consumer.ConsumerGroupMeta;
+import org.apache.iotdb.confignode.rpc.thrift.TGetAllSubscriptionInfoResp;
+import org.apache.iotdb.confignode.rpc.thrift.TGetCommitProgressReq;
+import org.apache.iotdb.confignode.rpc.thrift.TGetCommitProgressResp;
+import org.apache.iotdb.consensus.ConsensusFactory;
+import org.apache.iotdb.it.env.EnvFactory;
+import org.apache.iotdb.it.env.cluster.config.MppBaseConfig;
+import org.apache.iotdb.it.env.cluster.node.DataNodeWrapper;
+import org.apache.iotdb.it.framework.IoTDBTestRunner;
+import org.apache.iotdb.itbase.category.ClusterIT;
+import org.apache.iotdb.mpp.rpc.thrift.TPushConsumerGroupMetaResp;
+import org.apache.iotdb.mpp.rpc.thrift.TPushSingleConsumerGroupMetaReq;
+import org.apache.iotdb.rpc.TSStatusCode;
+import org.apache.iotdb.rpc.subscription.config.ConsumerConfig;
+import org.apache.iotdb.rpc.subscription.config.ConsumerConstant;
+import org.apache.iotdb.rpc.subscription.payload.poll.PollPayload;
+import org.apache.iotdb.rpc.subscription.payload.poll.RegionProgress;
+import org.apache.iotdb.rpc.subscription.payload.poll.SubscriptionPollRequest;
+import 
org.apache.iotdb.rpc.subscription.payload.poll.SubscriptionPollRequestType;
+import org.apache.iotdb.rpc.subscription.payload.poll.TopicProgress;
+import 
org.apache.iotdb.rpc.subscription.payload.request.PipeSubscribeHandshakeReq;
+import org.apache.iotdb.rpc.subscription.payload.request.PipeSubscribePollReq;
+import 
org.apache.iotdb.rpc.subscription.payload.request.SubscriptionHeartbeatReq;
+import 
org.apache.iotdb.rpc.subscription.payload.response.PipeSubscribeHeartbeatResp;
+import org.apache.iotdb.service.rpc.thrift.TPipeSubscribeResp;
+import org.apache.iotdb.session.subscription.SubscriptionSessionWrapper;
+import org.apache.iotdb.session.subscription.SubscriptionTreeSession;
+import 
org.apache.iotdb.session.subscription.consumer.tree.SubscriptionTreePullConsumer;
+import org.apache.iotdb.subscription.it.AbstractSubscriptionIT;
+
+import org.awaitility.Awaitility;
+import org.junit.After;
+import org.junit.Assert;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.experimental.categories.Category;
+import org.junit.runner.RunWith;
+
+import java.lang.reflect.Field;
+import java.nio.ByteBuffer;
+import java.time.Duration;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Properties;
+import java.util.Set;
+import java.util.concurrent.TimeUnit;
+
+@RunWith(IoTDBTestRunner.class)
+@Category({ClusterIT.class})
+public class IoTDBConsensusSubscriptionBrokerRecoveryIT extends 
AbstractSubscriptionIT {
+
+  @Override
+  @Before
+  public void setUp() throws Exception {
+    super.setUp();
+    EnvFactory.getEnv()
+        .getConfig()
+        .getCommonConfig()
+        .setConfigNodeConsensusProtocolClass(ConsensusFactory.RATIS_CONSENSUS)
+        
.setSchemaRegionConsensusProtocolClass(ConsensusFactory.RATIS_CONSENSUS)
+        .setDataRegionConsensusProtocolClass(ConsensusFactory.IOT_CONSENSUS)
+        .setSchemaReplicationFactor(1)
+        .setDataReplicationFactor(3)
+        .setAutoCreateSchemaEnabled(true)
+        .setPipeMemoryManagementEnabled(false)
+        .setIsPipeEnableMemoryCheck(false);
+    // Recovery is driven explicitly below; a periodic metadata push must not 
mask a broken
+    // handshake.
+    final Properties config = new Properties();
+    config.setProperty("subscription_enabled", "true");
+    ((MppBaseConfig) EnvFactory.getEnv().getConfig().getDataNodeCommonConfig())
+        .updateProperties(config);
+    config.setProperty("subscription_meta_syncer_initial_sync_delay_minutes", 
"60");
+    config.setProperty("subscription_meta_syncer_sync_interval_minutes", "60");
+    ((MppBaseConfig) 
EnvFactory.getEnv().getConfig().getConfigNodeCommonConfig())
+        .updateProperties(config);
+    EnvFactory.getEnv().initClusterEnvironment(1, 3);
+  }
+
+  @Override
+  @After
+  public void tearDown() throws Exception {
+    try {
+      EnvFactory.getEnv().cleanClusterEnvironment();
+    } finally {
+      super.tearDown();
+    }
+  }
+
+  @Test
+  public void testHandshakeRepairsMissingBrokersWithoutResubscribing() throws 
Exception {
+    testMissingBrokerRecovery(true);
+  }
+
+  @Test
+  public void 
testMetadataPushRepairsMissingBrokersWithoutRestoringCanceledTopics()
+      throws Exception {
+    testMissingBrokerRecovery(false);
+  }
+
+  private void testMissingBrokerRecovery(final boolean repairByHandshake) 
throws Exception {
+    final ConsensusSubscriptionITSupport.TestIdentifiers ids =
+        ConsensusSubscriptionITSupport.newIdentifiers(
+            repairByHandshake ? "broker_handshake" : "broker_metadata");
+    final String canceledTopic = ids.getTopic() + "_canceled";
+    final List<SubscriptionSessionWrapper> probes = new ArrayList<>();
+    SubscriptionTreePullConsumer consumer = null;
+    final IClientManager<TEndPoint, SyncDataNodeInternalServiceClient> clients 
=
+        new IClientManager.Factory<TEndPoint, 
SyncDataNodeInternalServiceClient>()
+            .createClientManager(
+                new 
ClientPoolFactory.SyncDataNodeInternalServiceClientPoolFactory());
+    try {
+      ConsensusSubscriptionITSupport.bootstrapDatabase(ids.getDatabase());
+      ConsensusSubscriptionITSupport.createConsensusTopic(
+          ids.getTopic(), ids.getDatabase() + ".**");
+      ConsensusSubscriptionITSupport.createConsensusTopic(canceledTopic, 
ids.getDatabase() + ".**");
+      consumer = createConsumer(ids, TimeUnit.HOURS.toMillis(1));
+      consumer.subscribe(ids.getTopic());
+      final Set<String> committedRows =
+          ConsensusSubscriptionITSupport.insertRows(ids.getDatabase(), "d0", 
100L, 8, true);
+      assertRowsConsumed(consumer, committedRows);
+      // Creating a new subscription runs ConfigNode's commit-progress 
synchronization without
+      // background metadata pushes, then this topic is left unsubscribed.
+      consumer.subscribe(canceledTopic);
+      consumer.unsubscribe(canceledTopic);
+      awaitConfigNodeCheckpoint(ids, 
consumer.committedPositions(ids.getTopic()));
+      final ConsumerGroupMeta authoritativeMeta = 
getConsumerGroupMeta(ids.getConsumerGroupId());
+      Assert.assertEquals(
+          Collections.singleton(ids.getTopic()), 
authoritativeMeta.getSubscribedTopicNames());
+      final ConsumerConfig consumerConfig = 
createConsumerConfig(authoritativeMeta, consumer);
+
+      for (final DataNodeWrapper node : 
EnvFactory.getEnv().getDataNodeWrapperList()) {
+        final SubscriptionSessionWrapper probe = getProviderSession(consumer, 
node);
+        probes.add(probe);
+        // A replacement-generation push can remove the old broker and then 
fail while resolving
+        // topic metadata. Exercise that real RPC failure without changing 
ConfigNode's metadata.
+        final ConsumerGroupMeta replacement =
+            new ConsumerGroupMeta(
+                ids.getConsumerGroupId(),
+                authoritativeMeta.getCreationTime() + 1,
+                authoritativeMeta.getConsumerMeta(ids.getConsumerId()));
+        replacement.addSubscription(
+            ids.getConsumerId(), Collections.singleton(ids.getTopic() + 
"_missing"));
+        final TPushConsumerGroupMetaResp failure = pushMeta(clients, node, 
replacement);
+        Assert.assertEquals(
+            failure.toString(),
+            TSStatusCode.CONSUMER_PUSH_META_ERROR.getStatusCode(),
+            failure.getStatus().getCode());
+        Assert.assertTrue(failure.isSetExceptionMessages());
+        Assert.assertFalse(failure.getExceptionMessages().isEmpty());
+        final TPipeSubscribeResp missingBroker =
+            probe
+                .getSessionConnection()
+                .pipeSubscribe(
+                    PipeSubscribePollReq.toTPipeSubscribeReq(
+                        new SubscriptionPollRequest(
+                            SubscriptionPollRequestType.POLL.getType(),
+                            new 
PollPayload(Collections.singleton(ids.getTopic())),
+                            1_000L,
+                            1_048_576L)));
+        Assert.assertEquals(
+            missingBroker.getStatus().toString(),
+            TSStatusCode.SUBSCRIPTION_POLL_ERROR.getStatusCode(),
+            missingBroker.getStatus().getCode());
+      }
+      Assert.assertEquals(authoritativeMeta, 
getConsumerGroupMeta(ids.getConsumerGroupId()));
+      final Set<String> pendingRows =
+          ConsensusSubscriptionITSupport.insertRows(ids.getDatabase(), "d0", 
200L, 8, true);
+
+      for (int index = 0; index < probes.size(); index++) {
+        final SubscriptionSessionWrapper probe = probes.get(index);
+        if (repairByHandshake) {
+          assertSuccess(
+              probe
+                  .getSessionConnection()
+                  
.pipeSubscribe(PipeSubscribeHandshakeReq.toTPipeSubscribeReq(consumerConfig)));
+        } else {
+          final TPushConsumerGroupMetaResp repaired =
+              pushMeta(clients, EnvFactory.getEnv().getDataNodeWrapper(index), 
authoritativeMeta);
+          Assert.assertEquals(
+              repaired.toString(),
+              TSStatusCode.SUCCESS_STATUS.getStatusCode(),
+              repaired.getStatus().getCode());
+        }
+        final TPipeSubscribeResp heartbeat =
+            
probe.getSessionConnection().pipeSubscribe(SubscriptionHeartbeatReq.toThriftReq());
+        assertSuccess(heartbeat);
+        Assert.assertEquals(
+            Collections.singleton(ids.getTopic()),
+            
PipeSubscribeHeartbeatResp.fromTPipeSubscribeResp(heartbeat).getTopics().keySet());
+      }
+      Assert.assertEquals(authoritativeMeta, 
getConsumerGroupMeta(ids.getConsumerGroupId()));
+      assertRowsConsumed(consumer, pendingRows);
+      assertRowsConsumed(
+          consumer,
+          ConsensusSubscriptionITSupport.insertRows(ids.getDatabase(), "d0", 
300L, 8, true));
+    } finally {
+      try {
+        clients.close();
+      } finally {
+        ConsensusSubscriptionITSupport.cleanup(consumer, ids.getTopic(), 
ids.getDatabase());
+        try (final SubscriptionTreeSession session =
+            new SubscriptionTreeSession(
+                EnvFactory.getEnv().getIP(), 
Integer.parseInt(EnvFactory.getEnv().getPort()))) {
+          session.open();
+          session.dropTopicIfExists(canceledTopic);
+        }
+      }
+    }
+  }
+
+  @Test
+  public void testProviderReplacementPreservesUncommittedBatch() throws 
Exception {
+    final ConsensusSubscriptionITSupport.TestIdentifiers ids =
+        
ConsensusSubscriptionITSupport.newIdentifiers("broker_provider_replacement");
+    SubscriptionTreePullConsumer consumer = null;
+    try {
+      ConsensusSubscriptionITSupport.bootstrapDatabase(ids.getDatabase());
+      ConsensusSubscriptionITSupport.createConsensusTopic(
+          ids.getTopic(), ids.getDatabase() + ".**");
+      consumer = createConsumer(ids, 1_000L);
+      consumer.subscribe(ids.getTopic());
+      final Set<String> rows =
+          ConsensusSubscriptionITSupport.insertRows(ids.getDatabase(), "d0", 
100L, 8, true);
+      final ConsensusSubscriptionITSupport.PolledMessageBatch batch =
+          ConsensusSubscriptionITSupport.pollFirstNonEmptyBatchWithoutCommit(
+              consumer, 60, Duration.ofSeconds(1));
+      Assert.assertFalse(batch.getMessages().isEmpty());
+      final TopicProgress progressBeforeReplacement = 
consumer.committedPositions(ids.getTopic());
+      final ConsumerGroupMeta metaBeforeReplacement =
+          getConsumerGroupMeta(ids.getConsumerGroupId());
+      final Map<?, ?> providers = getProviders(consumer);
+      final Map<?, ?> oldProviders = new HashMap<>(providers);
+      Assert.assertEquals(3, oldProviders.size());
+      for (final Object provider : oldProviders.values()) {
+        ((SubscriptionSessionWrapper) getField(provider, "session"))
+            .getSessionConnection()
+            .forceClose();
+      }
+      Awaitility.await()
+          .pollInSameThread()
+          .atMost(Duration.ofSeconds(30))
+          .untilAsserted(
+              () -> {
+                Assert.assertEquals(oldProviders.keySet(), providers.keySet());
+                for (final Object nodeId : oldProviders.keySet()) {
+                  Assert.assertNotSame(oldProviders.get(nodeId), 
providers.get(nodeId));
+                }
+              });
+      Assert.assertEquals(metaBeforeReplacement, 
getConsumerGroupMeta(ids.getConsumerGroupId()));
+      Assert.assertEquals(progressBeforeReplacement, 
consumer.committedPositions(ids.getTopic()));
+      consumer.commitSync(batch.getMessages());
+      final ConsensusSubscriptionITSupport.ConsumedRecords consumed = 
batch.getConsumedRecords();
+      consumed.merge(
+          ConsensusSubscriptionITSupport.pollAndCommitUntilAtLeast(
+              consumer, rows.size() - consumed.getUniqueRowCount(), 20, 
Duration.ofSeconds(1)));
+      ConsensusSubscriptionITSupport.assertExactRowKeys(rows, consumed);
+      assertRowsConsumed(
+          consumer,
+          ConsensusSubscriptionITSupport.insertRows(ids.getDatabase(), "d0", 
200L, 8, true));
+    } finally {
+      ConsensusSubscriptionITSupport.cleanup(consumer, ids.getTopic(), 
ids.getDatabase());
+    }
+  }
+
+  private static SubscriptionTreePullConsumer createConsumer(
+      final ConsensusSubscriptionITSupport.TestIdentifiers ids, final long 
controlIntervalMs)
+      throws Exception {
+    final SubscriptionTreePullConsumer consumer =
+        new SubscriptionTreePullConsumer.Builder()
+            .host(EnvFactory.getEnv().getIP())
+            .port(Integer.parseInt(EnvFactory.getEnv().getPort()))
+            .consumerId(ids.getConsumerId())
+            .consumerGroupId(ids.getConsumerGroupId())
+            .heartbeatIntervalMs(controlIntervalMs)
+            .endpointsSyncIntervalMs(controlIntervalMs)
+            .autoCommit(false)
+            .buildPullConsumer();
+    consumer.open();
+    return consumer;
+  }
+
+  private static ConsumerConfig createConsumerConfig(
+      final ConsumerGroupMeta meta, final SubscriptionTreePullConsumer 
consumer) throws Exception {
+    final Map<String, String> attributes =
+        new 
HashMap<>(meta.getConsumerMeta(consumer.getConsumerId()).getConfig().getAttribute());
+    attributes.put(
+        ConsumerConstant.CONSUMER_INSTANCE_ID_KEY,
+        (String) getField(consumer, "consumerInstanceId"));
+    return new ConsumerConfig(attributes);
+  }
+
+  private static SubscriptionSessionWrapper getProviderSession(
+      final SubscriptionTreePullConsumer consumer, final DataNodeWrapper node) 
throws Exception {
+    final TEndPoint endpoint = new TEndPoint(node.getIp(), node.getPort());
+    for (final Object provider : getProviders(consumer).values()) {
+      if (endpoint.equals(getField(provider, "endPoint"))) {
+        return (SubscriptionSessionWrapper) getField(provider, "session");
+      }
+    }
+    throw new AssertionError("Missing subscription provider for " + endpoint);
+  }
+
+  private static ConsumerGroupMeta getConsumerGroupMeta(final String 
consumerGroupId)
+      throws Exception {
+    try (final SyncConfigNodeIServiceClient client =
+        (SyncConfigNodeIServiceClient) 
EnvFactory.getEnv().getLeaderConfigNodeConnection()) {
+      final TGetAllSubscriptionInfoResp resp = client.getAllSubscriptionInfo();
+      Assert.assertEquals(TSStatusCode.SUCCESS_STATUS.getStatusCode(), 
resp.getStatus().getCode());
+      for (final ByteBuffer buffer : resp.getAllSubscriptionInfo()) {
+        final ConsumerGroupMeta meta = ConsumerGroupMeta.deserialize(buffer);
+        if (consumerGroupId.equals(meta.getConsumerGroupId())) {
+          return meta;
+        }
+      }
+      throw new AssertionError("Missing consumer group " + consumerGroupId);
+    }
+  }
+
+  private static TPushConsumerGroupMetaResp pushMeta(
+      final IClientManager<TEndPoint, SyncDataNodeInternalServiceClient> 
clients,
+      final DataNodeWrapper node,
+      final ConsumerGroupMeta meta)
+      throws Exception {
+    try (final SyncDataNodeInternalServiceClient client =
+        clients.borrowClient(new TEndPoint(node.getInternalAddress(), 
node.getInternalPort()))) {
+      return client.pushSingleConsumerGroupMeta(
+          new 
TPushSingleConsumerGroupMetaReq().setConsumerGroupMeta(meta.serialize()));
+    }
+  }
+
+  private static void awaitConfigNodeCheckpoint(
+      final ConsensusSubscriptionITSupport.TestIdentifiers ids, final 
TopicProgress checkpoint) {
+    Assert.assertNotNull(checkpoint);
+    Assert.assertFalse(checkpoint.getRegionProgress().isEmpty());
+    Awaitility.await()
+        .pollInSameThread()
+        .atMost(Duration.ofSeconds(30))
+        .untilAsserted(
+            () -> {
+              try (final SyncConfigNodeIServiceClient client =
+                  (SyncConfigNodeIServiceClient)
+                      EnvFactory.getEnv().getLeaderConfigNodeConnection()) {
+                for (final Map.Entry<String, RegionProgress> entry :
+                    checkpoint.getRegionProgress().entrySet()) {
+                  final TGetCommitProgressResp resp =
+                      client.getCommitProgress(
+                          new TGetCommitProgressReq(
+                              ids.getConsumerGroupId(),
+                              ids.getTopic(),
+                              
ConsensusGroupId.Factory.createFromString(entry.getKey()).getId(),
+                              0));
+                  Assert.assertEquals(
+                      TSStatusCode.SUCCESS_STATUS.getStatusCode(), 
resp.getStatus().getCode());
+                  Assert.assertTrue(resp.isSetCommittedRegionProgress());
+                  Assert.assertEquals(
+                      entry.getValue(),
+                      RegionProgress.deserialize(
+                          ByteBuffer.wrap(resp.getCommittedRegionProgress())));
+                }
+              }
+            });
+  }
+
+  private static void assertRowsConsumed(
+      final SubscriptionTreePullConsumer consumer, final Set<String> 
expectedRows)
+      throws Exception {
+    ConsensusSubscriptionITSupport.assertExactRowKeys(
+        expectedRows,
+        ConsensusSubscriptionITSupport.pollAndCommitUntilAtLeast(
+            consumer, expectedRows.size(), 60, Duration.ofSeconds(1)));
+  }
+
+  private static void assertSuccess(final TPipeSubscribeResp resp) {
+    Assert.assertEquals(
+        resp.getStatus().toString(),
+        TSStatusCode.SUCCESS_STATUS.getStatusCode(),
+        resp.getStatus().getCode());
+  }
+
+  private static Map<?, ?> getProviders(final SubscriptionTreePullConsumer 
consumer)
+      throws Exception {
+    return (Map<?, ?>) getField(getField(consumer, "providers"), 
"subscriptionProviders");
+  }
+
+  private static Object getField(final Object target, final String name) 
throws Exception {
+    for (Class<?> type = target.getClass(); type != null; type = 
type.getSuperclass()) {
+      try {
+        final Field field = type.getDeclaredField(name);
+        field.setAccessible(true);
+        return field.get(target);
+      } catch (final NoSuchFieldException ignored) {
+        // Client state is declared in the consumer/provider base classes.
+      }
+    }
+    throw new NoSuchFieldException(name);
+  }
+}
diff --git 
a/iotdb-core/consensus/src/main/java/org/apache/iotdb/consensus/iot/IoTConsensus.java
 
b/iotdb-core/consensus/src/main/java/org/apache/iotdb/consensus/iot/IoTConsensus.java
index 63ddcc6eba8..28b0458c8b6 100644
--- 
a/iotdb-core/consensus/src/main/java/org/apache/iotdb/consensus/iot/IoTConsensus.java
+++ 
b/iotdb-core/consensus/src/main/java/org/apache/iotdb/consensus/iot/IoTConsensus.java
@@ -82,6 +82,7 @@ import java.util.Collections;
 import java.util.List;
 import java.util.Map;
 import java.util.Optional;
+import java.util.Set;
 import java.util.concurrent.ConcurrentHashMap;
 import java.util.concurrent.Future;
 import java.util.concurrent.ScheduledExecutorService;
@@ -104,6 +105,7 @@ public class IoTConsensus implements IConsensus {
   private final IStateMachine.Registry registry;
   private final Map<ConsensusGroupId, IoTConsensusServerImpl> stateMachineMap =
       new ConcurrentHashMap<>();
+  private final Set<ConsensusGroupId> pendingPeerCreatedCallbacks = 
ConcurrentHashMap.newKeySet();
   private final IoTConsensusRPCService service;
   private final RegisterManager registerManager = new RegisterManager();
   private final UserDataTransferAuditHandler userDataTransferAuditHandler;
@@ -223,6 +225,7 @@ public class IoTConsensus implements IConsensus {
                   config,
                   userDataTransferAuditHandler,
                   userDataTransferAuditClassifier);
+          pendingPeerCreatedCallbacks.add(consensusGroupId);
           stateMachineMap.put(consensusGroupId, consensus);
         }
       } catch (DiskSpaceInsufficientException e) {
@@ -253,15 +256,19 @@ public class IoTConsensus implements IConsensus {
     }
     stateMachineMap.forEach(
         (consensusGroupId, consensus) -> {
-          final BiConsumer<ConsensusGroupId, IoTConsensusServerImpl> callback 
= onPeerRecovering;
-          if (callback != null) {
-            callback.accept(consensusGroupId, consensus);
+          try {
+            final BiConsumer<ConsensusGroupId, IoTConsensusServerImpl> 
callback = onPeerRecovering;
+            if (callback != null) {
+              callback.accept(consensusGroupId, consensus);
+            }
+            consensus.start();
+            // Subscription metadata may have been loaded before any local 
consensus peers existed.
+            // Recovered peers must trigger the same binding as newly created 
peers, after startup
+            // has restored their indices and installed the pre-start 
WAL-retention guards.
+            notifyPeerCreated(consensusGroupId, consensus);
+          } finally {
+            pendingPeerCreatedCallbacks.remove(consensusGroupId);
           }
-          consensus.start();
-          // Subscription metadata may have been loaded before any local 
consensus peers existed.
-          // Recovered peers must trigger the same binding as newly created 
peers, after startup
-          // has restored their indices and installed the pre-start 
WAL-retention guards.
-          notifyPeerCreated(consensusGroupId, consensus);
         });
   }
 
@@ -355,6 +362,7 @@ public class IoTConsensus implements IConsensus {
                     throw new RuntimeException(e);
                   }
                   impl.start();
+                  pendingPeerCreatedCallbacks.add(groupId);
                   return impl;
                 }))
         .orElseThrow(
@@ -367,7 +375,16 @@ public class IoTConsensus implements IConsensus {
       throw new ConsensusGroupAlreadyExistException(groupId);
     }
 
-    notifyPeerCreated(groupId, stateMachineMap.get(groupId));
+    try {
+      notifyPeerCreated(groupId, stateMachineMap.get(groupId));
+    } finally {
+      pendingPeerCreatedCallbacks.remove(groupId);
+    }
+  }
+
+  /** Returns whether a new or recovered local peer is still waiting for its 
creation callback. */
+  public boolean isPeerCreationCallbackPending(final ConsensusGroupId groupId) 
{
+    return pendingPeerCreatedCallbacks.contains(groupId);
   }
 
   private void notifyPeerCreated(
@@ -412,6 +429,7 @@ public class IoTConsensus implements IConsensus {
     if (!exist.get()) {
       throw new ConsensusGroupNotExistException(groupId);
     }
+    pendingPeerCreatedCallbacks.remove(groupId);
     FileUtils.deleteFileOrDirectoryWithRateLimiter(
         new File(buildPeerDir(storageDir, groupId)),
         RegionMigrationFileRemoveRateLimiter.getInstance()::acquire);
diff --git 
a/iotdb-core/consensus/src/test/java/org/apache/iotdb/consensus/iot/IoTConsensusRecoveryTest.java
 
b/iotdb-core/consensus/src/test/java/org/apache/iotdb/consensus/iot/IoTConsensusRecoveryTest.java
index fcbb2772a62..4e7131f1a5c 100644
--- 
a/iotdb-core/consensus/src/test/java/org/apache/iotdb/consensus/iot/IoTConsensusRecoveryTest.java
+++ 
b/iotdb-core/consensus/src/test/java/org/apache/iotdb/consensus/iot/IoTConsensusRecoveryTest.java
@@ -101,12 +101,14 @@ public class IoTConsensusRecoveryTest {
     IoTConsensus.onPeerRecovering =
         (groupId, server) -> {
           assertSame(server, consensus.getImpl(groupId));
+          assertTrue(consensus.isPeerCreationCallbackPending(groupId));
           assertFalse(stateMachines.get(groupId).started);
           notifications.computeIfAbsent(groupId, ignored -> new 
ArrayList<>()).add("recovering");
         };
     IoTConsensus.onNewPeerCreated =
         (groupId, server) -> {
           assertSame(server, consensus.getImpl(groupId));
+          assertTrue(consensus.isPeerCreationCallbackPending(groupId));
           assertTrue(stateMachines.get(groupId).started);
           notifications.computeIfAbsent(groupId, ignored -> new 
ArrayList<>()).add("available");
         };
@@ -116,6 +118,8 @@ public class IoTConsensusRecoveryTest {
     assertEquals(2, notifications.size());
     assertEquals(Arrays.asList("recovering", "available"), 
notifications.get(firstRegion));
     assertEquals(Arrays.asList("recovering", "available"), 
notifications.get(secondRegion));
+    assertFalse(consensus.isPeerCreationCallbackPending(firstRegion));
+    assertFalse(consensus.isPeerCreationCallbackPending(secondRegion));
   }
 
   @Test
@@ -141,6 +145,24 @@ public class IoTConsensusRecoveryTest {
     assertTrue(notifiedRegions.containsAll(Arrays.asList(firstRegion, 
secondRegion)));
     assertTrue(stateMachines.get(firstRegion).started);
     assertTrue(stateMachines.get(secondRegion).started);
+    assertFalse(consensus.isPeerCreationCallbackPending(firstRegion));
+    assertFalse(consensus.isPeerCreationCallbackPending(secondRegion));
+  }
+
+  @Test
+  public void testNewPeerRemainsPendingUntilCallbackReturns() throws Exception 
{
+    final DataRegionId regionId = new DataRegionId(3);
+    IoTConsensus.onNewPeerCreated =
+        (groupId, server) -> {
+          assertSame(server, consensus.getImpl(groupId));
+          assertTrue(consensus.getAllConsensusGroupIds().contains(groupId));
+          assertTrue(consensus.isPeerCreationCallbackPending(groupId));
+          throw new IllegalStateException("subscription binding failed");
+        };
+
+    createPeer(regionId);
+
+    assertFalse(consensus.isPeerCreationCallbackPending(regionId));
   }
 
   private IoTConsensus newConsensus() {
diff --git 
a/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/subscription/agent/SubscriptionBrokerAgent.java
 
b/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/subscription/agent/SubscriptionBrokerAgent.java
index 89d877665eb..9c448e743c1 100644
--- 
a/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/subscription/agent/SubscriptionBrokerAgent.java
+++ 
b/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/subscription/agent/SubscriptionBrokerAgent.java
@@ -962,6 +962,33 @@ public class SubscriptionBrokerAgent {
     prefetchingQueueCount.invalidate();
   }
 
+  public Map<String, Set<String>> getConsensusQueueRegionIds(
+      final String consumerGroupId, final Set<String> topicNames) {
+    final Map<String, Set<String>> regionIdsByTopic = new LinkedHashMap<>();
+    final ConsensusSubscriptionBroker broker = 
getConsensusBroker(consumerGroupId);
+    if (Objects.nonNull(broker)) {
+      for (final String topicName : topicNames) {
+        regionIdsByTopic.put(topicName, broker.getQueueRegionIds(topicName));
+      }
+    }
+    return regionIdsByTopic;
+  }
+
+  public boolean hasConsensusQueueForRegion(
+      final String consumerGroupId, final String topicName, final String 
regionId) {
+    final ConsensusSubscriptionBroker broker = 
getConsensusBroker(consumerGroupId);
+    return Objects.nonNull(broker) && broker.hasQueueForRegion(topicName, 
regionId);
+  }
+
+  public void rollbackConsensusPrefetchingQueues(
+      final String consumerGroupId, final String topicName, final Set<String> 
existingRegionIds) {
+    final ConsensusSubscriptionBroker broker = 
getConsensusBroker(consumerGroupId);
+    if (Objects.nonNull(broker)) {
+      broker.rollbackConsensusPrefetchingQueues(topicName, existingRegionIds);
+      prefetchingQueueCount.invalidate();
+    }
+  }
+
   public void unbindByRegion(final ConsensusGroupId regionId) {
     int totalClosed = 0;
     for (final ConsensusSubscriptionBroker broker : 
getBrokers(ConsensusSubscriptionBroker.class)) {
diff --git 
a/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/subscription/agent/SubscriptionConsumerAgent.java
 
b/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/subscription/agent/SubscriptionConsumerAgent.java
index 8af488c5923..129330e88ce 100644
--- 
a/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/subscription/agent/SubscriptionConsumerAgent.java
+++ 
b/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/subscription/agent/SubscriptionConsumerAgent.java
@@ -30,6 +30,7 @@ import 
org.apache.iotdb.rpc.subscription.exception.SubscriptionException;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
+import java.util.Collections;
 import java.util.LinkedHashSet;
 import java.util.List;
 import java.util.Objects;
@@ -116,11 +117,7 @@ public class SubscriptionConsumerAgent {
 
     // if consumer group meta does not exist on local agent
     if (Objects.isNull(metaInAgent)) {
-      SubscriptionAgent.broker().createPipeBrokerIfNotExist(consumerGroupId);
-      ConsensusSubscriptionSetupHandler.setupConsensusSubscriptions(
-          consumerGroupId,
-          metaFromCoordinator.getSubscribedTopicNames(),
-          metaFromCoordinator.visibleUnder(true));
+      ensureConsumerGroupBrokerInternal(metaFromCoordinator);
       replaceConsumerGroupMeta(consumerGroupId, metaFromCoordinator);
       return;
     }
@@ -145,12 +142,8 @@ public class SubscriptionConsumerAgent {
         }
       }
 
-      ConsensusSubscriptionSetupHandler.setupConsensusSubscriptions(
-          consumerGroupId,
-          metaFromCoordinator.getSubscribedTopicNames(),
-          metaFromCoordinator.visibleUnder(true));
+      ensureConsumerGroupBrokerInternal(metaFromCoordinator);
       replaceConsumerGroupMeta(consumerGroupId, metaFromCoordinator);
-      // no need to create broker manually
       return;
     }
 
@@ -179,12 +172,7 @@ public class SubscriptionConsumerAgent {
         newlySubscribedTopics);
 
     applyTopicDiff(
-        () -> {
-          if (!newlySubscribedTopics.isEmpty()) {
-            ConsensusSubscriptionSetupHandler.handleNewSubscriptions(
-                consumerGroupId, newlySubscribedTopics);
-          }
-        },
+        () -> ensureConsumerGroupBrokerInternal(metaFromCoordinator),
         () -> {
           for (final String topicName : pipeTopicsUnsubByGroup) {
             SubscriptionAgent.broker().removePrefetchingQueue(consumerGroupId, 
topicName);
@@ -279,6 +267,48 @@ public class SubscriptionConsumerAgent {
     }
   }
 
+  /**
+   * Ensures that a non-empty consumer group has the local broker and its 
consensus subscriptions.
+   *
+   * <p>The coordinator metadata is authoritative here. In particular, an 
empty metadata must not
+   * recreate a broker or restore topics that were explicitly removed from the 
group.
+   */
+  public void ensureConsumerGroupBroker(final String consumerGroupId) {
+    executeWithConsumerGroupLock(
+        consumerGroupId,
+        () -> {
+          final ConsumerGroupMeta consumerGroupMeta = 
getConsumerGroupMeta(consumerGroupId);
+          if (Objects.nonNull(consumerGroupMeta)) {
+            ensureConsumerGroupBrokerInternal(consumerGroupMeta);
+          }
+        });
+  }
+
+  private void ensureConsumerGroupBrokerInternal(final ConsumerGroupMeta 
consumerGroupMeta) {
+    if (consumerGroupMeta.isEmpty()) {
+      return;
+    }
+
+    final String consumerGroupId = consumerGroupMeta.getConsumerGroupId();
+    final boolean brokerExisted = 
SubscriptionAgent.broker().isBrokerExist(consumerGroupId);
+    SubscriptionAgent.broker().createPipeBrokerIfNotExist(consumerGroupId);
+    try {
+      // setupConsensusSubscriptions is idempotent and also repairs a missing 
consensus broker or
+      // queue when a pipe broker still exists.
+      ConsensusSubscriptionSetupHandler.setupConsensusSubscriptions(
+          consumerGroupId,
+          consumerGroupMeta.getSubscribedTopicNames(),
+          consumerGroupMeta.visibleUnder(true));
+    } catch (final RuntimeException e) {
+      // Allow a later metadata push or handshake to retry after a partial 
setup failure. Preserve
+      // an existing broker because it may still contain usable queues for the 
current metadata.
+      if (!brokerExisted) {
+        SubscriptionAgent.broker().dropBroker(consumerGroupId);
+      }
+      throw e;
+    }
+  }
+
   private ConsumerGroupMeta getConsumerGroupMeta(final String consumerGroupId) 
{
     acquireReadLock();
     try {
@@ -307,7 +337,7 @@ public class SubscriptionConsumerAgent {
     }
   }
 
-  void executeWithConsumerGroupLock(final String consumerGroupId, final 
Runnable operation) {
+  public void executeWithConsumerGroupLock(final String consumerGroupId, final 
Runnable operation) {
     final ConsumerGroupOperationLock operationLock =
         consumerGroupOperationLocks.compute(
             consumerGroupId,
@@ -416,6 +446,19 @@ public class SubscriptionConsumerAgent {
     }
   }
 
+  public Set<String> getTopicNamesSubscribedByConsumerGroup(final String 
consumerGroupId) {
+    acquireReadLock();
+    try {
+      final ConsumerGroupMeta consumerGroupMeta =
+          consumerGroupMetaKeeper.getConsumerGroupMeta(consumerGroupId);
+      return Objects.isNull(consumerGroupMeta)
+          ? Collections.emptySet()
+          : new LinkedHashSet<>(consumerGroupMeta.getSubscribedTopicNames());
+    } finally {
+      releaseReadLock();
+    }
+  }
+
   /**
    * Get all active subscriptions: consumerGroupId → set of subscribed topic 
names. Used by
    * consensus subscription auto-binding when a new DataRegion is created.
diff --git 
a/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/subscription/broker/ConsensusSubscriptionBroker.java
 
b/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/subscription/broker/ConsensusSubscriptionBroker.java
index 60b268504a2..22c79ac7303 100644
--- 
a/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/subscription/broker/ConsensusSubscriptionBroker.java
+++ 
b/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/subscription/broker/ConsensusSubscriptionBroker.java
@@ -751,6 +751,52 @@ public class ConsensusSubscriptionBroker implements 
ISubscriptionBroker {
     closeAndRemoveConsensusPrefetchingQueues(topicName, true, 
retainProgressAfterUnsubscribe);
   }
 
+  public Set<String> getQueueRegionIds(final String topicName) {
+    synchronized (queueLifecycleLock) {
+      return topicNameToConsensusPrefetchingQueues
+          .getOrDefault(topicName, Collections.emptyList())
+          .stream()
+          .filter(queue -> !queue.isClosed())
+          .map(queue -> queue.getConsensusGroupId().toString())
+          .collect(Collectors.toSet());
+    }
+  }
+
+  public boolean hasQueueForRegion(final String topicName, final String 
regionId) {
+    synchronized (queueLifecycleLock) {
+      return topicNameToConsensusPrefetchingQueues
+          .getOrDefault(topicName, Collections.emptyList())
+          .stream()
+          .anyMatch(
+              queue ->
+                  !queue.isClosed() && 
regionId.equals(queue.getConsensusGroupId().toString()));
+    }
+  }
+
+  public void rollbackConsensusPrefetchingQueues(
+      final String topicName, final Set<String> existingRegionIds) {
+    final List<ConsensusPrefetchingQueue> queuesToClose = new ArrayList<>();
+    synchronized (queueLifecycleLock) {
+      final List<ConsensusPrefetchingQueue> queues =
+          topicNameToConsensusPrefetchingQueues.get(topicName);
+      if (Objects.isNull(queues)) {
+        return;
+      }
+      for (final ConsensusPrefetchingQueue queue : queues) {
+        if 
(!existingRegionIds.contains(queue.getConsensusGroupId().toString())) {
+          queuesToClose.add(queue);
+        }
+      }
+      queues.removeAll(queuesToClose);
+      if (queues.isEmpty()) {
+        topicNameToConsensusPrefetchingQueues.remove(topicName, queues);
+        topicConsumerLastPollMs.remove(topicName);
+        topicOwnershipSnapshots.remove(topicName);
+      }
+    }
+    queuesToClose.forEach(queue -> queue.closeAsync(false, false));
+  }
+
   public void wakeUpPrefetchingQueues(final String topicName, final boolean 
isTableModel) {
     final List<ConsensusPrefetchingQueue> queues =
         topicNameToConsensusPrefetchingQueues.get(topicName);
diff --git 
a/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/subscription/broker/consensus/ConsensusSubscriptionCommitManager.java
 
b/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/subscription/broker/consensus/ConsensusSubscriptionCommitManager.java
index ed49dda3f46..f9a27372d5e 100644
--- 
a/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/subscription/broker/consensus/ConsensusSubscriptionCommitManager.java
+++ 
b/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/subscription/broker/consensus/ConsensusSubscriptionCommitManager.java
@@ -274,17 +274,36 @@ public class ConsensusSubscriptionCommitManager {
   /** Restores only attempted topics to their exact state before setup began. 
*/
   void restoreSetupSnapshot(
       final SetupSnapshot setupSnapshot, final Set<String> 
attemptedTopicNames) {
+    restoreSetupSnapshot(setupSnapshot, attemptedTopicNames, 
Collections.emptyMap());
+  }
+
+  void restoreSetupSnapshot(
+      final SetupSnapshot setupSnapshot,
+      final Set<String> attemptedTopicNames,
+      final Map<String, Set<String>> existingQueueRegionIds) {
     for (final String topicName : attemptedTopicNames) {
       final TopicSetupSnapshot topicSnapshot = 
setupSnapshot.topicSnapshots.get(topicName);
       if (Objects.isNull(topicSnapshot)) {
         continue;
       }
       synchronized (getTopicPersistLock(topicSnapshot.topicFileKey)) {
-        removeTopicStateFromMemory(setupSnapshot.consumerGroupId, topicName);
+        final Set<String> preservedRegionIds =
+            existingQueueRegionIds.getOrDefault(topicName, 
Collections.emptySet());
+        // Existing queues can commit while setup is running. Keep their state 
objects, progress,
+        // and initialization markers instead of rewinding them to the setup 
snapshot.
+        removeTopicStateFromMemory(setupSnapshot.consumerGroupId, topicName, 
preservedRegionIds);
 
         CommitStateKey stateKeyForRewrite = null;
+        if (!preservedRegionIds.isEmpty()) {
+          stateKeyForRewrite =
+              getCommitStateKey(
+                  setupSnapshot.consumerGroupId, topicName, 
preservedRegionIds.iterator().next());
+        }
         for (final Map.Entry<String, StateSetupSnapshot> entry :
             topicSnapshot.stateSnapshots.entrySet()) {
+          if (preservedRegionIds.contains(entry.getKey())) {
+            continue;
+          }
           final CommitStateKey stateKey =
               getCommitStateKey(setupSnapshot.consumerGroupId, topicName, 
entry.getKey());
           final StateSetupSnapshot stateSnapshot = entry.getValue();
@@ -334,12 +353,14 @@ public class ConsensusSubscriptionCommitManager {
     }
   }
 
-  private void removeTopicStateFromMemory(final String consumerGroupId, final 
String topicName) {
+  private void removeTopicStateFromMemory(
+      final String consumerGroupId, final String topicName, final Set<String> 
preservedRegionIds) {
     final Iterator<Map.Entry<String, CommitStateKey>> keyIterator =
         commitStateKeys.entrySet().iterator();
     while (keyIterator.hasNext()) {
       final Map.Entry<String, CommitStateKey> entry = keyIterator.next();
-      if (!entry.getValue().sameTopic(consumerGroupId, topicName)) {
+      if (!entry.getValue().sameTopic(consumerGroupId, topicName)
+          || preservedRegionIds.contains(entry.getValue().regionIdStr)) {
         continue;
       }
       commitStates.remove(entry.getKey());
diff --git 
a/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/subscription/broker/consensus/ConsensusSubscriptionSetupHandler.java
 
b/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/subscription/broker/consensus/ConsensusSubscriptionSetupHandler.java
index 474deb3c0cf..1ae30b123e1 100644
--- 
a/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/subscription/broker/consensus/ConsensusSubscriptionSetupHandler.java
+++ 
b/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/subscription/broker/consensus/ConsensusSubscriptionSetupHandler.java
@@ -172,7 +172,7 @@ public class ConsensusSubscriptionSetupHandler {
    * existing subscription metadata to find all active consensus subscriptions 
and binds prefetching
    * queues to the new region.
    */
-  private static void onNewRegionCreated(
+  static void onNewRegionCreated(
       final ConsensusGroupId groupId, final IoTConsensusServerImpl serverImpl) 
{
     if (!(groupId instanceof DataRegionId)) {
       return;
@@ -195,111 +195,129 @@ public class ConsensusSubscriptionSetupHandler {
         allSubscriptions.size(),
         serverImpl.getSearchIndex());
 
-    for (final Map.Entry<String, java.util.Set<String>> groupEntry : 
allSubscriptions.entrySet()) {
-      final String consumerGroupId = groupEntry.getKey();
-      final boolean isTableModel = 
SubscriptionAgent.consumer().isTableModel(consumerGroupId);
-      for (final String topicName : groupEntry.getValue()) {
-        if (!isConsensusBasedTopic(topicName, isTableModel)) {
+    for (final String consumerGroupId : allSubscriptions.keySet()) {
+      // Serialize binding with metadata repair and its rollback. Re-read the 
group's topics after
+      // acquiring the lock so an unsubscribe while this callback was waiting 
cannot be undone.
+      SubscriptionAgent.consumer()
+          .executeWithConsumerGroupLock(
+              consumerGroupId,
+              () ->
+                  bindNewRegionForConsumerGroup(
+                      consumerGroupId, groupId, serverImpl, commitManager));
+    }
+  }
+
+  private static void bindNewRegionForConsumerGroup(
+      final String consumerGroupId,
+      final ConsensusGroupId groupId,
+      final IoTConsensusServerImpl serverImpl,
+      final ConsensusSubscriptionCommitManager commitManager) {
+    final boolean isTableModel = 
SubscriptionAgent.consumer().isTableModel(consumerGroupId);
+    for (final String topicName :
+        
SubscriptionAgent.consumer().getTopicNamesSubscribedByConsumerGroup(consumerGroupId))
 {
+      if (!isConsensusBasedTopic(topicName, isTableModel)) {
+        continue;
+      }
+      if (SubscriptionAgent.broker()
+          .hasConsensusQueueForRegion(consumerGroupId, topicName, 
groupId.toString())) {
+        continue;
+      }
+      try {
+        final Map<String, TopicConfig> topicConfigs =
+            SubscriptionAgent.topic()
+                .getTopicConfigs(java.util.Collections.singleton(topicName), 
isTableModel);
+        final TopicConfig topicConfig = topicConfigs.get(topicName);
+        if (topicConfig == null) {
           continue;
         }
-        try {
-          final Map<String, TopicConfig> topicConfigs =
-              SubscriptionAgent.topic()
-                  .getTopicConfigs(java.util.Collections.singleton(topicName), 
isTableModel);
-          final TopicConfig topicConfig = topicConfigs.get(topicName);
-          if (topicConfig == null) {
-            continue;
-          }
 
-          // Resolve the new DataRegion's actual database name
-          final DataRegion dataRegion =
-              StorageEngine.getInstance().getDataRegion((DataRegionId) 
groupId);
-          if (dataRegion == null) {
-            continue;
-          }
-          final String dbRaw = dataRegion.getDatabaseName();
-          final String dbTableModel = dbRaw.startsWith("root.") ? 
dbRaw.substring(5) : dbRaw;
+        // Resolve the new DataRegion's actual database name
+        final DataRegion dataRegion =
+            StorageEngine.getInstance().getDataRegion((DataRegionId) groupId);
+        if (dataRegion == null) {
+          continue;
+        }
+        final String dbRaw = dataRegion.getDatabaseName();
+        final String dbTableModel = dbRaw.startsWith("root.") ? 
dbRaw.substring(5) : dbRaw;
 
-          if (!matchesTopicDataRegion(dbRaw, topicConfig, isTableModel)) {
-            continue;
-          }
+        if (!matchesTopicDataRegion(dbRaw, topicConfig, isTableModel)) {
+          continue;
+        }
 
-          final String actualDbName = topicConfig.isTableTopic() ? 
dbTableModel : null;
-          final ConsensusLogToTabletConverter converter =
-              buildConverter(topicName, topicConfig, actualDbName);
-          final SubscriptionWalRetentionPolicy retentionPolicy =
-              buildSubscriptionWalRetentionPolicy(topicName, topicConfig, 
serverImpl);
-
-          // Recover from persisted per-writer region progress when available. 
The queue will
-          // resolve a replay start from that progress on first poll via the 
region-level locator.
-          // A newly-created region starts from its first consensus index and 
never proposes its
-          // current local tail as an initial committed frontier.
-          commitManager.initializeStateWithoutTailProposal(consumerGroupId, 
topicName, groupId);
-          final RegionProgress committedRegionProgress =
-              resolveFallbackCommittedRegionProgress(
-                  commitManager, consumerGroupId, topicName, groupId);
-          final boolean hasLocalPersistedState =
-              commitManager.hasPersistedState(consumerGroupId, topicName, 
groupId);
-          // This region is created after the subscription already exists. Its 
first WAL entry may
-          // already be visible by the time this callback runs, so start from 
the beginning of this
-          // new region instead of tail+1 to avoid skipping the first write.
-          final long tailStartSearchIndex = FIRST_CONSENSUS_SEARCH_INDEX;
-          final long initialRuntimeVersion =
-              
regionRuntimeVersion.getOrDefault(groupId.convertToTConsensusGroupId(), 0L);
-          final boolean initialActive =
-              
lastKnownPreferredWriter.getOrDefault(groupId.convertToTConsensusGroupId(), -1)
-                  == IOTDB_CONFIG.getDataNodeId();
-          final Set<Integer> initialActiveWriterNodeIds =
-              regionActiveWriterNodeIds.getOrDefault(
-                  groupId.convertToTConsensusGroupId(),
-                  initialActive
-                      ? Collections.singleton(IOTDB_CONFIG.getDataNodeId())
-                      : Collections.emptySet());
-          final ConsensusRegionRuntimeState initialRuntimeState =
-              new ConsensusRegionRuntimeState(
-                  initialRuntimeVersion,
-                  
lastKnownPreferredWriter.getOrDefault(groupId.convertToTConsensusGroupId(), -1),
-                  initialActive,
-                  initialActiveWriterNodeIds);
+        final String actualDbName = topicConfig.isTableTopic() ? dbTableModel 
: null;
+        final ConsensusLogToTabletConverter converter =
+            buildConverter(topicName, topicConfig, actualDbName);
+        final SubscriptionWalRetentionPolicy retentionPolicy =
+            buildSubscriptionWalRetentionPolicy(topicName, topicConfig, 
serverImpl);
+
+        // Recover from persisted per-writer region progress when available. 
The queue will
+        // resolve a replay start from that progress on first poll via the 
region-level locator.
+        // A newly-created region starts from its first consensus index and 
never proposes its
+        // current local tail as an initial committed frontier.
+        commitManager.initializeStateWithoutTailProposal(consumerGroupId, 
topicName, groupId);
+        final RegionProgress committedRegionProgress =
+            resolveFallbackCommittedRegionProgress(
+                commitManager, consumerGroupId, topicName, groupId);
+        final boolean hasLocalPersistedState =
+            commitManager.hasPersistedState(consumerGroupId, topicName, 
groupId);
+        // This region is created after the subscription already exists. Its 
first WAL entry may
+        // already be visible by the time this callback runs, so start from 
the beginning of this
+        // new region instead of tail+1 to avoid skipping the first write.
+        final long tailStartSearchIndex = FIRST_CONSENSUS_SEARCH_INDEX;
+        final long initialRuntimeVersion =
+            
regionRuntimeVersion.getOrDefault(groupId.convertToTConsensusGroupId(), 0L);
+        final boolean initialActive =
+            
lastKnownPreferredWriter.getOrDefault(groupId.convertToTConsensusGroupId(), -1)
+                == IOTDB_CONFIG.getDataNodeId();
+        final Set<Integer> initialActiveWriterNodeIds =
+            regionActiveWriterNodeIds.getOrDefault(
+                groupId.convertToTConsensusGroupId(),
+                initialActive
+                    ? Collections.singleton(IOTDB_CONFIG.getDataNodeId())
+                    : Collections.emptySet());
+        final ConsensusRegionRuntimeState initialRuntimeState =
+            new ConsensusRegionRuntimeState(
+                initialRuntimeVersion,
+                
lastKnownPreferredWriter.getOrDefault(groupId.convertToTConsensusGroupId(), -1),
+                initialActive,
+                initialActiveWriterNodeIds);
 
-          LOGGER.info(
-              DataNodePipeMessages
-                  
.PIPE_LOG_AUTO_BINDING_CONSENSUS_QUEUE_FOR_TOPIC_IN_GROUP_TO_NEW_REGION_86F21649,
-              topicName,
-              consumerGroupId,
-              groupId,
-              dbTableModel,
-              tailStartSearchIndex,
-              hasLocalPersistedState,
-              committedRegionProgress,
-              initialRuntimeVersion,
-              initialActive);
+        LOGGER.info(
+            DataNodePipeMessages
+                
.PIPE_LOG_AUTO_BINDING_CONSENSUS_QUEUE_FOR_TOPIC_IN_GROUP_TO_NEW_REGION_86F21649,
+            topicName,
+            consumerGroupId,
+            groupId,
+            dbTableModel,
+            tailStartSearchIndex,
+            hasLocalPersistedState,
+            committedRegionProgress,
+            initialRuntimeVersion,
+            initialActive);
 
-          SubscriptionAgent.broker()
-              .bindConsensusPrefetchingQueue(
-                  consumerGroupId,
-                  topicName,
-                  topicConfig.getOrderMode(),
-                  groupId,
-                  serverImpl,
-                  retentionPolicy,
-                  converter,
-                  commitManager,
-                  committedRegionProgress,
-                  tailStartSearchIndex,
-                  initialRuntimeVersion,
-                  initialActive,
-                  hasLocalPersistedState);
-          SubscriptionAgent.broker().applyRuntimeStateForRegion(groupId, 
initialRuntimeState);
-        } catch (final Exception e) {
-          LOGGER.error(
-              DataNodePipeMessages
-                  
.PIPE_LOG_FAILED_TO_AUTO_BIND_TOPIC_IN_GROUP_TO_NEW_REGION_5BFD0E7D,
-              topicName,
-              consumerGroupId,
-              groupId,
-              e);
-        }
+        SubscriptionAgent.broker()
+            .bindConsensusPrefetchingQueue(
+                consumerGroupId,
+                topicName,
+                topicConfig.getOrderMode(),
+                groupId,
+                serverImpl,
+                retentionPolicy,
+                converter,
+                commitManager,
+                committedRegionProgress,
+                tailStartSearchIndex,
+                initialRuntimeVersion,
+                initialActive,
+                hasLocalPersistedState);
+        SubscriptionAgent.broker().applyRuntimeStateForRegion(groupId, 
initialRuntimeState);
+      } catch (final Exception e) {
+        LOGGER.error(
+            
DataNodePipeMessages.PIPE_LOG_FAILED_TO_AUTO_BIND_TOPIC_IN_GROUP_TO_NEW_REGION_5BFD0E7D,
+            topicName,
+            consumerGroupId,
+            groupId,
+            e);
       }
     }
   }
@@ -391,6 +409,14 @@ public class ConsensusSubscriptionSetupHandler {
 
   public static void setupConsensusSubscriptions(
       final String consumerGroupId, final Set<String> topicNames, final 
boolean isTableModel) {
+    SubscriptionAgent.consumer()
+        .executeWithConsumerGroupLock(
+            consumerGroupId,
+            () -> setupConsensusSubscriptionsInternal(consumerGroupId, 
topicNames, isTableModel));
+  }
+
+  private static void setupConsensusSubscriptionsInternal(
+      final String consumerGroupId, final Set<String> topicNames, final 
boolean isTableModel) {
     final IConsensus dataRegionConsensus = 
DataRegionConsensusImpl.getInstance();
     if (!(dataRegionConsensus instanceof IoTConsensus)) {
       final String configuredProtocol = 
IOTDB_CONFIG.getDataRegionConsensusProtocolClass();
@@ -420,6 +446,10 @@ public class ConsensusSubscriptionSetupHandler {
         topicNames,
         ioTConsensus.getAllConsensusGroupIds().size());
 
+    // Recovery also visits topics with live queues. Roll back only newly 
bound regions so a
+    // failure on another topic cannot discard their in-flight events or 
committed progress.
+    final Map<String, Set<String>> existingQueueRegionIds =
+        SubscriptionAgent.broker().getConsensusQueueRegionIds(consumerGroupId, 
topicNames);
     final ConsensusSubscriptionCommitManager.SetupSnapshot setupSnapshot =
         commitManager.captureSetupSnapshot(consumerGroupId, topicNames);
     setupConsensusTopics(
@@ -431,19 +461,27 @@ public class ConsensusSubscriptionSetupHandler {
                 consumerGroupId, topicName, isTableModel, ioTConsensus, 
commitManager),
         attemptedTopicNames ->
             rollbackConsensusSubscriptionSetup(
-                consumerGroupId, attemptedTopicNames, commitManager, 
setupSnapshot));
+                consumerGroupId,
+                attemptedTopicNames,
+                commitManager,
+                setupSnapshot,
+                existingQueueRegionIds));
   }
 
   private static void rollbackConsensusSubscriptionSetup(
       final String consumerGroupId,
       final Set<String> attemptedTopicNames,
       final ConsensusSubscriptionCommitManager commitManager,
-      final ConsensusSubscriptionCommitManager.SetupSnapshot setupSnapshot) {
+      final ConsensusSubscriptionCommitManager.SetupSnapshot setupSnapshot,
+      final Map<String, Set<String>> existingQueueRegionIds) {
     RuntimeException rollbackFailure = null;
     for (final String topicName : attemptedTopicNames) {
       try {
         SubscriptionAgent.broker()
-            .unbindConsensusPrefetchingQueue(consumerGroupId, topicName, 
false);
+            .rollbackConsensusPrefetchingQueues(
+                consumerGroupId,
+                topicName,
+                existingQueueRegionIds.getOrDefault(topicName, 
Collections.emptySet()));
       } catch (final RuntimeException e) {
         if (Objects.isNull(rollbackFailure)) {
           rollbackFailure = e;
@@ -453,9 +491,14 @@ public class ConsensusSubscriptionSetupHandler {
       }
     }
     try {
-      commitManager.restoreSetupSnapshot(setupSnapshot, attemptedTopicNames);
+      commitManager.restoreSetupSnapshot(
+          setupSnapshot, attemptedTopicNames, existingQueueRegionIds);
       restoreStartupDetachedRetentions(
-          consumerGroupId, attemptedTopicNames, setupSnapshot, commitManager);
+          consumerGroupId,
+          attemptedTopicNames,
+          setupSnapshot,
+          commitManager,
+          existingQueueRegionIds);
     } catch (final RuntimeException e) {
       if (Objects.isNull(rollbackFailure)) {
         rollbackFailure = e;
@@ -472,7 +515,8 @@ public class ConsensusSubscriptionSetupHandler {
       final String consumerGroupId,
       final Set<String> attemptedTopicNames,
       final ConsensusSubscriptionCommitManager.SetupSnapshot setupSnapshot,
-      final ConsensusSubscriptionCommitManager commitManager) {
+      final ConsensusSubscriptionCommitManager commitManager,
+      final Map<String, Set<String>> existingQueueRegionIds) {
     final IConsensus dataRegionConsensus = 
DataRegionConsensusImpl.getInstance();
     if (!(dataRegionConsensus instanceof IoTConsensus)) {
       return;
@@ -480,6 +524,11 @@ public class ConsensusSubscriptionSetupHandler {
     final IoTConsensus ioTConsensus = (IoTConsensus) dataRegionConsensus;
     for (final String topicName : attemptedTopicNames) {
       for (final String regionId : setupSnapshot.getRegionIds(topicName)) {
+        if (existingQueueRegionIds
+            .getOrDefault(topicName, Collections.emptySet())
+            .contains(regionId)) {
+          continue;
+        }
         final ConsensusGroupId groupId = 
ConsensusGroupId.Factory.createFromString(regionId);
         final IoTConsensusServerImpl serverImpl = 
ioTConsensus.getImpl(groupId);
         if (Objects.nonNull(serverImpl)) {
@@ -554,7 +603,7 @@ public class ConsensusSubscriptionSetupHandler {
    * DATABASE_KEY} filter. Additionally, the {@link #onNewRegionCreated} 
callback ensures that
    * regions created after this method runs are also automatically bound.
    */
-  private static void setupConsensusQueueForTopic(
+  static void setupConsensusQueueForTopic(
       final String consumerGroupId,
       final String topicName,
       final boolean isTableModel,
@@ -598,6 +647,23 @@ public class ConsensusSubscriptionSetupHandler {
       if (!(groupId instanceof DataRegionId)) {
         continue;
       }
+      if (ioTConsensus.isPeerCreationCallbackPending(groupId)
+          && SubscriptionAgent.consumer()
+              .isTopicSubscribedByConsumerGroup(consumerGroupId, topicName)) {
+        // The new-region callback binds this peer from its first WAL index. 
Binding it here from
+        // the current tail could skip writes replicated before the callback 
finishes.
+        bound = true;
+        continue;
+      }
+      if (SubscriptionAgent.broker()
+          .hasConsensusQueueForRegion(consumerGroupId, topicName, 
groupId.toString())) {
+        // In particular, a queue created by onNewRegionCreated must keep its 
initialization from
+        // the beginning of the region instead of being reinitialized from a 
local tail proposal.
+        // Check the live broker as well because a region callback can finish 
after the setup
+        // snapshot was captured.
+        bound = true;
+        continue;
+      }
 
       final IoTConsensusServerImpl serverImpl = ioTConsensus.getImpl(groupId);
       if (serverImpl == null) {
diff --git 
a/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/subscription/receiver/SubscriptionReceiverV1.java
 
b/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/subscription/receiver/SubscriptionReceiverV1.java
index cd53d9376ab..93d309cc5db 100644
--- 
a/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/subscription/receiver/SubscriptionReceiverV1.java
+++ 
b/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/subscription/receiver/SubscriptionReceiverV1.java
@@ -454,6 +454,12 @@ public class SubscriptionReceiverV1 implements 
SubscriptionReceiver {
           consumerConfig);
     }
 
+    // A previous inactivity cleanup or metadata race may have removed the 
local broker while the
+    // coordinator still has a non-empty consumer-group metadata record. 
Reconcile that state before
+    // exposing the consumer to subsequent requests. The operation is 
idempotent and serialized with
+    // metadata pushes by SubscriptionConsumerAgent.
+    SubscriptionAgent.consumer().ensureConsumerGroupBroker(consumerGroupId);
+
     activateConsumer(consumerConfig);
 
     final int dataNodeId = 
IoTDBDescriptor.getInstance().getConfig().getDataNodeId();
diff --git 
a/iotdb-core/datanode/src/test/java/org/apache/iotdb/db/subscription/agent/SubscriptionConsumerAgentBrokerRecoveryTest.java
 
b/iotdb-core/datanode/src/test/java/org/apache/iotdb/db/subscription/agent/SubscriptionConsumerAgentBrokerRecoveryTest.java
new file mode 100644
index 00000000000..d281c85d5cc
--- /dev/null
+++ 
b/iotdb-core/datanode/src/test/java/org/apache/iotdb/db/subscription/agent/SubscriptionConsumerAgentBrokerRecoveryTest.java
@@ -0,0 +1,279 @@
+/*
+ * 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.iotdb.db.subscription.agent;
+
+import 
org.apache.iotdb.commons.pipe.agent.task.connection.UnboundedBlockingPendingQueue;
+import org.apache.iotdb.commons.subscription.meta.consumer.ConsumerGroupMeta;
+import org.apache.iotdb.commons.subscription.meta.consumer.ConsumerMeta;
+import org.apache.iotdb.commons.subscription.meta.topic.TopicMeta;
+import org.apache.iotdb.consensus.IConsensus;
+import org.apache.iotdb.consensus.iot.IoTConsensus;
+import org.apache.iotdb.db.consensus.DataRegionConsensusImpl;
+import org.apache.iotdb.db.pipe.metric.source.PipeDataRegionEventCounter;
+import org.apache.iotdb.db.subscription.broker.ISubscriptionBroker;
+import org.apache.iotdb.db.subscription.broker.SubscriptionBroker;
+import org.apache.iotdb.db.subscription.task.subtask.SubscriptionSinkSubtask;
+import org.apache.iotdb.pipe.api.event.Event;
+import org.apache.iotdb.rpc.subscription.config.ConsumerConfig;
+import org.apache.iotdb.rpc.subscription.config.ConsumerConstant;
+import org.apache.iotdb.rpc.subscription.config.TopicConstant;
+
+import org.junit.Test;
+
+import java.lang.reflect.Field;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.UUID;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.concurrent.Future;
+import java.util.concurrent.TimeUnit;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertNull;
+import static org.junit.Assert.assertSame;
+import static org.junit.Assert.assertTrue;
+import static org.mockito.Mockito.clearInvocations;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+public class SubscriptionConsumerAgentBrokerRecoveryTest {
+
+  private static final long TEST_TIMEOUT_SECONDS = 5L;
+
+  @Test
+  public void testNonEmptyMetadataRecreatesMissingBroker() {
+    final String consumerGroupId = unique("group");
+    final String consumerId = unique("consumer");
+    final SubscriptionConsumerAgent agent = SubscriptionAgent.consumer();
+    try {
+      final ConsumerGroupMeta meta = createMeta(consumerGroupId, consumerId);
+      assertNull(agent.handleSingleConsumerGroupMetaChanges(meta));
+      assertTrue(SubscriptionAgent.broker().isBrokerExist(consumerGroupId));
+
+      assertTrue(SubscriptionAgent.broker().dropBroker(consumerGroupId));
+      assertFalse(SubscriptionAgent.broker().isBrokerExist(consumerGroupId));
+
+      agent.ensureConsumerGroupBroker(consumerGroupId);
+
+      assertTrue(SubscriptionAgent.broker().isBrokerExist(consumerGroupId));
+      assertTrue(agent.isConsumerExisted(consumerGroupId, consumerId));
+    } finally {
+      agent.handleDropConsumerGroup(consumerGroupId);
+    }
+  }
+
+  @Test
+  public void testMetadataPushSetsUpNewConsensusTopicOnce() {
+    final String consumerGroupId = unique("group");
+    final String consumerId = unique("consumer");
+    final String topicName = unique("topic");
+    final SubscriptionConsumerAgent agent = SubscriptionAgent.consumer();
+    final IConsensus originalConsensus = DataRegionConsensusImpl.getInstance();
+    final IoTConsensus consensus = mock(IoTConsensus.class);
+    
when(consensus.getAllConsensusGroupIds()).thenReturn(Collections.emptyList());
+    DataRegionConsensusImpl.setInstance(consensus);
+    try {
+      assertNull(
+          SubscriptionAgent.topic()
+              .handleSingleTopicMetaChanges(
+                  new TopicMeta(
+                      topicName,
+                      1L,
+                      Collections.singletonMap(
+                          TopicConstant.MODE_KEY, 
TopicConstant.MODE_INCREMENTAL_VALUE))));
+      final ConsumerGroupMeta meta = createMeta(consumerGroupId, consumerId);
+      assertNull(agent.handleSingleConsumerGroupMetaChanges(meta));
+      clearInvocations(consensus);
+
+      final ConsumerGroupMeta subscribedMeta = meta.deepCopy();
+      subscribedMeta.addSubscription(consumerId, 
Collections.singleton(topicName));
+      assertNull(agent.handleSingleConsumerGroupMetaChanges(subscribedMeta));
+
+      // One discovery for setup logging and one for binding the topic's 
regions.
+      verify(consensus, times(2)).getAllConsensusGroupIds();
+      assertEquals(
+          Collections.singleton(topicName),
+          agent.getTopicNamesSubscribedByConsumer(consumerGroupId, 
consumerId));
+    } finally {
+      agent.handleDropConsumerGroup(consumerGroupId);
+      SubscriptionAgent.topic().handleDropTopic(topicName, false);
+      DataRegionConsensusImpl.setInstance(originalConsensus);
+    }
+  }
+
+  @Test
+  public void testEmptyMetadataDoesNotRecreateBroker() {
+    final String consumerGroupId = unique("group");
+    final String consumerId = unique("consumer");
+    final SubscriptionConsumerAgent agent = SubscriptionAgent.consumer();
+    try {
+      final ConsumerGroupMeta meta = createMeta(consumerGroupId, consumerId);
+      assertNull(agent.handleSingleConsumerGroupMetaChanges(meta));
+      assertTrue(SubscriptionAgent.broker().isBrokerExist(consumerGroupId));
+
+      final ConsumerGroupMeta emptyMeta = meta.deepCopy();
+      emptyMeta.removeConsumer(consumerId);
+      assertTrue(emptyMeta.isEmpty());
+      assertNull(agent.handleSingleConsumerGroupMetaChanges(emptyMeta));
+
+      assertFalse(SubscriptionAgent.broker().isBrokerExist(consumerGroupId));
+      agent.ensureConsumerGroupBroker(consumerGroupId);
+      assertFalse(SubscriptionAgent.broker().isBrokerExist(consumerGroupId));
+    } finally {
+      agent.handleDropConsumerGroup(consumerGroupId);
+    }
+  }
+
+  @Test
+  public void testConcurrentMetadataPushAndBrokerEnsureCreateOneBroker() 
throws Exception {
+    final String consumerGroupId = unique("group");
+    final String consumerId = unique("consumer");
+    final SubscriptionConsumerAgent agent = SubscriptionAgent.consumer();
+    final ExecutorService executor = Executors.newFixedThreadPool(2);
+    try {
+      final ConsumerGroupMeta meta = createMeta(consumerGroupId, consumerId);
+      assertNull(agent.handleSingleConsumerGroupMetaChanges(meta));
+      assertTrue(SubscriptionAgent.broker().dropBroker(consumerGroupId));
+
+      final Future<?> metadataPush =
+          executor.submit(() -> 
agent.handleSingleConsumerGroupMetaChanges(meta.deepCopy()));
+      final Future<?> handshakeRepair =
+          executor.submit(() -> 
agent.ensureConsumerGroupBroker(consumerGroupId));
+      metadataPush.get(TEST_TIMEOUT_SECONDS, TimeUnit.SECONDS);
+      handshakeRepair.get(TEST_TIMEOUT_SECONDS, TimeUnit.SECONDS);
+
+      assertEquals(1, getBrokers(consumerGroupId).size());
+    } finally {
+      executor.shutdownNow();
+      executor.awaitTermination(TEST_TIMEOUT_SECONDS, TimeUnit.SECONDS);
+      agent.handleDropConsumerGroup(consumerGroupId);
+    }
+  }
+
+  @Test
+  public void testBrokerAndExistingQueueSurviveProviderReplacementRepair() 
throws Exception {
+    final String consumerGroupId = unique("group");
+    final String consumerId = unique("consumer");
+    final String topicName = unique("topic");
+    final SubscriptionConsumerAgent agent = SubscriptionAgent.consumer();
+    try {
+      assertNull(
+          SubscriptionAgent.topic()
+              .handleSingleTopicMetaChanges(new TopicMeta(topicName, 1L, 
Collections.emptyMap())));
+      final ConsumerGroupMeta meta = createMeta(consumerGroupId, consumerId);
+      meta.addSubscription(consumerId, Collections.singleton(topicName));
+      assertNull(agent.handleSingleConsumerGroupMetaChanges(meta));
+
+      bindPipeQueue(consumerGroupId, topicName);
+      final SubscriptionBroker brokerBeforeRepair = 
getPipeBroker(consumerGroupId);
+      assertTrue(brokerBeforeRepair.hasQueue(topicName));
+
+      // A provider replacement closes only its session. The server-side 
repair must preserve the
+      // existing broker and queue when it reconciles the same non-empty 
metadata.
+      agent.ensureConsumerGroupBroker(consumerGroupId);
+
+      assertSame(brokerBeforeRepair, getPipeBroker(consumerGroupId));
+      assertTrue(getPipeBroker(consumerGroupId).hasQueue(topicName));
+    } finally {
+      agent.handleDropConsumerGroup(consumerGroupId);
+      SubscriptionAgent.topic().handleDropTopic(topicName, false);
+    }
+  }
+
+  @Test
+  public void testCanceledTopicIsNotRestoredWhenBrokerIsRecreated() throws 
Exception {
+    final String consumerGroupId = unique("group");
+    final String consumerId = unique("consumer");
+    final String topicName = unique("topic");
+    final SubscriptionConsumerAgent agent = SubscriptionAgent.consumer();
+    try {
+      assertNull(
+          SubscriptionAgent.topic()
+              .handleSingleTopicMetaChanges(new TopicMeta(topicName, 1L, 
Collections.emptyMap())));
+      final ConsumerGroupMeta meta = createMeta(consumerGroupId, consumerId);
+      meta.addSubscription(consumerId, Collections.singleton(topicName));
+      assertNull(agent.handleSingleConsumerGroupMetaChanges(meta));
+
+      bindPipeQueue(consumerGroupId, topicName);
+      assertTrue(getPipeBroker(consumerGroupId).hasQueue(topicName));
+
+      final ConsumerGroupMeta unsubscribedMeta = meta.deepCopy();
+      unsubscribedMeta.removeSubscription(consumerId, 
Collections.singleton(topicName));
+      assertNull(agent.handleSingleConsumerGroupMetaChanges(unsubscribedMeta));
+      assertFalse(getPipeBroker(consumerGroupId).hasQueue(topicName));
+
+      assertTrue(SubscriptionAgent.broker().dropBroker(consumerGroupId));
+      agent.ensureConsumerGroupBroker(consumerGroupId);
+
+      assertFalse(getPipeBroker(consumerGroupId).hasQueue(topicName));
+      assertFalse(agent.getAllSubscriptions().containsKey(consumerGroupId));
+    } finally {
+      agent.handleDropConsumerGroup(consumerGroupId);
+      SubscriptionAgent.topic().handleDropTopic(topicName, false);
+    }
+  }
+
+  private static ConsumerGroupMeta createMeta(
+      final String consumerGroupId, final String consumerId) {
+    final Map<String, String> attributes = new HashMap<>();
+    attributes.put(ConsumerConstant.CONSUMER_ID_KEY, consumerId);
+    attributes.put(ConsumerConstant.CONSUMER_GROUP_ID_KEY, consumerGroupId);
+    final ConsumerConfig consumerConfig = new ConsumerConfig(attributes);
+    return new ConsumerGroupMeta(
+        consumerGroupId, 1L, new ConsumerMeta(consumerId, 1L, 
consumerConfig.getAttribute()));
+  }
+
+  private static void bindPipeQueue(final String consumerGroupId, final String 
topicName) {
+    final UnboundedBlockingPendingQueue<Event> inputQueue =
+        new UnboundedBlockingPendingQueue<>(new PipeDataRegionEventCounter());
+    final SubscriptionSinkSubtask subtask =
+        new SubscriptionSinkSubtask(
+            unique("task"), 1L, "", 0, inputQueue, null, topicName, 
consumerGroupId);
+    SubscriptionAgent.broker().bindPrefetchingQueue(subtask);
+  }
+
+  @SuppressWarnings("unchecked")
+  private static List<ISubscriptionBroker> getBrokers(final String 
consumerGroupId)
+      throws Exception {
+    final Field field = 
SubscriptionBrokerAgent.class.getDeclaredField("consumerGroupIdToBrokers");
+    field.setAccessible(true);
+    final Map<String, List<ISubscriptionBroker>> brokersByConsumerGroup =
+        (Map<String, List<ISubscriptionBroker>>) 
field.get(SubscriptionAgent.broker());
+    return brokersByConsumerGroup.getOrDefault(consumerGroupId, 
Collections.emptyList());
+  }
+
+  private static SubscriptionBroker getPipeBroker(final String 
consumerGroupId) throws Exception {
+    return getBrokers(consumerGroupId).stream()
+        .filter(SubscriptionBroker.class::isInstance)
+        .map(SubscriptionBroker.class::cast)
+        .findFirst()
+        .orElseThrow(AssertionError::new);
+  }
+
+  private static String unique(final String prefix) {
+    return prefix + '-' + UUID.randomUUID();
+  }
+}
diff --git 
a/iotdb-core/datanode/src/test/java/org/apache/iotdb/db/subscription/broker/consensus/ConsensusSubscriptionSetupHandlerTest.java
 
b/iotdb-core/datanode/src/test/java/org/apache/iotdb/db/subscription/broker/consensus/ConsensusSubscriptionSetupHandlerTest.java
index 9401fc3e4af..19f944b52d0 100644
--- 
a/iotdb-core/datanode/src/test/java/org/apache/iotdb/db/subscription/broker/consensus/ConsensusSubscriptionSetupHandlerTest.java
+++ 
b/iotdb-core/datanode/src/test/java/org/apache/iotdb/db/subscription/broker/consensus/ConsensusSubscriptionSetupHandlerTest.java
@@ -21,12 +21,26 @@ package org.apache.iotdb.db.subscription.broker.consensus;
 
 import org.apache.iotdb.commons.consensus.DataRegionId;
 import org.apache.iotdb.commons.pipe.config.constant.SystemConstant;
+import org.apache.iotdb.commons.subscription.meta.consumer.ConsumerGroupMeta;
+import org.apache.iotdb.commons.subscription.meta.consumer.ConsumerMeta;
+import org.apache.iotdb.commons.subscription.meta.topic.TopicMeta;
+import org.apache.iotdb.consensus.IConsensus;
+import org.apache.iotdb.consensus.config.IoTConsensusConfig;
+import org.apache.iotdb.consensus.iot.IoTConsensus;
 import org.apache.iotdb.consensus.iot.IoTConsensusServerImpl;
 import org.apache.iotdb.consensus.iot.SubscriptionWalRetentionPolicy;
 import org.apache.iotdb.db.conf.IoTDBDescriptor;
+import org.apache.iotdb.db.consensus.DataRegionConsensusImpl;
+import org.apache.iotdb.db.storageengine.StorageEngine;
+import org.apache.iotdb.db.storageengine.dataregion.DataRegion;
+import org.apache.iotdb.db.subscription.agent.SubscriptionAgent;
+import org.apache.iotdb.db.subscription.agent.SubscriptionBrokerAgent;
+import org.apache.iotdb.db.subscription.broker.ConsensusSubscriptionBroker;
+import org.apache.iotdb.db.subscription.broker.ISubscriptionBroker;
 import org.apache.iotdb.rpc.subscription.config.TopicConfig;
 import org.apache.iotdb.rpc.subscription.config.TopicConstant;
 import org.apache.iotdb.rpc.subscription.exception.SubscriptionException;
+import org.apache.iotdb.rpc.subscription.payload.poll.RegionProgress;
 import org.apache.iotdb.rpc.subscription.payload.poll.WriterId;
 import org.apache.iotdb.rpc.subscription.payload.poll.WriterProgress;
 
@@ -35,25 +49,39 @@ import org.junit.Test;
 import org.junit.rules.TemporaryFolder;
 
 import java.io.File;
+import java.lang.reflect.Field;
 import java.util.Arrays;
 import java.util.Collections;
 import java.util.HashMap;
 import java.util.LinkedHashSet;
+import java.util.List;
 import java.util.Map;
 import java.util.Set;
+import java.util.UUID;
+import java.util.concurrent.CopyOnWriteArrayList;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.concurrent.Future;
+import java.util.concurrent.TimeUnit;
 import java.util.concurrent.atomic.AtomicInteger;
+import java.util.concurrent.atomic.AtomicReference;
 import java.util.function.LongSupplier;
 
 import static org.junit.Assert.assertEquals;
 import static org.junit.Assert.assertFalse;
 import static org.junit.Assert.assertNotNull;
 import static org.junit.Assert.assertNull;
+import static org.junit.Assert.assertSame;
 import static org.junit.Assert.assertTrue;
+import static org.junit.Assert.fail;
 import static org.mockito.ArgumentMatchers.any;
 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.verify;
+import static org.mockito.Mockito.when;
 
 public class ConsensusSubscriptionSetupHandlerTest {
 
@@ -130,6 +158,343 @@ public class ConsensusSubscriptionSetupHandlerTest {
     assertEquals(attemptedTopicNames, rolledBackTopicNames);
   }
 
+  @Test
+  public void 
testRepairFailurePreservesExistingQueuesAndConcurrentCommitProgress()
+      throws Exception {
+    final IConsensus originalConsensus = DataRegionConsensusImpl.getInstance();
+    final String consumerGroupId = "group-" + UUID.randomUUID();
+    final String topicName = "topic-" + UUID.randomUUID();
+    final DataRegionId existingRegion = new DataRegionId(51);
+    final DataRegionId addedRegion = new DataRegionId(52);
+    final WriterId writerId = new WriterId(existingRegion.toString(), 7);
+    final ConsensusSubscriptionCommitManager commitManager =
+        ConsensusSubscriptionCommitManager.getInstance();
+    final ConsensusSubscriptionBroker broker = new 
ConsensusSubscriptionBroker(consumerGroupId);
+    final ConsensusPrefetchingQueue existingQueue = 
mock(ConsensusPrefetchingQueue.class);
+    final ConsensusPrefetchingQueue addedQueue = 
mock(ConsensusPrefetchingQueue.class);
+    when(existingQueue.getConsensusGroupId()).thenReturn(existingRegion);
+    when(addedQueue.getConsensusGroupId()).thenReturn(addedRegion);
+    final List<ConsensusPrefetchingQueue> queues =
+        new CopyOnWriteArrayList<>(Collections.singletonList(existingQueue));
+    getConsensusQueues(broker).put(topicName, queues);
+    getBrokers()
+        .put(consumerGroupId, new 
CopyOnWriteArrayList<>(Collections.singletonList(broker)));
+    try {
+      assertNull(
+          SubscriptionAgent.topic()
+              .handleSingleTopicMetaChanges(
+                  new TopicMeta(
+                      topicName,
+                      1L,
+                      Collections.singletonMap(
+                          TopicConstant.MODE_KEY, 
TopicConstant.MODE_INCREMENTAL_VALUE))));
+      commitManager.receiveProgressBroadcast(
+          consumerGroupId,
+          topicName,
+          existingRegion.toString(),
+          writerId,
+          new WriterProgress(100L, 1L));
+      final 
ConsensusSubscriptionCommitManager.ConsensusSubscriptionCommitState 
existingState =
+          commitManager.getOrCreateState(consumerGroupId, topicName, 
existingRegion);
+
+      final IoTConsensus consensus = mock(IoTConsensus.class);
+      final AtomicInteger discoveryCount = new AtomicInteger();
+      when(consensus.getAllConsensusGroupIds())
+          .thenAnswer(
+              ignored -> {
+                if (discoveryCount.incrementAndGet() == 2) {
+                  // These changes happen after setup captured its rollback 
snapshot.
+                  queues.add(addedQueue);
+                  commitManager.receiveProgressBroadcast(
+                      consumerGroupId,
+                      topicName,
+                      existingRegion.toString(),
+                      writerId,
+                      new WriterProgress(200L, 2L));
+                  commitManager.receiveProgressBroadcast(
+                      consumerGroupId,
+                      topicName,
+                      addedRegion.toString(),
+                      new WriterId(addedRegion.toString(), 7),
+                      new WriterProgress(100L, 1L));
+                }
+                return Collections.emptyList();
+              });
+      DataRegionConsensusImpl.setInstance(consensus);
+
+      try {
+        ConsensusSubscriptionSetupHandler.setupConsensusSubscriptions(
+            consumerGroupId,
+            new LinkedHashSet<>(Arrays.asList(topicName, "missing-" + 
UUID.randomUUID())),
+            false);
+        fail();
+      } catch (final SubscriptionException expected) {
+        assertTrue(expected.getMessage().contains("missing-"));
+      }
+
+      assertEquals(
+          Collections.singleton(existingRegion.toString()), 
broker.getQueueRegionIds(topicName));
+      assertEquals(Collections.singletonList(existingQueue), queues);
+      verify(existingQueue, never()).closeAsync(false, false);
+      verify(addedQueue).closeAsync(false, false);
+      assertSame(
+          existingState,
+          commitManager.getOrCreateState(consumerGroupId, topicName, 
existingRegion));
+      assertEquals(
+          new WriterProgress(200L, 2L),
+          commitManager
+              .getCommittedRegionProgress(consumerGroupId, topicName, 
existingRegion)
+              .getWriterPositions()
+              .get(writerId));
+      assertFalse(commitManager.hasPersistedState(consumerGroupId, topicName, 
addedRegion));
+    } finally {
+      getBrokers().remove(consumerGroupId);
+      commitManager.removeAllStatesForConsumerGroup(consumerGroupId);
+      SubscriptionAgent.topic().handleDropTopic(topicName, false);
+      DataRegionConsensusImpl.setInstance(originalConsensus);
+    }
+  }
+
+  @SuppressWarnings("unchecked")
+  private static Map<String, List<ConsensusPrefetchingQueue>> 
getConsensusQueues(
+      final ConsensusSubscriptionBroker broker) throws Exception {
+    final Field field =
+        
ConsensusSubscriptionBroker.class.getDeclaredField("topicNameToConsensusPrefetchingQueues");
+    field.setAccessible(true);
+    return (Map<String, List<ConsensusPrefetchingQueue>>) field.get(broker);
+  }
+
+  @Test
+  public void testRepairPreservesInitializationOfExistingNewRegionQueue() 
throws Exception {
+    final String originalSystemDir = 
IoTDBDescriptor.getInstance().getConfig().getSystemDir();
+    final String consumerGroupId = "group-" + UUID.randomUUID();
+    final String topicName = "topic-" + UUID.randomUUID();
+    final DataRegionId regionId = new DataRegionId(53);
+    final DataRegion region = mock(DataRegion.class);
+    when(region.getDatabaseName()).thenReturn("root.repair");
+    final Field regionField = 
StorageEngine.class.getDeclaredField("dataRegionMap");
+    regionField.setAccessible(true);
+    @SuppressWarnings("unchecked")
+    final Map<DataRegionId, DataRegion> regions =
+        (Map<DataRegionId, DataRegion>) 
regionField.get(StorageEngine.getInstance());
+    final DataRegion previousRegion = regions.put(regionId, region);
+    try {
+      IoTDBDescriptor.getInstance()
+          .getConfig()
+          
.setSystemDir(temporaryFolder.newFolder("existingNewRegion").getAbsolutePath());
+      final ConsensusSubscriptionCommitManager manager =
+          new ConsensusSubscriptionCommitManager(
+              (group, topic, id) ->
+                  
ConsensusSubscriptionCommitManager.ConfigNodeProgressQueryResult.absent());
+      manager.initializeStateWithoutTailProposal(consumerGroupId, topicName, 
regionId);
+      assertFalse(manager.isInitialProgressPending(consumerGroupId, topicName, 
regionId));
+      assertTrue(manager.isConfigNodeProgressAvailable(consumerGroupId, 
topicName, regionId));
+      assertNull(
+          SubscriptionAgent.topic()
+              .handleSingleTopicMetaChanges(
+                  new TopicMeta(
+                      topicName,
+                      1L,
+                      Collections.singletonMap(
+                          TopicConstant.MODE_KEY, 
TopicConstant.MODE_INCREMENTAL_VALUE))));
+
+      final ConsensusPrefetchingQueue queue = 
mock(ConsensusPrefetchingQueue.class);
+      when(queue.getConsensusGroupId()).thenReturn(regionId);
+      when(queue.computeTailRegionProgress())
+          .thenReturn(new RegionProgress(Collections.emptyMap()));
+      final ConsensusSubscriptionBroker broker = new 
ConsensusSubscriptionBroker(consumerGroupId);
+      getConsensusQueues(broker)
+          .put(topicName, new 
CopyOnWriteArrayList<>(Collections.singletonList(queue)));
+      getBrokers()
+          .put(consumerGroupId, new 
CopyOnWriteArrayList<>(Collections.singletonList(broker)));
+      final IoTConsensus consensus = mock(IoTConsensus.class);
+      final IoTConsensusServerImpl server = mock(IoTConsensusServerImpl.class);
+      
when(server.getConfig()).thenReturn(IoTConsensusConfig.newBuilder().build());
+      when(consensus.getImpl(regionId)).thenReturn(server);
+      
when(consensus.getAllConsensusGroupIds()).thenReturn(Collections.singletonList(regionId));
+
+      ConsensusSubscriptionSetupHandler.setupConsensusQueueForTopic(
+          consumerGroupId, topicName, false, consensus, manager);
+
+      assertFalse(manager.isInitialProgressPending(consumerGroupId, topicName, 
regionId));
+      assertTrue(manager.isConfigNodeProgressAvailable(consumerGroupId, 
topicName, regionId));
+      verify(queue, never()).computeTailRegionProgress();
+    } finally {
+      if (previousRegion == null) {
+        regions.remove(regionId);
+      } else {
+        regions.put(regionId, previousRegion);
+      }
+      getBrokers().remove(consumerGroupId);
+      SubscriptionAgent.topic().handleDropTopic(topicName, false);
+      
IoTDBDescriptor.getInstance().getConfig().setSystemDir(originalSystemDir);
+    }
+  }
+
+  @Test
+  public void testRepairSkipsRegionWhileNewPeerCallbackIsPending() throws 
Exception {
+    final String consumerGroupId = "group-" + UUID.randomUUID();
+    final String topicName = "topic-" + UUID.randomUUID();
+    final DataRegionId regionId = new DataRegionId(54);
+    final IConsensus originalConsensus = DataRegionConsensusImpl.getInstance();
+    final IoTConsensus consensus = mock(IoTConsensus.class);
+    
when(consensus.getAllConsensusGroupIds()).thenReturn(Collections.emptyList());
+    DataRegionConsensusImpl.setInstance(consensus);
+    try {
+      assertNull(
+          SubscriptionAgent.topic()
+              .handleSingleTopicMetaChanges(
+                  new TopicMeta(
+                      topicName,
+                      1L,
+                      Collections.singletonMap(
+                          TopicConstant.MODE_KEY, 
TopicConstant.MODE_INCREMENTAL_VALUE))));
+      createSubscribedConsumerGroup(consumerGroupId, topicName);
+      
when(consensus.getAllConsensusGroupIds()).thenReturn(Collections.singletonList(regionId));
+      when(consensus.isPeerCreationCallbackPending(regionId)).thenReturn(true);
+
+      ConsensusSubscriptionSetupHandler.setupConsensusQueueForTopic(
+          consumerGroupId,
+          topicName,
+          false,
+          consensus,
+          ConsensusSubscriptionCommitManager.getInstance());
+
+      verify(consensus, never()).getImpl(regionId);
+      assertTrue(
+          getBrokers().getOrDefault(consumerGroupId, 
Collections.emptyList()).stream()
+              .noneMatch(ConsensusSubscriptionBroker.class::isInstance));
+    } finally {
+      SubscriptionAgent.consumer().handleDropConsumerGroup(consumerGroupId);
+      SubscriptionAgent.topic().handleDropTopic(topicName, false);
+      DataRegionConsensusImpl.setInstance(originalConsensus);
+    }
+  }
+
+  @Test
+  public void testNewSubscriptionDoesNotWaitForAnUnrelatedPeerCallback() 
throws Exception {
+    final String consumerGroupId = "group-" + UUID.randomUUID();
+    final String topicName = "topic-" + UUID.randomUUID();
+    final DataRegionId regionId = new DataRegionId(55);
+    final IoTConsensus consensus = mock(IoTConsensus.class);
+    
when(consensus.getAllConsensusGroupIds()).thenReturn(Collections.singletonList(regionId));
+    when(consensus.isPeerCreationCallbackPending(regionId)).thenReturn(true);
+    try {
+      assertNull(
+          SubscriptionAgent.topic()
+              .handleSingleTopicMetaChanges(
+                  new TopicMeta(
+                      topicName,
+                      1L,
+                      Collections.singletonMap(
+                          TopicConstant.MODE_KEY, 
TopicConstant.MODE_INCREMENTAL_VALUE))));
+
+      ConsensusSubscriptionSetupHandler.setupConsensusQueueForTopic(
+          consumerGroupId,
+          topicName,
+          false,
+          consensus,
+          ConsensusSubscriptionCommitManager.getInstance());
+
+      verify(consensus).getImpl(regionId);
+    } finally {
+      SubscriptionAgent.topic().handleDropTopic(topicName, false);
+    }
+  }
+
+  @Test
+  public void testNewRegionCallbackWaitsForRepairAndRereadsSubscriptions() 
throws Exception {
+    final String consumerGroupId = "group-" + UUID.randomUUID();
+    final String topicName = "topic-" + UUID.randomUUID();
+    final DataRegionId regionId = new DataRegionId(56);
+    final IConsensus originalConsensus = DataRegionConsensusImpl.getInstance();
+    final IoTConsensus consensus = mock(IoTConsensus.class);
+    
when(consensus.getAllConsensusGroupIds()).thenReturn(Collections.emptyList());
+    DataRegionConsensusImpl.setInstance(consensus);
+    final ExecutorService executor = Executors.newSingleThreadExecutor();
+    final CountDownLatch callbackStarted = new CountDownLatch(1);
+    final CountDownLatch callbackFinished = new CountDownLatch(1);
+    final AtomicReference<Future<?>> callback = new AtomicReference<>();
+    final IoTConsensusServerImpl server = mock(IoTConsensusServerImpl.class);
+    when(server.getSearchIndex())
+        .thenAnswer(
+            ignored -> {
+              callbackStarted.countDown();
+              return 1L;
+            });
+    try {
+      assertNull(
+          SubscriptionAgent.topic()
+              .handleSingleTopicMetaChanges(
+                  new TopicMeta(
+                      topicName,
+                      1L,
+                      Collections.singletonMap(
+                          TopicConstant.MODE_KEY, 
TopicConstant.MODE_INCREMENTAL_VALUE))));
+      final ConsumerGroupMeta meta = 
createSubscribedConsumerGroup(consumerGroupId, topicName);
+
+      SubscriptionAgent.consumer()
+          .executeWithConsumerGroupLock(
+              consumerGroupId,
+              () -> {
+                callback.set(
+                    executor.submit(
+                        () -> {
+                          try {
+                            
ConsensusSubscriptionSetupHandler.onNewRegionCreated(regionId, server);
+                          } finally {
+                            callbackFinished.countDown();
+                          }
+                        }));
+                try {
+                  assertTrue(callbackStarted.await(5, TimeUnit.SECONDS));
+                  assertFalse(callbackFinished.await(200, 
TimeUnit.MILLISECONDS));
+                } catch (final InterruptedException e) {
+                  Thread.currentThread().interrupt();
+                  throw new AssertionError(e);
+                }
+                final ConsumerGroupMeta unsubscribedMeta = meta.deepCopy();
+                unsubscribedMeta.removeSubscription(null, 
Collections.singleton(topicName));
+                assertNull(
+                    SubscriptionAgent.consumer()
+                        
.handleSingleConsumerGroupMetaChanges(unsubscribedMeta));
+              });
+
+      callback.get().get(5, TimeUnit.SECONDS);
+      assertTrue(
+          SubscriptionAgent.consumer()
+              .getTopicNamesSubscribedByConsumerGroup(consumerGroupId)
+              .isEmpty());
+      assertTrue(
+          getBrokers().getOrDefault(consumerGroupId, 
Collections.emptyList()).stream()
+              .noneMatch(ConsensusSubscriptionBroker.class::isInstance));
+    } finally {
+      executor.shutdownNow();
+      executor.awaitTermination(5, TimeUnit.SECONDS);
+      SubscriptionAgent.consumer().handleDropConsumerGroup(consumerGroupId);
+      SubscriptionAgent.topic().handleDropTopic(topicName, false);
+      DataRegionConsensusImpl.setInstance(originalConsensus);
+    }
+  }
+
+  private static ConsumerGroupMeta createSubscribedConsumerGroup(
+      final String consumerGroupId, final String topicName) {
+    final String consumerId = "consumer-" + UUID.randomUUID();
+    final ConsumerGroupMeta meta =
+        new ConsumerGroupMeta(
+            consumerGroupId, 1L, new ConsumerMeta(consumerId, 1L, 
Collections.emptyMap()));
+    meta.addSubscription(consumerId, Collections.singleton(topicName));
+    
assertNull(SubscriptionAgent.consumer().handleSingleConsumerGroupMetaChanges(meta));
+    return meta;
+  }
+
+  @SuppressWarnings("unchecked")
+  private static Map<String, List<ISubscriptionBroker>> getBrokers() throws 
Exception {
+    final Field field = 
SubscriptionBrokerAgent.class.getDeclaredField("consumerGroupIdToBrokers");
+    field.setAccessible(true);
+    return (Map<String, List<ISubscriptionBroker>>) 
field.get(SubscriptionAgent.broker());
+  }
+
   @Test
   public void 
testFallbackLookupDoesNotQueryConfigNodeWithoutLocalPersistence() throws 
Exception {
     final String originalSystemDir = 
IoTDBDescriptor.getInstance().getConfig().getSystemDir();
diff --git 
a/iotdb-core/datanode/src/test/java/org/apache/iotdb/db/subscription/receiver/SubscriptionReceiverV1Test.java
 
b/iotdb-core/datanode/src/test/java/org/apache/iotdb/db/subscription/receiver/SubscriptionReceiverV1Test.java
index 40482fe1b20..05eccb978f9 100644
--- 
a/iotdb-core/datanode/src/test/java/org/apache/iotdb/db/subscription/receiver/SubscriptionReceiverV1Test.java
+++ 
b/iotdb-core/datanode/src/test/java/org/apache/iotdb/db/subscription/receiver/SubscriptionReceiverV1Test.java
@@ -221,6 +221,39 @@ public class SubscriptionReceiverV1Test {
     Assert.assertTrue((boolean) getField(receiver, "consumerFenced"));
   }
 
+  @Test
+  public void 
testHandshakeRepairsMissingBrokerWithoutChangingConsumerMetadata() throws 
Exception {
+    final String consumerGroupId = "group-" + UUID.randomUUID();
+    final String consumerId = "consumer-" + UUID.randomUUID();
+    final ConsumerConfig consumerConfig =
+        createConsumerConfig(1_000L, null, null, consumerId, consumerGroupId);
+    final ConsumerGroupMeta consumerGroupMeta =
+        new ConsumerGroupMeta(
+            consumerGroupId,
+            System.currentTimeMillis(),
+            new ConsumerMeta(
+                consumerId, System.currentTimeMillis(), 
consumerConfig.getAttribute()));
+    final SubscriptionReceiverV1 receiver = new SubscriptionReceiverV1();
+    Assert.assertNull(
+        
SubscriptionAgent.consumer().handleSingleConsumerGroupMetaChanges(consumerGroupMeta));
+    try {
+      
Assert.assertTrue(SubscriptionAgent.broker().dropBroker(consumerGroupId));
+      
Assert.assertFalse(SubscriptionAgent.broker().isBrokerExist(consumerGroupId));
+
+      final TSStatus status =
+          receiver
+              
.handle(PipeSubscribeHandshakeReq.toTPipeSubscribeReq(consumerConfig))
+              .getStatus();
+
+      Assert.assertEquals(TSStatusCode.SUCCESS_STATUS.getStatusCode(), 
status.getCode());
+      
Assert.assertTrue(SubscriptionAgent.broker().isBrokerExist(consumerGroupId));
+      Assert.assertTrue(
+          SubscriptionAgent.consumer().isConsumerExisted(consumerGroupId, 
consumerId));
+    } finally {
+      SubscriptionAgent.consumer().handleDropConsumerGroup(consumerGroupId);
+    }
+  }
+
   @Test
   public void testNeverHandshakenConsumerStillReturnsMissingConsumerStatus() {
     final SubscriptionReceiverV1 receiver = new SubscriptionReceiverV1();

Reply via email to