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

gerlowskija 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 daa4b301350 SOLR-18569: Cleanup ephem /node_roles/... on process exit 
(#5237)
daa4b301350 is described below

commit daa4b3013503d8ae628b118e9c2a763a58751499
Author: Jason Gerlowski <[email protected]>
AuthorDate: Fri Oct 30 11:26:16 2026 -0400

    SOLR-18569: Cleanup ephem /node_roles/... on process exit (#5237)
    
    Co-authored-by: Nick Shanin <[email protected]>
---
 ...8569-wait-for-node-role-deletion-on-startup.yml |   9 ++
 .../java/org/apache/solr/cloud/ZkController.java   |  96 ++++++++++++++-----
 .../org/apache/solr/cloud/ZkControllerTest.java    | 105 +++++++++++++++++++++
 3 files changed, 184 insertions(+), 26 deletions(-)

diff --git 
a/changelog/unreleased/SOLR-18569-wait-for-node-role-deletion-on-startup.yml 
b/changelog/unreleased/SOLR-18569-wait-for-node-role-deletion-on-startup.yml
new file mode 100644
index 00000000000..686f6fb8c81
--- /dev/null
+++ b/changelog/unreleased/SOLR-18569-wait-for-node-role-deletion-on-startup.yml
@@ -0,0 +1,9 @@
+title: >
+  Solr startup no longer hangs if ephemeral "node-role" znodes from a previous 
process are still present.
+type: fixed
+authors:
+  - name: Jason Gerlowski
+  - name: Nick Shanin
+links:
+  - name: SOLR-18569
+    url: https://issues.apache.org/jira/browse/SOLR-18569
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 72467e0fc51..05157a55f4c 100644
--- a/solr/core/src/java/org/apache/solr/cloud/ZkController.java
+++ b/solr/core/src/java/org/apache/solr/cloud/ZkController.java
@@ -1092,43 +1092,74 @@ public class ZkController implements Closeable {
     }
   }
 
-  private void checkForExistingEphemeralNode() throws KeeperException, 
InterruptedException {
+  /**
+   * Waits for any ephemeral znodes left by a previous process with this node 
name to disappear.
+   *
+   * <p>Covers both the {@code /live_nodes} entry and this node's entries 
under any {@code
+   * /node_roles/<role>/<mode>}. Throws if any are still present after twice 
the ZK session timeout.
+   *
+   * @throws SolrException if ephemeral nodes were not all removed by the end 
of the waiting period
+   */
+  // package-private for testing
+  void checkForExistingEphemeralNode() throws KeeperException, 
InterruptedException {
     if (zkRunOnly) {
       return;
     }
     String nodeName = getNodeName();
-    String nodePath = ZkStateReader.LIVE_NODES_ZKNODE + "/" + nodeName;
 
-    if (!zkClient.exists(nodePath, true)) {
-      return;
+    List<String> candidatePaths = new ArrayList<>();
+    candidatePaths.add(ZkStateReader.LIVE_NODES_ZKNODE + "/" + nodeName);
+    for (NodeRoles.Role role : NodeRoles.Role.values()) {
+      for (String mode : role.supportedModes()) {
+        candidatePaths.add(NodeRoles.getZNodeForRoleMode(role, mode) + "/" + 
nodeName);
+      }
     }
 
-    final CountDownLatch deletedLatch = new CountDownLatch(1);
-    Stat stat =
-        zkClient.exists(
-            nodePath,
-            event -> {
-              if (Watcher.Event.EventType.None.equals(event.getType())) {
-                return;
-              }
-              if (Watcher.Event.EventType.NodeDeleted.equals(event.getType())) 
{
-                deletedLatch.countDown();
-              }
-            },
-            true);
+    // Set a deletion watch on each znode that exists; the latch counts down 
as each is deleted
+    final List<String> watchedPaths = new ArrayList<>();
+    final List<CountDownLatch> latches = new ArrayList<>();
+    for (String path : candidatePaths) {
+      if (!zkClient.exists(path, true)) {
+        continue;
+      }
+      final CountDownLatch deletedLatch = new CountDownLatch(1);
+      Stat stat =
+          zkClient.exists(
+              path,
+              event -> {
+                if (Watcher.Event.EventType.None.equals(event.getType())) {
+                  return;
+                }
+                if 
(Watcher.Event.EventType.NodeDeleted.equals(event.getType())) {
+                  deletedLatch.countDown();
+                }
+              },
+              true);
+      if (stat == null) {
+        // znode suddenly disappeared but that's okay
+        continue;
+      }
+      watchedPaths.add(path);
+      latches.add(deletedLatch);
+    }
 
-    if (stat == null) {
-      // znode suddenly disappeared but that's okay
+    if (watchedPaths.isEmpty()) {
       return;
     }
 
-    boolean deleted =
-        deletedLatch.await(zkClient.getZooKeeper().getSessionTimeout() * 2L, 
TimeUnit.MILLISECONDS);
-    if (!deleted) {
-      throw new SolrException(
-          ErrorCode.SERVER_ERROR,
-          "A previous ephemeral live node still exists. "
-              + "Solr cannot continue. Please ensure that no other Solr 
process using the same port is running already.");
+    // A single overall deadline, shared by all of the znodes
+    final long deadlineNanos =
+        System.nanoTime()
+            + 
TimeUnit.MILLISECONDS.toNanos(zkClient.getZooKeeper().getSessionTimeout() * 2L);
+    for (int i = 0; i < latches.size(); i++) {
+      long remainingNanos = deadlineNanos - System.nanoTime();
+      if (!latches.get(i).await(Math.max(remainingNanos, 0), 
TimeUnit.NANOSECONDS)) {
+        throw new SolrException(
+            ErrorCode.SERVER_ERROR,
+            "An ephemeral node from a previous process representing this Solr 
instance still exists: "
+                + watchedPaths.get(i)
+                + ". Solr cannot continue. Please ensure that no other Solr 
process using the same port is running already.");
+      }
     }
   }
 
@@ -1289,6 +1320,19 @@ public class ZkController implements Closeable {
     } catch (NoNodeException e) {
 
     }
+    cc.nodeRoles
+        .getRoles()
+        .forEach(
+            (role, mode) -> {
+              try {
+                zkClient.delete(
+                    NodeRoles.getZNodeForRoleMode(role, mode) + "/" + 
nodeName, -1, true);
+              } catch (KeeperException e) {
+
+              } catch (InterruptedException e) {
+                Thread.currentThread().interrupt();
+              }
+            });
   }
 
   public String getNodeName() {
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 2d45de8ebb3..6599a12a0bd 100644
--- a/solr/core/src/test/org/apache/solr/cloud/ZkControllerTest.java
+++ b/solr/core/src/test/org/apache/solr/cloud/ZkControllerTest.java
@@ -56,6 +56,7 @@ import org.apache.solr.common.util.Utils;
 import org.apache.solr.core.CloudConfig;
 import org.apache.solr.core.CoreContainer;
 import org.apache.solr.core.CoreDescriptor;
+import org.apache.solr.core.NodeRoles;
 import org.apache.solr.core.PluginInfo;
 import org.apache.solr.core.SolrXmlConfig;
 import org.apache.solr.handler.admin.CoreAdminHandler;
@@ -255,6 +256,110 @@ public class ZkControllerTest extends SolrCloudTestCase {
     }
   }
 
+  @Test
+  public void testStartupWaitsForLeftoverEphemeralNodesToDisappear() throws 
Exception {
+    final String staleRoleEntry = 
ephemeralNodeRolePath(NodeRoles.Role.OVERSEER);
+    runWithLeftoverEphemeralNodes(
+        "testStartupWaitsForLeftoverEphemeralNodes",
+        staleRoleEntry,
+        (zkController, otherClient, leftoverPaths) -> {
+          // Remove the leftovers shortly after the check starts waiting on 
them
+          AtomicReference<Exception> deleterFailure = new AtomicReference<>();
+          Thread deleter =
+              new Thread(
+                  () -> {
+                    try {
+                      Thread.sleep(1000);
+                      for (String path : leftoverPaths) {
+                        otherClient.delete(path, -1, true);
+                      }
+                    } catch (Exception e) {
+                      deleterFailure.set(e);
+                    }
+                  });
+          deleter.start();
+          try {
+            // Returns normally once everything is gone
+            zkController.checkForExistingEphemeralNode();
+            for (String path : leftoverPaths) {
+              assertFalse(
+                  path + " should be gone when the check returns", 
otherClient.exists(path, true));
+            }
+          } finally {
+            deleter.join(TimeUnit.SECONDS.toMillis(30));
+          }
+          assertFalse("Deleter thread did not finish", deleter.isAlive());
+          assertNull("Deleter thread failed", deleterFailure.get());
+        });
+  }
+
+  @Test
+  public void testStartupFailsIfLeftoverEphemeralNodeNeverDisappears() throws 
Exception {
+    final String staleRoleEntry = 
ephemeralNodeRolePath(NodeRoles.Role.OVERSEER);
+    runWithLeftoverEphemeralNodes(
+        "testStartupFailsIfLeftoverEphemeralNode",
+        staleRoleEntry,
+        (zkController, otherClient, leftoverPaths) -> {
+          // Only the role entry is left behind, so the message must name it 
specifically
+          SolrException e =
+              expectThrows(SolrException.class, 
zkController::checkForExistingEphemeralNode);
+          assertEquals(SolrException.ErrorCode.SERVER_ERROR.code, e.code());
+          assertTrue(
+              "Message should name the leftover path: " + e.getMessage(),
+              e.getMessage().contains(staleRoleEntry));
+        });
+  }
+
+  /** The path of this test node's entry under a mode of 'role' that the node 
isn't using itself */
+  private static String ephemeralNodeRolePath(NodeRoles.Role role) {
+    String unusedMode = NodeRoles.MODE_PREFERRED; // default roles use 
'allowed'
+    assertTrue(role.supportedModes().contains(unusedMode));
+    return NodeRoles.getZNodeForRoleMode(role, unusedMode) + "/" + 
TEST_NODE_NAME;
+  }
+
+  private static final String TEST_NODE_NAME = "127.0.0.1:8983_solr";
+
+  @FunctionalInterface
+  private interface LeftoverNodesCheck {
+    void run(ZkController zkController, SolrZkClient otherClient, List<String> 
leftoverPaths)
+        throws Exception;
+  }
+
+  /**
+   * Starts a ZkController, removes its own ephemeral entries, then has a 
different ZK client create
+   * the given ephemeral paths (simulating a previous process with the same 
node name that hasn't
+   * fully gone away) before running 'check'.
+   */
+  private void runWithLeftoverEphemeralNodes(
+      String dirName, String leftoverPath, LeftoverNodesCheck check) throws 
Exception {
+    ZkTestServer server = new ZkTestServer(createTempDir(dirName));
+    try {
+      server.run();
+      CoreContainer cc = getCoreContainer();
+      // Short session timeout, as the check waits for twice the session 
timeout
+      CloudConfig cloudConfig =
+          new CloudConfig.CloudConfigBuilder("127.0.0.1", 8983, "solr")
+              .setZkClientTimeout(3000)
+              .build();
+      ZkController zkController = new ZkController(cc, server.getZkAddress(), 
3000, cloudConfig);
+      try (SolrZkClient otherClient =
+          new SolrZkClient.Builder()
+              .withUrl(server.getZkAddress())
+              .withTimeout(TIMEOUT, TimeUnit.MILLISECONDS)
+              .build()) {
+        assertEquals(TEST_NODE_NAME, zkController.getNodeName());
+        zkController.removeEphemeralLiveNode();
+        otherClient.create(leftoverPath, null, CreateMode.EPHEMERAL, true);
+        check.run(zkController, otherClient, List.of(leftoverPath));
+      } finally {
+        zkController.close();
+        cc.shutdown();
+      }
+    } finally {
+      server.shutdown();
+    }
+  }
+
   @LogLevel(value = 
"org.apache.solr.cloud=DEBUG;org.apache.solr.cloud.overseer=DEBUG")
   @Test
   public void testPublishAndWaitForDownStates() throws Exception {

Reply via email to