This is an automated email from the ASF dual-hosted git repository.
CRZbulabula 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 bacbd5a6dc4 Fix ConfigNode client failover with stale candidates
(#18847)
bacbd5a6dc4 is described below
commit bacbd5a6dc4b677cfe0b84b4f8ab3065c69ffc13
Author: d-wang-commit <[email protected]>
AuthorDate: Sat Oct 17 10:20:27 2026 +0800
Fix ConfigNode client failover with stale candidates (#18847)
Refresh candidates before fallback selection and resume after the current
endpoint.
Preserve candidates and cursor on empty refreshes.
Add routing and connection tests and re-enable node status failover IT.
---
.../it/cluster/IoTDBNodeStatusFailoverIT.java | 21 +-
.../commons/client/AbstractConfigNodeClient.java | 12 +-
.../AbstractConfigNodeClientConnectionTest.java | 340 +++++++++++++++++++++
.../client/AbstractConfigNodeClientTest.java | 312 +++++++++++++++++++
4 files changed, 672 insertions(+), 13 deletions(-)
diff --git
a/integration-test/src/test/java/org/apache/iotdb/confignode/it/cluster/IoTDBNodeStatusFailoverIT.java
b/integration-test/src/test/java/org/apache/iotdb/confignode/it/cluster/IoTDBNodeStatusFailoverIT.java
index 570432bcb9f..13dd39c7a31 100644
---
a/integration-test/src/test/java/org/apache/iotdb/confignode/it/cluster/IoTDBNodeStatusFailoverIT.java
+++
b/integration-test/src/test/java/org/apache/iotdb/confignode/it/cluster/IoTDBNodeStatusFailoverIT.java
@@ -37,8 +37,8 @@ import org.apache.iotdb.rpc.TSStatusCode;
import com.sun.tools.attach.VirtualMachine;
import org.apache.tsfile.utils.Pair;
import org.junit.After;
-import org.junit.Ignore;
import org.junit.Rule;
+import org.junit.Test;
import org.junit.experimental.categories.Category;
import org.junit.rules.ErrorCollector;
import org.junit.rules.TemporaryFolder;
@@ -77,7 +77,6 @@ import static org.junit.Assert.fail;
/** Process-level status persistence checks with three actual ConfigRegion
voters. */
@RunWith(IoTDBTestRunner.class)
@Category(ClusterIT.class)
-@Ignore("Temporarily skip IoTDBNodeStatusFailoverIT")
public class IoTDBNodeStatusFailoverIT {
@Rule public TemporaryFolder temporary = new TemporaryFolder();
@Rule public ErrorCollector failures = new ErrorCollector();
@@ -89,7 +88,7 @@ public class IoTDBNodeStatusFailoverIT {
EnvFactory.getEnv().cleanClusterEnvironment();
}
- // @Test
+ @Test
public void testGracefulLeaderShutdownPreservesStoppedAndRemoving() throws
Exception {
init(3);
int stopped = stoppedFixture(0);
@@ -114,7 +113,7 @@ public class IoTDBNodeStatusFailoverIT {
assertStatus(1, removing, NodeStatus.Removing);
}
- // @Test
+ @Test
public void testGracefulSeedShutdownReportsStopped() throws Exception {
init(1);
transfer(1);
@@ -137,7 +136,7 @@ public class IoTDBNodeStatusFailoverIT {
awaitPersisted(2, seedId, null);
}
- // @Test
+ @Test
public void testForcedLeaderShutdownWithTwoDataNodes() throws Exception {
init(2);
int stopped = stoppedFixture(0);
@@ -157,7 +156,7 @@ public class IoTDBNodeStatusFailoverIT {
assertStatus(0, stopped, NodeStatus.Stopped);
}
- // @Test
+ @Test
public void testNewLeaderChangesSurviveReturnToOriginalLeader() throws
Exception {
init(3);
int stopped = stoppedFixture(0);
@@ -181,7 +180,7 @@ public class IoTDBNodeStatusFailoverIT {
assertStatus(1, removing, NodeStatus.Unknown);
}
- // @Test
+ @Test
public void
testReadOnlyReasonPersistsAcrossLeaderTransferAndClearsAfterRestart()
throws Exception {
init(2);
@@ -230,7 +229,7 @@ public class IoTDBNodeStatusFailoverIT {
}
}
- // @Test
+ @Test
public void testLiveOldLeaderPartitionAndQuorumRecovery() throws Exception {
init(3);
int stopped = stoppedFixture(0);
@@ -299,7 +298,7 @@ public class IoTDBNodeStatusFailoverIT {
}
}
- // @Test
+ @Test
public void testNoQuorumTransitionMatrix() throws Exception {
init(2);
int nodeId = stoppedFixture(0);
@@ -362,7 +361,7 @@ public class IoTDBNodeStatusFailoverIT {
}
}
- // @Test
+ @Test
public void testSnapshotAndFollowingClearLogRecoverTogether() throws
Exception {
init(3);
int stopped = stoppedFixture(0);
@@ -458,7 +457,7 @@ public class IoTDBNodeStatusFailoverIT {
Collections.singletonList(NodeStatus.Running));
}
- // @Test
+ @Test
public void testRealRemovalPersistsIntentAndDeletesRegistration() throws
Exception {
EnvFactory.getEnv()
.getConfig()
diff --git
a/iotdb-core/node-commons/src/main/java/org/apache/iotdb/commons/client/AbstractConfigNodeClient.java
b/iotdb-core/node-commons/src/main/java/org/apache/iotdb/commons/client/AbstractConfigNodeClient.java
index 86bc416653e..2d20166fbb5 100644
---
a/iotdb-core/node-commons/src/main/java/org/apache/iotdb/commons/client/AbstractConfigNodeClient.java
+++
b/iotdb-core/node-commons/src/main/java/org/apache/iotdb/commons/client/AbstractConfigNodeClient.java
@@ -322,6 +322,11 @@ public abstract class AbstractConfigNodeClient<C extends
AbstractConfigNodeClien
}
}
+ // Refresh stale candidates before retrying after an RPC or connection
failure.
+ syncLatestConfigNodeList();
+ if (configNode != null) {
+ cursor = configNodes.indexOf(configNode);
+ }
for (int tryHostNum = 0; tryHostNum < configNodes.size(); tryHostNum++) {
cursor = (cursor + 1) % configNodes.size();
TEndPoint tryEndpoint = configNodes.get(cursor);
@@ -345,8 +350,11 @@ public abstract class AbstractConfigNodeClient<C extends
AbstractConfigNodeClien
}
public void syncLatestConfigNodeList() {
- configNodes = getConfigNodeInfo().getLatestConfigNodes();
- cursor = 0;
+ final List<TEndPoint> latestConfigNodes =
getConfigNodeInfo().getLatestConfigNodes();
+ if (!latestConfigNodes.isEmpty()) {
+ configNodes = latestConfigNodes;
+ cursor = 0;
+ }
}
@Override
diff --git
a/iotdb-core/node-commons/src/test/java/org/apache/iotdb/commons/client/AbstractConfigNodeClientConnectionTest.java
b/iotdb-core/node-commons/src/test/java/org/apache/iotdb/commons/client/AbstractConfigNodeClientConnectionTest.java
new file mode 100644
index 00000000000..6b99634c3ae
--- /dev/null
+++
b/iotdb-core/node-commons/src/test/java/org/apache/iotdb/commons/client/AbstractConfigNodeClientConnectionTest.java
@@ -0,0 +1,340 @@
+/*
+ * 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.commons.client;
+
+import org.apache.iotdb.common.rpc.thrift.TEndPoint;
+import org.apache.iotdb.commons.client.property.ThriftClientProperty;
+import org.apache.iotdb.commons.conf.CommonConfig;
+import org.apache.iotdb.commons.conf.CommonDescriptor;
+import org.apache.iotdb.commons.consensus.ConfigRegionId;
+
+import org.apache.commons.pool2.BaseKeyedPooledObjectFactory;
+import org.apache.commons.pool2.PooledObject;
+import org.apache.commons.pool2.impl.DefaultPooledObject;
+import org.apache.commons.pool2.impl.GenericKeyedObjectPool;
+import org.apache.thrift.TException;
+import org.apache.thrift.transport.TTransport;
+import org.junit.Test;
+
+import java.io.IOException;
+import java.net.InetAddress;
+import java.net.ServerSocket;
+import java.net.Socket;
+import java.net.SocketException;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+import java.util.concurrent.BlockingQueue;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.concurrent.Future;
+import java.util.concurrent.LinkedBlockingQueue;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicReference;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertNotNull;
+import static org.junit.Assert.assertNotSame;
+import static org.junit.Assert.assertSame;
+import static org.junit.Assert.assertTrue;
+import static org.mockito.Mockito.CALLS_REAL_METHODS;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+import static org.mockito.Mockito.withSettings;
+
+public class AbstractConfigNodeClientConnectionTest {
+
+ private static final int CLIENT_COUNT = 100;
+ private static final int RECONNECT_ROUNDS = 3;
+ private static final int TIMEOUT_SECONDS = 30;
+
+ @Test(timeout = 60_000)
+ public void testCloseReturnsConnectionToPoolUntilManagerCloses() throws
Exception {
+ CommonConfig commonConfig = CommonDescriptor.getInstance().getConfig();
+ boolean internalSslEnabled = commonConfig.isEnableInternalSSL();
+ commonConfig.setEnableInternalSSL(false);
+ try (SocketAcceptor server = new SocketAcceptor()) {
+ AbstractConfigNodeInfo nodeInfo = mock(AbstractConfigNodeInfo.class);
+ List<TEndPoint> nodes = Collections.singletonList(server.endpoint());
+ when(nodeInfo.getLatestConfigNodes()).thenReturn(nodes);
+ ClientManager<ConfigRegionId, TestConfigNodeClient> manager =
newManager(nodes, nodeInfo);
+ TestConfigNodeClient client = null;
+ try {
+ client = manager.borrowClient(AbstractConfigNodeInfo.CONFIG_REGION_ID);
+ TTransport transport = client.getTransport();
+ List<Socket> sockets = server.awaitConnections(1);
+ assertTrue(transport.isOpen());
+ assertEquals(1, manager.getPool().getNumActive());
+
+ client.close();
+ assertTrue(transport.isOpen());
+ assertEquals(0, manager.getPool().getNumActive());
+ assertEquals(1, manager.getPool().getNumIdle());
+
+ try (TestConfigNodeClient borrowed =
+ manager.borrowClient(AbstractConfigNodeInfo.CONFIG_REGION_ID)) {
+ assertSame(client, borrowed);
+ assertSame(transport, borrowed.getTransport());
+ assertEquals(1, manager.getPool().getCreatedCount());
+ assertEquals(1, manager.getPool().getNumActive());
+ assertEquals(0, manager.getPool().getNumIdle());
+ }
+ manager.close();
+ assertFalse(transport.isOpen());
+ assertEquals(0, manager.getPool().getNumActive());
+ assertEquals(0, manager.getPool().getNumIdle());
+ assertPeerConnectionsClosed(sockets);
+ } finally {
+ manager.close();
+ if (client != null) {
+ client.invalidate();
+ }
+ }
+ } finally {
+ commonConfig.setEnableInternalSSL(internalSslEnabled);
+ }
+ }
+
+ /** Exercises 100 clients with real TCP transports, not 100 complete
DataNode processes. */
+ @Test(timeout = 120_000)
+ public void testConcurrentReconnectReleasesPreviousConnections() throws
Exception {
+ CommonConfig commonConfig = CommonDescriptor.getInstance().getConfig();
+ boolean internalSslEnabled = commonConfig.isEnableInternalSSL();
+ commonConfig.setEnableInternalSSL(false);
+ ExecutorService workers = Executors.newFixedThreadPool(CLIENT_COUNT);
+ List<TestConfigNodeClient> clients = new ArrayList<>();
+ List<TTransport> allTransports = new ArrayList<>();
+ try (SocketAcceptor first = new SocketAcceptor();
+ SocketAcceptor second = new SocketAcceptor()) {
+ AtomicReference<List<TEndPoint>> latestNodes =
+ new AtomicReference<>(Collections.singletonList(first.endpoint()));
+ AbstractConfigNodeInfo nodeInfo = mock(AbstractConfigNodeInfo.class);
+ when(nodeInfo.getLatestConfigNodes())
+ .thenAnswer(invocation -> new ArrayList<>(latestNodes.get()));
+
+ for (int i = 0; i < CLIENT_COUNT; i++) {
+ TestConfigNodeClient client =
+ mock(
+ TestConfigNodeClient.class,
+ withSettings()
+ .useConstructor(latestNodes.get(), nodeInfo)
+ .defaultAnswer(CALLS_REAL_METHODS));
+ clients.add(client);
+ allTransports.add(client.getTransport());
+ }
+ List<Socket> previousSockets = first.awaitConnections(CLIENT_COUNT);
+ assertOpenTransportCount(allTransports, CLIENT_COUNT);
+
+ for (int round = 0; round < RECONNECT_ROUNDS; round++) {
+ SocketAcceptor target = round % 2 == 0 ? second : first;
+ latestNodes.set(Collections.singletonList(target.endpoint()));
+ CountDownLatch ready = new CountDownLatch(CLIENT_COUNT);
+ CountDownLatch start = new CountDownLatch(1);
+ List<Future<?>> reconnects = new ArrayList<>();
+ try {
+ for (TestConfigNodeClient client : clients) {
+ reconnects.add(
+ workers.submit(
+ () -> {
+ ready.countDown();
+ assertTrue(start.await(TIMEOUT_SECONDS,
TimeUnit.SECONDS));
+ TTransport previousTransport = client.getTransport();
+ client.configLeader = null;
+ // Use real fallback selection and real TCP connection
replacement.
+ client.connectAndSync();
+ assertEquals(target.endpoint(), client.configNode);
+ assertNotSame(previousTransport, client.getTransport());
+ assertFalse(previousTransport.isOpen());
+ assertTrue(client.getTransport().isOpen());
+ return null;
+ }));
+ }
+ assertTrue(ready.await(TIMEOUT_SECONDS, TimeUnit.SECONDS));
+ } finally {
+ start.countDown();
+ }
+ for (Future<?> reconnect : reconnects) {
+ reconnect.get(TIMEOUT_SECONDS, TimeUnit.SECONDS);
+ }
+ for (TestConfigNodeClient client : clients) {
+ allTransports.add(client.getTransport());
+ }
+ assertOpenTransportCount(allTransports, CLIENT_COUNT);
+ assertPeerConnectionsClosed(previousSockets);
+ previousSockets = target.awaitConnections(CLIENT_COUNT);
+ }
+
+ clients.forEach(TestConfigNodeClient::invalidate);
+ assertOpenTransportCount(allTransports, 0);
+ assertPeerConnectionsClosed(previousSockets);
+ assertEquals(CLIENT_COUNT * (RECONNECT_ROUNDS + 1),
allTransports.size());
+ } finally {
+ workers.shutdownNow();
+ try {
+ assertTrue(workers.awaitTermination(TIMEOUT_SECONDS,
TimeUnit.SECONDS));
+ } finally {
+ clients.forEach(TestConfigNodeClient::invalidate);
+ commonConfig.setEnableInternalSSL(internalSslEnabled);
+ }
+ }
+ }
+
+ private static void assertOpenTransportCount(List<TTransport> transports,
int expected) {
+ assertEquals(expected,
transports.stream().filter(TTransport::isOpen).count());
+ }
+
+ private static ClientManager<ConfigRegionId, TestConfigNodeClient>
newManager(
+ List<TEndPoint> nodes, AbstractConfigNodeInfo nodeInfo) {
+ return new ClientManager<>(
+ manager ->
+ new GenericKeyedObjectPool<>(
+ new BaseKeyedPooledObjectFactory<ConfigRegionId,
TestConfigNodeClient>() {
+ @Override
+ public TestConfigNodeClient create(ConfigRegionId key) {
+ return mock(
+ TestConfigNodeClient.class,
+ withSettings()
+ .useConstructor(nodes, nodeInfo, manager)
+ .defaultAnswer(CALLS_REAL_METHODS));
+ }
+
+ @Override
+ public PooledObject<TestConfigNodeClient>
wrap(TestConfigNodeClient client) {
+ return new DefaultPooledObject<>(client);
+ }
+
+ @Override
+ public void destroyObject(
+ ConfigRegionId key, PooledObject<TestConfigNodeClient>
client) {
+ client.getObject().invalidate();
+ }
+ }));
+ }
+
+ private static void assertPeerConnectionsClosed(List<Socket> sockets) throws
IOException {
+ for (Socket socket : sockets) {
+ // connect() sends no RPC payload: EOF proves the client closed the
actual TCP connection.
+ socket.setSoTimeout((int) TimeUnit.SECONDS.toMillis(TIMEOUT_SECONDS));
+ assertEquals(-1, socket.getInputStream().read());
+ socket.close();
+ }
+ }
+
+ private static final class SocketAcceptor implements AutoCloseable {
+
+ private final ServerSocket server;
+ private final ExecutorService acceptor =
Executors.newSingleThreadExecutor();
+ private final BlockingQueue<Socket> connections = new
LinkedBlockingQueue<>();
+ private final List<Socket> allConnections =
Collections.synchronizedList(new ArrayList<>());
+ private final Future<?> accepting;
+
+ private SocketAcceptor() throws IOException {
+ server = new ServerSocket(0, CLIENT_COUNT * 2,
InetAddress.getByName("127.0.0.1"));
+ accepting =
+ acceptor.submit(
+ () -> {
+ while (!server.isClosed()) {
+ try {
+ Socket socket = server.accept();
+ allConnections.add(socket);
+ connections.add(socket);
+ } catch (SocketException e) {
+ if (!server.isClosed()) {
+ throw e;
+ }
+ }
+ }
+ return null;
+ });
+ }
+
+ private TEndPoint endpoint() {
+ return new TEndPoint("127.0.0.1", server.getLocalPort());
+ }
+
+ private List<Socket> awaitConnections(int count) throws
InterruptedException {
+ List<Socket> result = new ArrayList<>();
+ for (int i = 0; i < count; i++) {
+ Socket socket = connections.poll(TIMEOUT_SECONDS, TimeUnit.SECONDS);
+ assertNotNull(socket);
+ result.add(socket);
+ }
+ return result;
+ }
+
+ @Override
+ public void close() throws Exception {
+ try {
+ server.close();
+ accepting.get(TIMEOUT_SECONDS, TimeUnit.SECONDS);
+ } finally {
+ acceptor.shutdownNow();
+ try {
+ assertTrue(acceptor.awaitTermination(TIMEOUT_SECONDS,
TimeUnit.SECONDS));
+ } finally {
+ for (Socket socket : allConnections) {
+ socket.close();
+ }
+ }
+ }
+ }
+ }
+
+ abstract static class TestConfigNodeClient
+ extends AbstractConfigNodeClient<TestConfigNodeClient> {
+
+ private final AbstractConfigNodeInfo nodeInfo;
+
+ TestConfigNodeClient(List<TEndPoint> nodes, AbstractConfigNodeInfo
nodeInfo) throws TException {
+ this(nodes, nodeInfo, null);
+ }
+
+ TestConfigNodeClient(
+ List<TEndPoint> nodes,
+ AbstractConfigNodeInfo nodeInfo,
+ ClientManager<ConfigRegionId, TestConfigNodeClient> manager)
+ throws TException {
+ super(
+ nodes,
+ new ThriftClientProperty.Builder()
+ .setConnectionTimeoutMs((int)
TimeUnit.SECONDS.toMillis(TIMEOUT_SECONDS))
+ .build(),
+ manager);
+ this.nodeInfo = nodeInfo;
+ }
+
+ @Override
+ protected String getNodeTypeName() {
+ return "DataNode";
+ }
+
+ @Override
+ protected TEndPoint getAddressAndPort() {
+ return new TEndPoint("127.0.0.1", 0);
+ }
+
+ @Override
+ protected AbstractConfigNodeInfo getConfigNodeInfo() {
+ return nodeInfo;
+ }
+ }
+}
diff --git
a/iotdb-core/node-commons/src/test/java/org/apache/iotdb/commons/client/AbstractConfigNodeClientTest.java
b/iotdb-core/node-commons/src/test/java/org/apache/iotdb/commons/client/AbstractConfigNodeClientTest.java
new file mode 100644
index 00000000000..53c8cd4e0b0
--- /dev/null
+++
b/iotdb-core/node-commons/src/test/java/org/apache/iotdb/commons/client/AbstractConfigNodeClientTest.java
@@ -0,0 +1,312 @@
+/*
+ * 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.commons.client;
+
+import org.apache.iotdb.common.rpc.thrift.TEndPoint;
+import org.apache.iotdb.common.rpc.thrift.TSStatus;
+import org.apache.iotdb.commons.client.property.ThriftClientProperty;
+import org.apache.iotdb.rpc.TSStatusCode;
+
+import org.apache.thrift.TException;
+import org.junit.Test;
+
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.List;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertNull;
+import static org.junit.Assert.assertTrue;
+import static org.junit.Assert.fail;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyInt;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.CALLS_REAL_METHODS;
+import static org.mockito.Mockito.clearInvocations;
+import static org.mockito.Mockito.doThrow;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+import static org.mockito.Mockito.withSettings;
+
+public class AbstractConfigNodeClientTest {
+
+ private static final TEndPoint FIRST = new TEndPoint("127.0.0.1", 10710);
+ private static final TEndPoint SECOND = new TEndPoint("127.0.0.1", 10720);
+ private static final TEndPoint THIRD = new TEndPoint("127.0.0.1", 10730);
+
+ @Test
+ public void testRefreshStaleListAfterRedirectWithoutLeader() throws
TException {
+ TestConfigNodeClient client = newClient(Arrays.asList(SECOND, FIRST,
THIRD));
+
+ // The latest list has a different order. Continue after the node that
just replied, rather
+ // than restarting at a fixed position that would select FIRST again.
+ executeResponses(
+ client, Arrays.asList(FIRST, THIRD),
Arrays.asList(redirectWithoutLeader(), success()));
+
+ assertEquals(Arrays.asList(SECOND, FIRST, THIRD), client.configNodes);
+ assertNull(client.configLeader);
+ }
+
+ @Test
+ public void testRepeatedRedirectsContinueThroughLatestList() throws
TException {
+ TestConfigNodeClient client = newClient(Arrays.asList(FIRST, SECOND,
THIRD));
+
+ // Refreshing the same list on every response must not reset the cursor
and pin retries to
+ // SECOND. Both non-leaders respond successfully at the transport layer.
+ executeResponses(
+ client,
+ Arrays.asList(FIRST, SECOND, THIRD),
+ Arrays.asList(redirectWithoutLeader(), redirectWithoutLeader(),
success()));
+ }
+
+ @Test
+ public void testResponseCandidateListCanReorderNodesAfterLeaderUpdate()
throws TException {
+ AbstractConfigNodeInfo nodeInfo = mock(AbstractConfigNodeInfo.class);
+ when(nodeInfo.getLatestConfigNodes()).thenReturn(Arrays.asList(FIRST,
SECOND, THIRD));
+ TestConfigNodeClient client = newClient(Collections.singletonList(FIRST),
nodeInfo);
+ List<TEndPoint> expectedNodes = Arrays.asList(FIRST, THIRD);
+ List<TEndPoint> requestedNodes = new ArrayList<>();
+
+ TSStatus result =
+ client.executeRemoteCallWithRetry(
+ () -> {
+ int requestIndex = requestedNodes.size();
+ assertTrue(requestIndex < expectedNodes.size());
+ assertEquals(expectedNodes.get(requestIndex), client.configNode);
+ requestedNodes.add(client.configNode);
+ return requestIndex == 0 ? redirectWithoutLeader() : success();
+ },
+ status -> {
+ if (!client.updateConfigNodeLeader(status)) {
+ return true;
+ }
+ // Registration updates both lists, but the shared set and
response list may have
+ // different iteration orders. Continue after FIRST in the
refreshed shared list.
+
when(nodeInfo.getLatestConfigNodes()).thenReturn(Arrays.asList(SECOND, FIRST,
THIRD));
+ client.configNodes = Arrays.asList(FIRST, THIRD, SECOND);
+ return false;
+ });
+
+ assertEquals(TSStatusCode.SUCCESS_STATUS.getStatusCode(),
result.getCode());
+ assertEquals(expectedNodes, requestedNodes);
+ }
+
+ @Test
+ public void testExplicitRedirectTakesPriorityOverCandidateList() throws
TException {
+ TestConfigNodeClient client = newClient(Arrays.asList(FIRST, SECOND));
+ TSStatus redirect = redirectWithoutLeader().setRedirectNode(THIRD);
+
+ executeResponses(client, Arrays.asList(FIRST, THIRD),
Arrays.asList(redirect, success()));
+
+ assertEquals(THIRD, client.configLeader);
+ }
+
+ @Test
+ public void testSingleCandidateCanBeRetried() throws TException {
+ TestConfigNodeClient client = newClient(Collections.singletonList(FIRST));
+
+ executeResponses(
+ client, Arrays.asList(FIRST, FIRST),
Arrays.asList(redirectWithoutLeader(), success()));
+ }
+
+ @Test
+ public void testRemovedCurrentNodeStartsAtFirstLatestCandidate() throws
TException {
+ TestConfigNodeClient client = newClient(Arrays.asList(SECOND, THIRD));
+
+ executeResponses(
+ client, Arrays.asList(FIRST, SECOND),
Arrays.asList(redirectWithoutLeader(), success()));
+ }
+
+ @Test
+ public void testEmptyLatestListPreservesKnownCandidates() throws TException {
+ AbstractConfigNodeInfo nodeInfo = mock(AbstractConfigNodeInfo.class);
+ when(nodeInfo.getLatestConfigNodes()).thenReturn(Collections.emptyList());
+ TestConfigNodeClient client = newClient(Arrays.asList(FIRST, SECOND),
nodeInfo);
+
+ executeResponses(
+ client, Arrays.asList(FIRST, SECOND),
Arrays.asList(redirectWithoutLeader(), success()));
+
+ assertEquals(Arrays.asList(FIRST, SECOND), client.configNodes);
+ }
+
+ @Test
+ public void
testRpcFailureRefreshesCandidatesEvenWhenOldNodeAcceptsConnections()
+ throws TException {
+ TestConfigNodeClient client = newClient(Arrays.asList(FIRST, SECOND,
THIRD));
+ List<TEndPoint> requestedNodes = new ArrayList<>();
+
+ TSStatus result =
+ client.executeRemoteCallWithRetry(
+ () -> {
+ requestedNodes.add(client.configNode);
+ if (requestedNodes.size() == 1) {
+ throw new TException("RPC failed while the old endpoint still
accepts connections");
+ }
+ assertEquals(SECOND, client.configNode);
+ return success();
+ },
+ status -> !client.updateConfigNodeLeader(status));
+
+ assertEquals(TSStatusCode.SUCCESS_STATUS.getStatusCode(),
result.getCode());
+ assertEquals(Arrays.asList(FIRST, SECOND), requestedNodes);
+ }
+
+ @Test
+ public void
testCachedLeaderConnectionFailureRefreshesBeforeTryingOldCandidates()
+ throws TException {
+ AbstractConfigNodeInfo nodeInfo = mock(AbstractConfigNodeInfo.class);
+ when(nodeInfo.getLatestConfigNodes()).thenReturn(Arrays.asList(SECOND,
THIRD));
+ TestConfigNodeClient client = newClient(Arrays.asList(FIRST, THIRD),
nodeInfo);
+ doThrow(new TException("Cached leader is unreachable"))
+ .when(client)
+ .connect(eq(FIRST), anyInt());
+
+ client.connectAndSync();
+
+ assertEquals(SECOND, client.configNode);
+ assertNull(client.configLeader);
+ assertEquals(Arrays.asList(SECOND, THIRD), client.configNodes);
+ verify(client, never()).connect(eq(THIRD), anyInt());
+ }
+
+ @Test
+ public void testUnavailableCandidatesHaveBoundedConnectionAttempts() throws
TException {
+ TestConfigNodeClient client = newClient(Arrays.asList(FIRST, SECOND,
THIRD));
+ client.configLeader = null;
+ clearInvocations(client);
+ doThrow(new TException("Candidate is
unreachable")).when(client).connect(any(), anyInt());
+
+ try {
+ client.connectAndSync();
+ fail("All candidates are unreachable");
+ } catch (TException expected) {
+ // connectAndSync allows two passes, with at most one attempt per
candidate in each pass.
+ verify(client, times(2)).connect(eq(FIRST), anyInt());
+ verify(client, times(2)).connect(eq(SECOND), anyInt());
+ verify(client, times(2)).connect(eq(THIRD), anyInt());
+ verify(client, times(6)).connect(any(), anyInt());
+ }
+ }
+
+ @Test
+ public void testSuccessfulRpcKeepsExistingConnection() throws TException {
+ AbstractConfigNodeInfo nodeInfo = mock(AbstractConfigNodeInfo.class);
+ TestConfigNodeClient client = newClient(Collections.singletonList(FIRST),
nodeInfo);
+ clearInvocations(client);
+
+ executeResponses(
+ client, Collections.singletonList(FIRST),
Collections.singletonList(success()));
+
+ verify(client, never()).connect(any(), anyInt());
+ verify(nodeInfo, never()).getLatestConfigNodes();
+ }
+
+ @Test
+ public void testWarmingUpKeepsKnownLeaderWithoutRefreshingCandidates()
throws TException {
+ AbstractConfigNodeInfo nodeInfo = mock(AbstractConfigNodeInfo.class);
+ TestConfigNodeClient client = newClient(Arrays.asList(FIRST, SECOND),
nodeInfo);
+
+ executeResponses(
+ client,
+ Arrays.asList(FIRST, FIRST),
+ Arrays.asList(
+ new
TSStatus(TSStatusCode.CONFIG_NODE_LEADER_WARMING_UP.getStatusCode()),
success()));
+
+ assertEquals(FIRST, client.configLeader);
+ verify(nodeInfo, never()).getLatestConfigNodes();
+ verify(client, never()).connect(eq(SECOND), anyInt());
+ }
+
+ private static TestConfigNodeClient newClient(List<TEndPoint> latestNodes)
throws TException {
+ AbstractConfigNodeInfo nodeInfo = mock(AbstractConfigNodeInfo.class);
+ when(nodeInfo.getLatestConfigNodes()).thenReturn(latestNodes);
+ return newClient(Collections.singletonList(FIRST), nodeInfo);
+ }
+
+ private static TestConfigNodeClient newClient(
+ List<TEndPoint> nodes, AbstractConfigNodeInfo nodeInfo) {
+ return mock(
+ TestConfigNodeClient.class,
+ withSettings().useConstructor(nodes,
nodeInfo).defaultAnswer(CALLS_REAL_METHODS));
+ }
+
+ private static void executeResponses(
+ TestConfigNodeClient client, List<TEndPoint> expectedNodes,
List<TSStatus> responses)
+ throws TException {
+ List<TEndPoint> requestedNodes = new ArrayList<>();
+ TSStatus result =
+ client.executeRemoteCallWithRetry(
+ () -> {
+ int requestIndex = requestedNodes.size();
+ assertTrue(requestIndex < expectedNodes.size());
+ assertEquals(expectedNodes.get(requestIndex), client.configNode);
+ requestedNodes.add(client.configNode);
+ return responses.get(requestIndex);
+ },
+ status -> !client.updateConfigNodeLeader(status));
+
+ assertEquals(TSStatusCode.SUCCESS_STATUS.getStatusCode(),
result.getCode());
+ assertEquals(expectedNodes, requestedNodes);
+ }
+
+ private static TSStatus redirectWithoutLeader() {
+ return new TSStatus(TSStatusCode.REDIRECTION_RECOMMEND.getStatusCode());
+ }
+
+ private static TSStatus success() {
+ return new TSStatus(TSStatusCode.SUCCESS_STATUS.getStatusCode());
+ }
+
+ abstract static class TestConfigNodeClient
+ extends AbstractConfigNodeClient<TestConfigNodeClient> {
+
+ private final AbstractConfigNodeInfo nodeInfo;
+
+ TestConfigNodeClient(List<TEndPoint> nodes, AbstractConfigNodeInfo
nodeInfo) throws TException {
+ super(nodes, new ThriftClientProperty.Builder().build(), null);
+ this.nodeInfo = nodeInfo;
+ }
+
+ @Override
+ public void connect(TEndPoint endpoint, int timeoutMs) throws TException {
+ // Model successful TCP connections while exercising the real selection
and retry logic.
+ configNode = endpoint;
+ }
+
+ @Override
+ protected String getNodeTypeName() {
+ return "DataNode";
+ }
+
+ @Override
+ protected TEndPoint getAddressAndPort() {
+ return new TEndPoint("127.0.0.1", 10740);
+ }
+
+ @Override
+ protected AbstractConfigNodeInfo getConfigNodeInfo() {
+ return nodeInfo;
+ }
+ }
+}