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 9647e38692f [Subscription] Restore consensus subscription brokers
after restart (#18916)
9647e38692f is described below
commit 9647e38692f8ba350ac4b3eb4087cb95f473e3dd
Author: Caideyipi <[email protected]>
AuthorDate: Tue Oct 20 22:16:22 2026 +0800
[Subscription] Restore consensus subscription brokers after restart (#18916)
---
.../IoTDBConsensusSubscriptionRecoveryTableIT.java | 165 +++++++++++++++++++
.../apache/iotdb/consensus/iot/IoTConsensus.java | 17 +-
.../consensus/iot/IoTConsensusRecoveryTest.java | 181 +++++++++++++++++++++
3 files changed, 359 insertions(+), 4 deletions(-)
diff --git
a/integration-test/src/test/java/org/apache/iotdb/subscription/it/consensus/local/tablemodel/IoTDBConsensusSubscriptionRecoveryTableIT.java
b/integration-test/src/test/java/org/apache/iotdb/subscription/it/consensus/local/tablemodel/IoTDBConsensusSubscriptionRecoveryTableIT.java
new file mode 100644
index 00000000000..3df498340be
--- /dev/null
+++
b/integration-test/src/test/java/org/apache/iotdb/subscription/it/consensus/local/tablemodel/IoTDBConsensusSubscriptionRecoveryTableIT.java
@@ -0,0 +1,165 @@
+/*
+ * 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.tablemodel;
+
+import org.apache.iotdb.it.env.EnvFactory;
+import org.apache.iotdb.it.env.cluster.config.MppCommonConfig;
+import org.apache.iotdb.it.env.cluster.env.AbstractEnv;
+import org.apache.iotdb.it.framework.IoTDBTestRunner;
+import org.apache.iotdb.itbase.category.LocalStandaloneIT;
+import
org.apache.iotdb.session.subscription.consumer.table.SubscriptionTablePullConsumer;
+import org.apache.iotdb.session.subscription.payload.SubscriptionMessage;
+import org.apache.iotdb.session.subscription.payload.SubscriptionRecordHandler;
+import
org.apache.iotdb.subscription.it.consensus.local.AbstractSubscriptionConsensusLocalIT;
+
+import org.apache.tsfile.read.query.dataset.ResultSet;
+import org.awaitility.Awaitility;
+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.time.Duration;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Properties;
+import java.util.Set;
+
+@RunWith(IoTDBTestRunner.class)
+@Category({LocalStandaloneIT.class})
+public class IoTDBConsensusSubscriptionRecoveryTableIT
+ extends AbstractSubscriptionConsensusLocalIT {
+
+ @Override
+ @Before
+ public void setUp() throws Exception {
+ // Isolate subscription recovery from data loss caused by ASYNC WAL during
a forced shutdown.
+ EnvFactory.getEnv().getConfig().getCommonConfig().setWalMode("SYNC");
+ // Persist each acknowledged batch before killing the servers.
+ final Properties properties = new Properties();
+ properties.setProperty("subscription_consensus_commit_persist_interval",
"1");
+ properties.setProperty("subscription_consensus_commit_fsync_enabled",
"true");
+ ((MppCommonConfig)
EnvFactory.getEnv().getConfig().getDataNodeCommonConfig())
+ .updateProperties(properties);
+ super.setUp();
+ }
+
+ @Test
+ public void testRebuiltConsumersResumeAfterFullClusterRestart() throws
Exception {
+ final ConsensusSubscriptionTableITSupport.TestIdentifiers ids =
+
ConsensusSubscriptionTableITSupport.newIdentifiers("table_full_cluster_restart");
+ final String tableName = "t1";
+ final Set<String> expectedRows = new LinkedHashSet<>();
+ final Set<String> consumedRows = new LinkedHashSet<>();
+ SubscriptionTablePullConsumer firstConsumer = null;
+ SubscriptionTablePullConsumer secondConsumer = null;
+
+ try {
+ ConsensusSubscriptionTableITSupport.bootstrapDatabaseAndTable(
+ ids.getDatabase(), tableName,
ConsensusSubscriptionTableITSupport.DEFAULT_TABLE_SCHEMA);
+ ConsensusSubscriptionTableITSupport.createConsensusTopic(ids.getTopic(),
".*", ".*");
+ firstConsumer =
+ ConsensusSubscriptionTableITSupport.createConsumer(
+ ids.consumer("first"), ids.getConsumerGroupId());
+ secondConsumer =
+ ConsensusSubscriptionTableITSupport.createConsumer(
+ ids.consumer("second"), ids.getConsumerGroupId());
+ firstConsumer.subscribe(ids.getTopic());
+ secondConsumer.subscribe(ids.getTopic());
+
+ final Set<String> committedRows =
+ ConsensusSubscriptionTableITSupport.insertRows(
+ ids.getDatabase(), tableName, 1_000L, 16, true);
+ expectedRows.addAll(committedRows);
+ assertConsumedRows(expectedRows, consumedRows, firstConsumer,
secondConsumer);
+
+ final Set<String> pendingRows =
+ ConsensusSubscriptionTableITSupport.insertRows(
+ ids.getDatabase(), tableName, 2_000L, 64, true);
+
+ EnvFactory.getEnv().shutdownForciblyAllDataNodes();
+ EnvFactory.getEnv().shutdownForciblyAllConfigNodes();
+ // Close while the servers are down so the old clients cannot remove
persisted subscriptions.
+ firstConsumer.close();
+ firstConsumer = null;
+ secondConsumer.close();
+ secondConsumer = null;
+
+ EnvFactory.getEnv().startAllConfigNodes();
+ EnvFactory.getEnv().startAllDataNodes();
+ ((AbstractEnv) EnvFactory.getEnv()).checkClusterStatusWithoutUnknown();
+
+ firstConsumer =
+ ConsensusSubscriptionTableITSupport.createConsumer(
+ ids.consumer("first"), ids.getConsumerGroupId());
+ secondConsumer =
+ ConsensusSubscriptionTableITSupport.createConsumer(
+ ids.consumer("second"), ids.getConsumerGroupId());
+ firstConsumer.subscribe(ids.getTopic());
+ secondConsumer.subscribe(ids.getTopic());
+
+ // Do not seek, recreate the topic, or insert new rows to trigger
subscription setup.
+ // Track unique rows as a receiver would, allowing redelivery around the
forced shutdown.
+ expectedRows.addAll(pendingRows);
+ assertConsumedRows(expectedRows, consumedRows, firstConsumer,
secondConsumer);
+
+ final Set<String> newRows =
+ ConsensusSubscriptionTableITSupport.insertRows(
+ ids.getDatabase(), tableName, 3_000L, 16, true);
+ expectedRows.addAll(newRows);
+ assertConsumedRows(expectedRows, consumedRows, firstConsumer,
secondConsumer);
+ } finally {
+ ConsensusSubscriptionTableITSupport.cleanup(firstConsumer,
ids.getTopic(), ids.getDatabase());
+ ConsensusSubscriptionTableITSupport.cleanup(
+ secondConsumer, ids.getTopic(), ids.getDatabase());
+ }
+ }
+
+ private static void assertConsumedRows(
+ final Set<String> expectedRows,
+ final Set<String> consumedRows,
+ final SubscriptionTablePullConsumer... consumers) {
+ Awaitility.await()
+ .pollInSameThread()
+ .atMost(Duration.ofMinutes(2))
+ .untilAsserted(
+ () -> {
+ for (final SubscriptionTablePullConsumer consumer : consumers) {
+ final List<SubscriptionMessage> messages =
consumer.poll(Duration.ofSeconds(1));
+ for (final SubscriptionMessage message : messages) {
+ for (final ResultSet resultSet : message.getResultSets()) {
+ final SubscriptionRecordHandler.SubscriptionResultSet
records =
+ (SubscriptionRecordHandler.SubscriptionResultSet)
resultSet;
+ while (records.hasNext()) {
+ consumedRows.add(
+ ConsensusSubscriptionTableITSupport.rowKey(
+ records.getDatabaseName(),
+ records.getTableName(),
+ records.nextRecord().getTimestamp()));
+ }
+ }
+ }
+ consumer.commitSync(messages);
+ }
+ Assert.assertEquals(expectedRows, consumedRows);
+ });
+ }
+}
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 8ee9ff96b7a..63ddcc6eba8 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
@@ -111,8 +111,9 @@ public class IoTConsensus implements IConsensus {
private IoTConsensusConfig config;
/**
- * Optional callback invoked after a new local peer is created via {@link
#createLocalPeer}. Used
- * by the subscription system to auto-bind prefetching queues to new
DataRegions.
+ * Optional callback invoked after a local peer is started, either during
recovery or via {@link
+ * #createLocalPeer}. Used by the subscription system to auto-bind
prefetching queues to available
+ * DataRegions.
*/
public static volatile BiConsumer<ConsensusGroupId, IoTConsensusServerImpl>
onNewPeerCreated;
@@ -257,6 +258,10 @@ public class IoTConsensus implements IConsensus {
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);
});
}
@@ -362,11 +367,15 @@ public class IoTConsensus implements IConsensus {
throw new ConsensusGroupAlreadyExistException(groupId);
}
- // Notify subscription system about new peer creation for auto-binding
+ notifyPeerCreated(groupId, stateMachineMap.get(groupId));
+ }
+
+ private void notifyPeerCreated(
+ final ConsensusGroupId groupId, final IoTConsensusServerImpl serverImpl)
{
final BiConsumer<ConsensusGroupId, IoTConsensusServerImpl> callback =
onNewPeerCreated;
if (callback != null) {
try {
- callback.accept(groupId, stateMachineMap.get(groupId));
+ callback.accept(groupId, serverImpl);
} catch (final Exception e) {
logger.warn(
IoTConsensusMessages.LOG_ONNEWPEERCREATED_CALLBACK_FAILED_GROUP_ARG_2671FCDA,
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
new file mode 100644
index 00000000000..fcbb2772a62
--- /dev/null
+++
b/iotdb-core/consensus/src/test/java/org/apache/iotdb/consensus/iot/IoTConsensusRecoveryTest.java
@@ -0,0 +1,181 @@
+/*
+ * 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.consensus.iot;
+
+import org.apache.iotdb.common.rpc.thrift.TConsensusGroupType;
+import org.apache.iotdb.common.rpc.thrift.TEndPoint;
+import org.apache.iotdb.commons.consensus.ConsensusGroupId;
+import org.apache.iotdb.commons.consensus.DataRegionId;
+import org.apache.iotdb.consensus.common.Peer;
+import org.apache.iotdb.consensus.config.ConsensusConfig;
+import org.apache.iotdb.consensus.iot.util.TestStateMachine;
+
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.rules.TemporaryFolder;
+
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.function.BiConsumer;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertSame;
+import static org.junit.Assert.assertTrue;
+
+public class IoTConsensusRecoveryTest {
+
+ @Rule public final TemporaryFolder temporaryFolder = new TemporaryFolder();
+
+ private final Map<ConsensusGroupId, LifecycleStateMachine> stateMachines =
new HashMap<>();
+ private final Map<ConsensusGroupId, List<Peer>> peerLists = new HashMap<>();
+ private BiConsumer<ConsensusGroupId, IoTConsensusServerImpl>
originalNewPeerCallback;
+ private BiConsumer<ConsensusGroupId, IoTConsensusServerImpl>
originalRecoveringCallback;
+ private ConsensusConfig config;
+ private IoTConsensus consensus;
+
+ @Before
+ public void setUp() throws Exception {
+ originalNewPeerCallback = IoTConsensus.onNewPeerCreated;
+ originalRecoveringCallback = IoTConsensus.onPeerRecovering;
+ IoTConsensus.onNewPeerCreated = null;
+ IoTConsensus.onPeerRecovering = null;
+ config =
+ ConsensusConfig.newBuilder()
+ .setThisNodeId(1)
+ .setThisNode(new TEndPoint("127.0.0.1", 0))
+
.setStorageDir(temporaryFolder.newFolder("consensus").getAbsolutePath())
+ .setRecvSnapshotDirs(
+
Collections.singletonList(temporaryFolder.newFolder("snapshots").getAbsolutePath()))
+ .setConsensusGroupType(TConsensusGroupType.DataRegion)
+ .build();
+ consensus = newConsensus();
+ consensus.start();
+ }
+
+ @After
+ public void tearDown() {
+ try {
+ if (consensus != null) {
+ consensus.stop();
+ }
+ } finally {
+ IoTConsensus.onNewPeerCreated = originalNewPeerCallback;
+ IoTConsensus.onPeerRecovering = originalRecoveringCallback;
+ }
+ }
+
+ @Test
+ public void testRecoveredPeersNotifyAfterStarting() throws Exception {
+ final DataRegionId firstRegion = new DataRegionId(1);
+ final DataRegionId secondRegion = new DataRegionId(2);
+ createPeer(firstRegion);
+ createPeer(secondRegion);
+ consensus.stop();
+ consensus = newConsensus();
+
+ final Map<ConsensusGroupId, List<String>> notifications = new HashMap<>();
+ IoTConsensus.onPeerRecovering =
+ (groupId, server) -> {
+ assertSame(server, consensus.getImpl(groupId));
+ assertFalse(stateMachines.get(groupId).started);
+ notifications.computeIfAbsent(groupId, ignored -> new
ArrayList<>()).add("recovering");
+ };
+ IoTConsensus.onNewPeerCreated =
+ (groupId, server) -> {
+ assertSame(server, consensus.getImpl(groupId));
+ assertTrue(stateMachines.get(groupId).started);
+ notifications.computeIfAbsent(groupId, ignored -> new
ArrayList<>()).add("available");
+ };
+
+ consensus.start();
+
+ assertEquals(2, notifications.size());
+ assertEquals(Arrays.asList("recovering", "available"),
notifications.get(firstRegion));
+ assertEquals(Arrays.asList("recovering", "available"),
notifications.get(secondRegion));
+ }
+
+ @Test
+ public void testRecoveryContinuesAfterPeerNotificationFailure() throws
Exception {
+ final DataRegionId firstRegion = new DataRegionId(1);
+ final DataRegionId secondRegion = new DataRegionId(2);
+ createPeer(firstRegion);
+ createPeer(secondRegion);
+ consensus.stop();
+ consensus = newConsensus();
+
+ final List<ConsensusGroupId> notifiedRegions = new ArrayList<>();
+ IoTConsensus.onNewPeerCreated =
+ (groupId, server) -> {
+ assertTrue(stateMachines.get(groupId).started);
+ notifiedRegions.add(groupId);
+ throw new IllegalStateException("subscription binding failed");
+ };
+
+ consensus.start();
+
+ assertEquals(2, notifiedRegions.size());
+ assertTrue(notifiedRegions.containsAll(Arrays.asList(firstRegion,
secondRegion)));
+ assertTrue(stateMachines.get(firstRegion).started);
+ assertTrue(stateMachines.get(secondRegion).started);
+ }
+
+ private IoTConsensus newConsensus() {
+ final IoTConsensus instance =
+ new IoTConsensus(
+ config,
+ groupId -> {
+ final LifecycleStateMachine stateMachine = new
LifecycleStateMachine();
+ stateMachines.put(groupId, stateMachine);
+ return stateMachine;
+ });
+ instance.recordCorrectPeerListBeforeStarting(peerLists);
+ return instance;
+ }
+
+ private void createPeer(final DataRegionId regionId) throws Exception {
+ final List<Peer> peers =
+ Collections.singletonList(
+ new Peer(regionId, config.getThisNodeId(),
config.getThisNodeEndPoint()));
+ consensus.createLocalPeer(regionId, peers);
+ peerLists.put(regionId, peers);
+ }
+
+ private static final class LifecycleStateMachine extends TestStateMachine {
+
+ private boolean started;
+
+ @Override
+ public void start() {
+ started = true;
+ }
+
+ @Override
+ public void stop() {
+ started = false;
+ }
+ }
+}