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

dsmiley pushed a commit to branch branch_9x
in repository https://gitbox.apache.org/repos/asf/solr.git


The following commit(s) were added to refs/heads/branch_9x by this push:
     new 3e676e5eef9 SOLR-17879: Fail to start if its major version is smaller 
than the cluster (#3510)
3e676e5eef9 is described below

commit 3e676e5eef98527821b03bbe80ba45cab0d5c66a
Author: David Smiley <dsmi...@apache.org>
AuthorDate: Fri Sep 5 20:41:49 2025 -0400

    SOLR-17879: Fail to start if its major version is smaller than the cluster 
(#3510)
    
    A Solr node will now fail to start if its major.minor version (e.g. 9.10) 
is *lower* than that of any existing
      Solr node in a SolrCloud cluster (as reported by info in "live_node").
    
    (cherry picked from commit bd767e3f744e540530abd86cba8f4a6a8a17035c)
---
 solr/CHANGES.txt                                   |   3 +
 .../java/org/apache/solr/cloud/ZkController.java   |  49 +++++++
 .../org/apache/solr/cloud/ZkControllerTest.java    | 151 +++++++++++++++++++++
 .../solr/cloud/overseer/ZkStateReaderTest.java     |  32 ++++-
 .../pages/major-changes-in-solr-9.adoc             |   8 ++
 .../apache/solr/common/cloud/ZkStateReader.java    |  25 ++--
 6 files changed, 253 insertions(+), 15 deletions(-)

diff --git a/solr/CHANGES.txt b/solr/CHANGES.txt
index 123bbfb63d3..7977e711944 100644
--- a/solr/CHANGES.txt
+++ b/solr/CHANGES.txt
@@ -42,6 +42,9 @@ Other Changes
 
 * SOLR-17620: SolrCloud "live_node" now has metadata: version of Solr, roles 
(Yuntong Qu, David Smiley)
 
+* SOLR-17879: A Solr node will now fail to start if it's major.minor version 
(e.g. 9.10) is *lower* than that of any existing
+  Solr node in a SolrCloud cluster (as reported by info in "live_node").  
(David Smiley)
+
 ==================  9.9.1 ==================
 Bug Fixes
 ---------------------
diff --git a/solr/core/src/java/org/apache/solr/cloud/ZkController.java 
b/solr/core/src/java/org/apache/solr/cloud/ZkController.java
index da81cc405c7..e75c6b64202 100644
--- a/solr/core/src/java/org/apache/solr/cloud/ZkController.java
+++ b/solr/core/src/java/org/apache/solr/cloud/ZkController.java
@@ -107,6 +107,7 @@ import org.apache.solr.common.params.CollectionAdminParams;
 import org.apache.solr.common.params.CommonParams;
 import org.apache.solr.common.params.SolrParams;
 import org.apache.solr.common.util.Compressor;
+import org.apache.solr.common.util.EnvUtils;
 import org.apache.solr.common.util.ExecutorUtil;
 import org.apache.solr.common.util.IOUtils;
 import org.apache.solr.common.util.ObjectReleaseTracker;
@@ -596,6 +597,53 @@ public class ZkController implements Closeable {
     }
   }
 
+  /**
+   * Checks version compatibility with other nodes in the cluster. Refuses to 
start if there's a
+   * major.minor version difference between our Solr version and other nodes 
in the cluster. Note:
+   * uses live nodes.
+   */
+  private void checkClusterVersionCompatibility() throws InterruptedException, 
KeeperException {
+    Optional<SolrVersion> lowestVersion = 
zkStateReader.fetchLowestSolrVersion();
+    if (lowestVersion.isPresent()) {
+      SolrVersion ourVersion = SolrVersion.LATEST;
+      SolrVersion clusterVersion = lowestVersion.get();
+
+      if (ourVersion.lessThan(clusterVersion)) {
+        log.warn(
+            "Our Solr version {} is older than cluster version {}", 
ourVersion, clusterVersion);
+
+        if (EnvUtils.getPropertyAsBool("solr.cloud.downgrade.enabled", false)) 
{
+          return;
+        }
+
+        // Check major version compatibility
+        if (ourVersion.getMajorVersion() < clusterVersion.getMajorVersion()) {
+          String message =
+              String.format(
+                  Locale.ROOT,
+                  "Refusing to start Solr, since our version is lower than the 
lowest version currently running in the cluster. "
+                      + "Our version: %s, lowest version in cluster: %s.",
+                  ourVersion,
+                  clusterVersion);
+          throw new SolrException(ErrorCode.INVALID_STATE, message);
+        }
+
+        // Check minor version compatibility within the same major version
+        if (ourVersion.getMajorVersion() == clusterVersion.getMajorVersion()
+            && ourVersion.getMinorVersion() < 
clusterVersion.getMinorVersion()) {
+          String message =
+              String.format(
+                  Locale.ROOT,
+                  "Refusing to start Solr, since our version is lower than the 
lowest version currently running in the cluster. "
+                      + "Our version: %s, lowest version in cluster: %s.",
+                  ourVersion,
+                  clusterVersion);
+          throw new SolrException(ErrorCode.INVALID_STATE, message);
+        }
+      }
+    }
+  }
+
   public CloudSolrClient getSolrClient() {
     return getSolrCloudManager().getSolrClient();
   }
@@ -983,6 +1031,7 @@ public class ZkController implements Closeable {
 
       checkForExistingEphemeralNode();
       registerLiveNodesListener();
+      checkClusterVersionCompatibility();
 
       // start the overseer first as following code may need it's processing
       if (!zkRunOnly) {
diff --git a/solr/core/src/test/org/apache/solr/cloud/ZkControllerTest.java 
b/solr/core/src/test/org/apache/solr/cloud/ZkControllerTest.java
index cf78c8ff47b..2d45de8ebb3 100644
--- a/solr/core/src/test/org/apache/solr/cloud/ZkControllerTest.java
+++ b/solr/core/src/test/org/apache/solr/cloud/ZkControllerTest.java
@@ -41,6 +41,7 @@ import org.apache.solr.SolrTestCaseJ4;
 import org.apache.solr.client.api.util.SolrVersion;
 import org.apache.solr.client.solrj.impl.Http2SolrClient;
 import org.apache.solr.common.MapWriter;
+import org.apache.solr.common.SolrException;
 import org.apache.solr.common.cloud.ClusterProperties;
 import org.apache.solr.common.cloud.ClusterState;
 import org.apache.solr.common.cloud.DocCollection;
@@ -66,6 +67,7 @@ import org.apache.solr.util.LogLevel;
 import org.apache.zookeeper.CreateMode;
 import org.apache.zookeeper.data.Stat;
 import org.hamcrest.Matchers;
+import org.junit.Ignore;
 import org.junit.Test;
 
 @SolrTestCaseJ4.SuppressSSL
@@ -516,6 +518,155 @@ public class ZkControllerTest extends SolrCloudTestCase {
     }
   }
 
+  @Test
+  @Ignore("Would need to disable ObjectReleaseTracker")
+  public void testVersionCompatibilityFailsStartup() throws Exception {
+    Path zkDir = createTempDir("testVersionCompatibilityFailsStartup");
+    ZkTestServer server = new ZkTestServer(zkDir);
+    try {
+      server.run();
+
+      // Manually create a live node with a high version (99.0.0) to simulate
+      // a newer cluster that the current node (SolrVersion.LATEST=10.0.0) 
cannot join
+      try (SolrZkClient zkClient =
+          new SolrZkClient.Builder()
+              .withUrl(server.getZkAddress())
+              .withTimeout(TIMEOUT, TimeUnit.MILLISECONDS)
+              .build()) {
+
+        // Create cluster nodes first
+        ZkController.createClusterZkNodes(zkClient);
+
+        String liveNodeName = "test_node:8983_solr";
+        String liveNodePath = ZkStateReader.LIVE_NODES_ZKNODE + "/" + 
liveNodeName;
+
+        // Create live node data with version 99.0.0
+        Map<String, Object> liveNodeData =
+            Map.of(LIVE_NODE_SOLR_VERSION, "99.0.0", LIVE_NODE_NODE_NAME, 
liveNodeName);
+        byte[] data = Utils.toJSON(liveNodeData);
+
+        // persistent since we're about to close this zkClient
+        zkClient.create(liveNodePath, data, CreateMode.PERSISTENT, true);
+      }
+
+      // Now try to create a ZkController - this should fail due to version 
incompatibility
+      CoreContainer cc = getCoreContainer();
+      try {
+        CloudConfig cloudConfig = new 
CloudConfig.CloudConfigBuilder("127.0.0.1", 8984).build();
+
+        SolrException exception =
+            expectThrows(
+                SolrException.class,
+                () -> {
+                  var zc = new ZkController(cc, server.getZkAddress(), 
TIMEOUT, cloudConfig);
+                  zc.close();
+                });
+
+        // Verify the exception is due to version incompatibility
+        assertEquals(
+            "Expected INVALID_STATE error code",
+            SolrException.ErrorCode.INVALID_STATE.code,
+            exception.code());
+        assertTrue(
+            "Exception message should mention refusing to start: " + 
exception.getMessage(),
+            exception.getMessage().contains("Refusing to start Solr"));
+        assertTrue(
+            "Exception message should mention minor version: " + 
exception.getMessage(),
+            exception.getMessage().contains("minor version"));
+        assertTrue(
+            "Exception message should mention our version: " + 
exception.getMessage(),
+            exception.getMessage().contains("10.0.0"));
+        assertTrue(
+            "Exception message should mention cluster version: " + 
exception.getMessage(),
+            exception.getMessage().contains("99.0.0"));
+      } finally {
+        cc.shutdown();
+      }
+    } finally {
+      server.shutdown();
+    }
+  }
+
+  @Ignore("Would need to disable ObjectReleaseTracker")
+  public void testMinorVersionCompatibilityFailsStartup() throws Exception {
+    Path zkDir = createTempDir("testMinorVersionCompatibilityFailsStartup");
+    ZkTestServer server = new ZkTestServer(zkDir);
+    try {
+      server.run();
+
+      // Create a higher minor version based on SolrVersion.LATEST for cluster 
simulation
+      SolrVersion currentVersion = SolrVersion.LATEST;
+      SolrVersion higherMinorVersion =
+          SolrVersion.forIntegers(
+              currentVersion.getMajorVersion(),
+              currentVersion.getMinorVersion() + 1,
+              currentVersion.getPatchVersion());
+
+      // Manually create a live node with a higher minor version to simulate
+      // a newer cluster that the current node (SolrVersion.LATEST) cannot join
+      try (SolrZkClient zkClient =
+          new SolrZkClient.Builder()
+              .withUrl(server.getZkAddress())
+              .withTimeout(TIMEOUT, TimeUnit.MILLISECONDS)
+              .build()) {
+
+        // Create cluster nodes first
+        ZkController.createClusterZkNodes(zkClient);
+
+        String liveNodeName = "test_node:8983_solr";
+        String liveNodePath = ZkStateReader.LIVE_NODES_ZKNODE + "/" + 
liveNodeName;
+
+        // Create live node data with higher minor version (same major, higher 
minor than LATEST)
+        Map<String, Object> liveNodeData =
+            Map.of(
+                LIVE_NODE_SOLR_VERSION,
+                higherMinorVersion.toString(),
+                LIVE_NODE_NODE_NAME,
+                liveNodeName);
+        byte[] data = Utils.toJSON(liveNodeData);
+
+        // persistent since we're about to close this zkClient
+        zkClient.create(liveNodePath, data, CreateMode.PERSISTENT, true);
+      }
+
+      // Now try to create a ZkController - this should fail due to minor 
version incompatibility
+      CoreContainer cc = getCoreContainer();
+      try {
+        CloudConfig cloudConfig = new 
CloudConfig.CloudConfigBuilder("127.0.0.1", 8984).build();
+
+        SolrException exception =
+            expectThrows(
+                SolrException.class,
+                () -> {
+                  var zc = new ZkController(cc, server.getZkAddress(), 
TIMEOUT, cloudConfig);
+                  zc.close();
+                });
+
+        // Verify the exception is due to minor version incompatibility
+        assertEquals(
+            "Expected INVALID_STATE error code",
+            SolrException.ErrorCode.INVALID_STATE.code,
+            exception.code());
+        assertTrue(
+            "Exception message should mention refusing to start: " + 
exception.getMessage(),
+            exception.getMessage().contains("Refusing to start Solr"));
+        assertTrue(
+            "Exception message should mention minor version: " + 
exception.getMessage(),
+            exception.getMessage().contains("minor version"));
+        assertTrue(
+            "Exception message should mention our version: " + 
exception.getMessage(),
+            exception.getMessage().contains(currentVersion.toString()));
+        assertTrue(
+            "Exception message should mention cluster version: " + 
exception.getMessage(),
+            exception.getMessage().contains(higherMinorVersion.toString()));
+      } finally {
+        cc.shutdown();
+      }
+    } finally {
+      server.shutdown();
+    }
+  }
+
   public void testCheckNoOldClusterstate() throws Exception {
     Path zkDir = createTempDir("testCheckNoOldClusterstate");
     ZkTestServer server = new ZkTestServer(zkDir);
diff --git 
a/solr/core/src/test/org/apache/solr/cloud/overseer/ZkStateReaderTest.java 
b/solr/core/src/test/org/apache/solr/cloud/overseer/ZkStateReaderTest.java
index b9523c9b2d9..cb07e84f5b5 100644
--- a/solr/core/src/test/org/apache/solr/cloud/overseer/ZkStateReaderTest.java
+++ b/solr/core/src/test/org/apache/solr/cloud/overseer/ZkStateReaderTest.java
@@ -923,8 +923,9 @@ public class ZkStateReaderTest extends SolrTestCaseJ4 {
     zkClient.create(livePath + "/" + node2, data2, CreateMode.EPHEMERAL, true);
 
     var lowestVersion = reader.fetchLowestSolrVersion();
+    assertTrue("Expected lowest version to be present", 
lowestVersion.isPresent());
     assertEquals(
-        "Expected lowest version to be 9.0.1", SolrVersion.valueOf("9.0.1"), 
lowestVersion);
+        "Expected lowest version to be 9.0.1", SolrVersion.valueOf("9.0.1"), 
lowestVersion.get());
   }
 
   /** Test when the only live node has empty data. */
@@ -943,7 +944,9 @@ public class ZkStateReaderTest extends SolrTestCaseJ4 {
     String emptyNode = "empty_node";
     zkClient.create(livePath + "/" + emptyNode, new byte[0], 
CreateMode.EPHEMERAL, true);
 
-    assertEquals("after empty node", SolrVersion.valueOf("9.9.0"), 
reader.fetchLowestSolrVersion());
+    var lowestVersion = reader.fetchLowestSolrVersion();
+    assertTrue("Expected lowest version to be present for empty node", 
lowestVersion.isPresent());
+    assertEquals("after empty node", SolrVersion.valueOf("9.9.0"), 
lowestVersion.get());
   }
 
   /** Test when two live nodes exist; one is blank and the other has a high 
version */
@@ -965,11 +968,32 @@ public class ZkStateReaderTest extends SolrTestCaseJ4 {
         CreateMode.EPHEMERAL,
         true);
 
-    assertEquals("after high node", SolrVersion.LATEST, 
reader.fetchLowestSolrVersion());
+    var lowestVersion1 = reader.fetchLowestSolrVersion();
+    assertTrue(
+        "Expected lowest version to be present for high version node", 
lowestVersion1.isPresent());
+    assertEquals("after high node", SolrVersion.valueOf("888.0.0"), 
lowestVersion1.get());
 
     String node2 = "node2_solr";
     zkClient.create(livePath + "/" + node2, new byte[0], CreateMode.EPHEMERAL, 
true);
 
-    assertEquals("after empty node", SolrVersion.valueOf("9.9.0"), 
reader.fetchLowestSolrVersion());
+    var lowestVersion2 = reader.fetchLowestSolrVersion();
+    assertTrue("Expected lowest version to be present for empty node", 
lowestVersion2.isPresent());
+    assertEquals("after empty node", SolrVersion.valueOf("9.9.0"), 
lowestVersion2.get());
+  }
+
+  /** Test when no live nodes exist - should return empty Optional */
+  public void testFetchLowestSolrVersion_noLiveNodes() throws Exception {
+    SolrZkClient zkClient = fixture.zkClient;
+    ZkStateReader reader = fixture.reader;
+    String livePath = ZkStateReader.LIVE_NODES_ZKNODE;
+
+    // Clear any existing live node children.
+    List<String> nodes = zkClient.getChildren(livePath, null, true);
+    for (String node : nodes) {
+      zkClient.delete(livePath + "/" + node, -1, true);
+    }
+
+    var lowestVersion = reader.fetchLowestSolrVersion();
+    assertFalse("Expected no lowest version when no live nodes exist", 
lowestVersion.isPresent());
   }
 }
diff --git 
a/solr/solr-ref-guide/modules/upgrade-notes/pages/major-changes-in-solr-9.adoc 
b/solr/solr-ref-guide/modules/upgrade-notes/pages/major-changes-in-solr-9.adoc
index a220a63b8f8..9f0e1d127c8 100644
--- 
a/solr/solr-ref-guide/modules/upgrade-notes/pages/major-changes-in-solr-9.adoc
+++ 
b/solr/solr-ref-guide/modules/upgrade-notes/pages/major-changes-in-solr-9.adoc
@@ -28,6 +28,14 @@ You should also consider all changes that have been made to 
Solr in any version
 
 A thorough review of the list in 
xref:major-changes-in-earlier-8-x-versions[Major Changes in Earlier 8.x 
Versions] as well as the {solr-javadocs}/changes/Changes.html[CHANGES.txt] in 
your Solr instance will help you plan your migration to Solr 9.
 
+There is a new limitation introduced in Solr 9.10, and that which is 
especially relevant to Solr 10
+and beyond.
+A Solr node will now fail to start if it's major.minor version (e.g. 9.10) is 
*lower* than that of any existing Solr node in a SolrCloud cluster (as reported 
by info in "live_node").
+What this
+means is that Solr supports rolling _upgrades_ but not rolling _downgrades_ 
spanning a major version.
+This compatibility safeguard can be toggled with 
"SOLR_CLOUD_DOWNGRADE_ENABLED".
+
+
 === Upgrade Prerequisites
 
 *Solr 9 requires Java 11 as minimum Java version and is also tested with Java 
17.*
diff --git 
a/solr/solrj-zookeeper/src/java/org/apache/solr/common/cloud/ZkStateReader.java 
b/solr/solrj-zookeeper/src/java/org/apache/solr/common/cloud/ZkStateReader.java
index 3bff3ea21ca..24b9b1d537c 100644
--- 
a/solr/solrj-zookeeper/src/java/org/apache/solr/common/cloud/ZkStateReader.java
+++ 
b/solr/solrj-zookeeper/src/java/org/apache/solr/common/cloud/ZkStateReader.java
@@ -31,6 +31,7 @@ import java.util.List;
 import java.util.Map;
 import java.util.Map.Entry;
 import java.util.Objects;
+import java.util.Optional;
 import java.util.Set;
 import java.util.SortedSet;
 import java.util.TreeSet;
@@ -866,23 +867,25 @@ public class ZkStateReader implements SolrCloseable {
   }
 
   /**
-   * Returns the lowest Solr version among all live nodes in the cluster. It's 
not greater than
-   * {@link SolrVersion#LATEST_STRING}. Will not return null. If older Solr 
nodes have joined that
-   * don't declare their version, the result won't be accurate, but it's at 
least an upper bound on
-   * the possible version it might be.
+   * Returns the lowest Solr version among all live nodes in the cluster. If 
older Solr nodes have
+   * joined that don't declare their version, the result won't be accurate, 
but it's at least an
+   * upper bound on the possible version it might be.
    *
-   * @return the lowest Solr version of the cluster; not null
+   * @return an Optional containing the lowest Solr version of nodes in the 
cluster, or empty if no
+   *     live nodes exist or all nodes return 9.9.0 for unspecified versions
    */
-  public SolrVersion fetchLowestSolrVersion() throws KeeperException, 
InterruptedException {
+  public Optional<SolrVersion> fetchLowestSolrVersion()
+      throws KeeperException, InterruptedException {
     List<String> liveNodeNames = zkClient.getChildren(LIVE_NODES_ZKNODE, null, 
true);
-    SolrVersion lowest = SolrVersion.LATEST; // current software
+    SolrVersion lowest = null;
     // the last version to not specify its version in live nodes
     final SolrVersion UNSPECIFIED_VERSION = SolrVersion.valueOf("9.9.0");
+
     for (String nodeName : liveNodeNames) {
       String path = LIVE_NODES_ZKNODE + "/" + nodeName;
       byte[] data = zkClient.getData(path, null, null, true);
       if (data == null || data.length == 0) {
-        return UNSPECIFIED_VERSION;
+        return Optional.of(UNSPECIFIED_VERSION);
       }
 
       @SuppressWarnings("unchecked")
@@ -890,14 +893,14 @@ public class ZkStateReader implements SolrCloseable {
       String nodeVersionStr = (String) props.get(LIVE_NODE_SOLR_VERSION);
       if (nodeVersionStr == null) { // weird
         log.warn("No Solr version found: {}", props);
-        return UNSPECIFIED_VERSION;
+        return Optional.of(UNSPECIFIED_VERSION);
       }
       SolrVersion nodeVersion = SolrVersion.valueOf(nodeVersionStr);
-      if (nodeVersion.compareTo(lowest) < 0) {
+      if (lowest == null || nodeVersion.compareTo(lowest) < 0) {
         lowest = nodeVersion;
       }
     }
-    return lowest;
+    return Optional.ofNullable(lowest);
   }
 
   /**

Reply via email to