SOLR-17204: REPLACENODE supports the source node not being live (#2353)

The  REPLACENODE command now supports the source not being live


Co-authored-by: Vincent Primault <vprimault@salesforce.com>
diff --git a/solr/CHANGES.txt b/solr/CHANGES.txt
index 21f1155..386c7d8 100644
--- a/solr/CHANGES.txt
+++ b/solr/CHANGES.txt
@@ -130,6 +130,8 @@
 
 * SOLR-17211: New SolrJ JDK client supports Async (James Dyer)
 
+* SOLR-17204: REPLACENODE now supports the source node not being live (Vincent Primault)
+
 Optimizations
 ---------------------
 * SOLR-17144: Close searcherExecutor thread per core after 1 minute (Pierre Salagnac, Christine Poerschke)
diff --git a/solr/core/src/java/org/apache/solr/cloud/api/collections/ReplaceNodeCmd.java b/solr/core/src/java/org/apache/solr/cloud/api/collections/ReplaceNodeCmd.java
index ee3324f..5134e99 100644
--- a/solr/core/src/java/org/apache/solr/cloud/api/collections/ReplaceNodeCmd.java
+++ b/solr/core/src/java/org/apache/solr/cloud/api/collections/ReplaceNodeCmd.java
@@ -60,19 +60,19 @@
     boolean parallel = message.getBool("parallel", false);
     ClusterState clusterState = zkStateReader.getClusterState();
 
-    if (!clusterState.liveNodesContain(source)) {
-      throw new SolrException(
-          SolrException.ErrorCode.BAD_REQUEST, "Source Node: " + source + " is not live");
-    }
     if (target != null && !clusterState.liveNodesContain(target)) {
       throw new SolrException(
-          SolrException.ErrorCode.BAD_REQUEST, "Target Node: " + target + " is not live");
+          SolrException.ErrorCode.BAD_REQUEST, "Target node: " + target + " is not live");
     } else if (clusterState.getLiveNodes().size() <= 1) {
       throw new SolrException(
           SolrException.ErrorCode.BAD_REQUEST,
           "No nodes other than the source node: "
               + source
               + " are live, therefore replicas cannot be moved");
+    } else if (source.equals(target)) {
+      throw new SolrException(
+          SolrException.ErrorCode.BAD_REQUEST,
+          "Target node: " + target + " cannot be the same as source node");
     }
     List<Replica> sourceReplicas = ReplicaMigrationUtils.getReplicasOfNode(source, clusterState);
     Map<Replica, String> replicaMovements = CollectionUtil.newHashMap(sourceReplicas.size());
diff --git a/solr/core/src/test/org/apache/solr/cloud/ReplaceNodeTest.java b/solr/core/src/test/org/apache/solr/cloud/ReplaceNodeTest.java
index 1f40b6c..6333d7d 100644
--- a/solr/core/src/test/org/apache/solr/cloud/ReplaceNodeTest.java
+++ b/solr/core/src/test/org/apache/solr/cloud/ReplaceNodeTest.java
@@ -312,6 +312,28 @@
         () -> createReplaceNodeRequest(liveNode, null, null).process(cloudClient));
   }
 
+  @Test
+  public void testFailIfSourceIsSameAsTarget() throws Exception {
+    configureCluster(2)
+        .addConfig(
+            "conf1", TEST_PATH().resolve("configsets").resolve("cloud-dynamic").resolve("conf"))
+        .configure();
+    String coll = "replacesourceissameastarget_coll";
+    if (log.isInfoEnabled()) {
+      log.info("total_jettys: {}", cluster.getJettySolrRunners().size());
+    }
+
+    CloudSolrClient cloudClient = cluster.getSolrClient();
+    cloudClient.request(CollectionAdminRequest.createCollection(coll, "conf1", 5, 1, 0, 0));
+
+    cluster.waitForActiveCollection(coll, 5, 5);
+
+    String liveNode = cloudClient.getClusterState().getLiveNodes().iterator().next();
+    expectThrows(
+        SolrException.class,
+        () -> createReplaceNodeRequest(liveNode, liveNode, null).process(cloudClient));
+  }
+
   public static CollectionAdminRequest.AsyncCollectionAdminRequest createReplaceNodeRequest(
       String sourceNode, String targetNode, Boolean parallel) {
     if (random().nextBoolean()) {