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

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

commit edc5da188a9ea28745b13c3cda9106dc1b4271a0
Author: Nick <[email protected]>
AuthorDate: Mon Oct 5 04:46:06 2026 -0600

    SOLR-18277: Restore parent shard state on failed split (#4959)
    
    (cherry picked from commit 0cc328310f8f4162572ea5bd5256cea29543e261)
---
 changelog/unreleased/SOLR-18277.yml                |   7 +
 .../solr/cloud/api/collections/SplitShardCmd.java  |  95 +++++-----
 .../solr/cloud/api/collections/ShardSplitTest.java |   2 -
 .../api/collections/SplitShardCmdCleanupTest.java  | 207 +++++++++++++++++++++
 4 files changed, 256 insertions(+), 55 deletions(-)

diff --git a/changelog/unreleased/SOLR-18277.yml 
b/changelog/unreleased/SOLR-18277.yml
new file mode 100644
index 00000000000..9c870578aca
--- /dev/null
+++ b/changelog/unreleased/SOLR-18277.yml
@@ -0,0 +1,7 @@
+title: Restore the parent shard to ACTIVE after a failed split even if cluster 
state is stale.
+type: fixed
+authors:
+  - name: Nick Shanin
+links:
+  - name: SOLR-18277
+    url: https://issues.apache.org/jira/browse/SOLR-18277
diff --git 
a/solr/core/src/java/org/apache/solr/cloud/api/collections/SplitShardCmd.java 
b/solr/core/src/java/org/apache/solr/cloud/api/collections/SplitShardCmd.java
index 4787e81734c..9bdf11435a6 100644
--- 
a/solr/core/src/java/org/apache/solr/cloud/api/collections/SplitShardCmd.java
+++ 
b/solr/core/src/java/org/apache/solr/cloud/api/collections/SplitShardCmd.java
@@ -33,11 +33,9 @@ import java.util.ArrayList;
 import java.util.Collection;
 import java.util.Collections;
 import java.util.HashMap;
-import java.util.HashSet;
 import java.util.List;
 import java.util.Map;
 import java.util.NoSuchElementException;
-import java.util.Set;
 import java.util.concurrent.TimeUnit;
 import java.util.concurrent.atomic.AtomicReference;
 import org.apache.solr.client.solrj.cloud.DistribStateManager;
@@ -164,7 +162,6 @@ public class SplitShardCmd implements 
CollApiCmds.CollectionApiCommand {
     zkStateReader.forceUpdateCollection(collectionName);
     AtomicReference<String> slice = new AtomicReference<>();
     slice.set(message.getStr(ZkStateReader.SHARD_ID_PROP));
-    Set<String> offlineSlices = new HashSet<>();
     RTimerTree timings = new RTimerTree();
     ClusterState clusterState = zkStateReader.getClusterState();
 
@@ -830,12 +827,7 @@ public class SplitShardCmd implements 
CollApiCmds.CollectionApiCommand {
     } finally {
       if (!success) {
         cleanupAfterFailure(
-            adminCmdContext,
-            zkStateReader,
-            collectionName,
-            parentSlice.getName(),
-            subSlices,
-            offlineSlices);
+            adminCmdContext, zkStateReader, collectionName, 
parentSlice.getName(), subSlices);
         unlockForSplit(ccc.getSolrCloudManager(), collectionName, 
parentSlice.getName());
       }
     }
@@ -939,8 +931,7 @@ public class SplitShardCmd implements 
CollApiCmds.CollectionApiCommand {
       ZkStateReader zkStateReader,
       String collectionName,
       String parentShard,
-      List<String> subSlices,
-      Set<String> offlineSlices) {
+      List<String> subSlices) {
     log.info("Cleaning up after a failed split of {}/{}", collectionName, 
parentShard);
     // get the latest state
     try {
@@ -960,48 +951,8 @@ public class SplitShardCmd implements 
CollApiCmds.CollectionApiCommand {
       return;
     }
 
-    // If parent is inactive and all sub shards are active, then rolling back
-    // to make the parent active again will cause data loss.
-    if (coll.getSlice(parentShard).getState() == Slice.State.INACTIVE) {
-      boolean allSubSlicesActive = true;
-      for (String sub : subSlices) {
-        if (coll.getSlice(sub).getState() != Slice.State.ACTIVE) {
-          allSubSlicesActive = false;
-          break;
-        }
-      }
-      if (allSubSlicesActive) {
-        return;
-      }
-    }
-
-    // set already created sub shards states to CONSTRUCTION - this prevents 
them
-    // from entering into RECOVERY or ACTIVE (SOLR-9455)
-    final Map<String, Object> propMap = new HashMap<>();
-    boolean sendUpdateState = false;
-    propMap.put(Overseer.QUEUE_OPERATION, 
OverseerAction.UPDATESHARDSTATE.toLower());
-    propMap.put(ZkStateReader.COLLECTION_PROP, collectionName);
-    for (Slice s : coll.getSlices()) {
-      if (!subSlices.contains(s.getName())) {
-        continue;
-      }
-      propMap.put(s.getName(), Slice.State.CONSTRUCTION.toString());
-      sendUpdateState = true;
-    }
-
-    // if parent is inactive activate it again
-    Slice parentSlice = coll.getSlice(parentShard);
-    if (parentSlice.getState() == Slice.State.INACTIVE) {
-      sendUpdateState = true;
-      propMap.put(parentShard, Slice.State.ACTIVE.toString());
-    }
-    // plus any other previously deactivated slices
-    for (String sliceName : offlineSlices) {
-      propMap.put(sliceName, Slice.State.ACTIVE.toString());
-      sendUpdateState = true;
-    }
-
-    if (sendUpdateState) {
+    Map<String, Object> propMap = buildCleanupShardStateUpdates(coll, 
parentShard, subSlices);
+    if (propMap != null) {
       try {
         ZkNodeProps m = new ZkNodeProps(propMap);
         if 
(ccc.getDistributedClusterStateUpdater().isDistributedStateUpdate()) {
@@ -1053,6 +1004,44 @@ public class SplitShardCmd implements 
CollApiCmds.CollectionApiCommand {
     }
   }
 
+  /**
+   * Returns the changes needed to undo a failed split, or {@code null} if the 
split has already
+   * finished. When it returns changes, they set the parent shard to {@code 
ACTIVE}, even if the
+   * cluster state has changed since it was read.
+   */
+  static Map<String, Object> buildCleanupShardStateUpdates(
+      DocCollection coll, String parentShard, List<String> subSlices) {
+    // If parent is inactive and all sub shards are active, then rolling back
+    // to make the parent active again will cause data loss.
+    if (coll.getSlice(parentShard).getState() == Slice.State.INACTIVE) {
+      boolean allSubSlicesActive = true;
+      for (String sub : subSlices) {
+        if (coll.getSlice(sub).getState() != Slice.State.ACTIVE) {
+          allSubSlicesActive = false;
+          break;
+        }
+      }
+      if (allSubSlicesActive) {
+        return null;
+      }
+    }
+
+    // set already created sub shards states to CONSTRUCTION - this prevents 
them
+    // from entering into RECOVERY or ACTIVE (SOLR-9455)
+    final Map<String, Object> propMap = new HashMap<>();
+    propMap.put(Overseer.QUEUE_OPERATION, 
OverseerAction.UPDATESHARDSTATE.toLower());
+    propMap.put(ZkStateReader.COLLECTION_PROP, coll.getName());
+    for (Slice s : coll.getSlices()) {
+      if (!subSlices.contains(s.getName())) {
+        continue;
+      }
+      propMap.put(s.getName(), Slice.State.CONSTRUCTION.toString());
+    }
+    // Always pin parent ACTIVE; a stale snapshot can miss an in-flight 
INACTIVE switch.
+    propMap.put(parentShard, Slice.State.ACTIVE.toString());
+    return propMap;
+  }
+
   public static Slice getParentSlice(
       ClusterState clusterState,
       String collectionName,
diff --git 
a/solr/core/src/test/org/apache/solr/cloud/api/collections/ShardSplitTest.java 
b/solr/core/src/test/org/apache/solr/cloud/api/collections/ShardSplitTest.java
index a366a849a42..d421e793cff 100644
--- 
a/solr/core/src/test/org/apache/solr/cloud/api/collections/ShardSplitTest.java
+++ 
b/solr/core/src/test/org/apache/solr/cloud/api/collections/ShardSplitTest.java
@@ -328,7 +328,6 @@ public class ShardSplitTest extends BasicDistributedZkTest {
    * <p>See SOLR-9439
    */
   @Test
-  @AwaitsFix(bugUrl = "https://issues.apache.org/jira/browse/SOLR-18277";)
   public void testSplitAfterFailedSplit() throws Exception {
     waitForThingsToLevelOut(15, TimeUnit.SECONDS);
 
@@ -384,7 +383,6 @@ public class ShardSplitTest extends BasicDistributedZkTest {
 
   @Test
   @Nightly
-  @AwaitsFix(bugUrl = "https://issues.apache.org/jira/browse/SOLR-18277";)
   public void testSplitAfterFailedSplit2() throws Exception {
     waitForThingsToLevelOut(15, TimeUnit.SECONDS);
 
diff --git 
a/solr/core/src/test/org/apache/solr/cloud/api/collections/SplitShardCmdCleanupTest.java
 
b/solr/core/src/test/org/apache/solr/cloud/api/collections/SplitShardCmdCleanupTest.java
new file mode 100644
index 00000000000..f399e9501ed
--- /dev/null
+++ 
b/solr/core/src/test/org/apache/solr/cloud/api/collections/SplitShardCmdCleanupTest.java
@@ -0,0 +1,207 @@
+/*
+ * 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.solr.cloud.api.collections;
+
+import static org.apache.solr.SolrTestCaseJ4.assumeWorkingMockito;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+import java.time.Instant;
+import java.util.HashMap;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import org.apache.solr.SolrTestCase;
+import org.apache.solr.client.solrj.cloud.DistribStateManager;
+import org.apache.solr.client.solrj.cloud.SolrCloudManager;
+import org.apache.solr.cloud.Overseer;
+import org.apache.solr.cloud.overseer.OverseerAction;
+import org.apache.solr.cloud.overseer.SliceMutator;
+import org.apache.solr.cloud.overseer.ZkWriteCommand;
+import org.apache.solr.common.cloud.ClusterState;
+import org.apache.solr.common.cloud.DocCollection;
+import org.apache.solr.common.cloud.DocRouter;
+import org.apache.solr.common.cloud.Slice;
+import org.apache.solr.common.cloud.ZkNodeProps;
+import org.apache.solr.common.cloud.ZkStateReader;
+import org.apache.solr.common.util.TimeSource;
+import org.apache.solr.handler.admin.ConfigSetsHandler;
+import org.junit.BeforeClass;
+import org.junit.Test;
+
+/** In-memory coverage for {@link SplitShardCmd} failed-split state rollback. 
*/
+public class SplitShardCmdCleanupTest extends SolrTestCase {
+
+  private static final String COLLECTION = "collection1";
+  private static final String PARENT = "shard1";
+  private static final String CHILD_0 = "shard1_0";
+  private static final String CHILD_1 = "shard1_1";
+  private static final List<String> CHILDREN = List.of(CHILD_0, CHILD_1);
+
+  @BeforeClass
+  public static void beforeClass() {
+    assumeWorkingMockito();
+  }
+
+  @Test
+  public void testCleanupIncludesParentWhenSnapshotStillShowsActive() {
+    DocCollection coll =
+        collection(
+            slice(PARENT, Slice.State.ACTIVE),
+            slice(CHILD_0, Slice.State.CONSTRUCTION),
+            slice(CHILD_1, Slice.State.RECOVERY));
+
+    Map<String, Object> updates =
+        SplitShardCmd.buildCleanupShardStateUpdates(coll, PARENT, CHILDREN);
+
+    assertNotNull(updates);
+    assertEquals(Slice.State.ACTIVE.toString(), updates.get(PARENT));
+    assertEquals(Slice.State.CONSTRUCTION.toString(), updates.get(CHILD_0));
+    assertEquals(Slice.State.CONSTRUCTION.toString(), updates.get(CHILD_1));
+  }
+
+  @Test
+  public void testCleanupRestoresInactiveParentWhenChildrenAreNotAllActive() {
+    DocCollection coll =
+        collection(
+            slice(PARENT, Slice.State.INACTIVE),
+            slice(CHILD_0, Slice.State.ACTIVE),
+            slice(CHILD_1, Slice.State.CONSTRUCTION));
+
+    Map<String, Object> updates =
+        SplitShardCmd.buildCleanupShardStateUpdates(coll, PARENT, CHILDREN);
+
+    assertNotNull(updates);
+    assertEquals(Slice.State.ACTIVE.toString(), updates.get(PARENT));
+    assertEquals(Slice.State.CONSTRUCTION.toString(), updates.get(CHILD_0));
+    assertEquals(Slice.State.CONSTRUCTION.toString(), updates.get(CHILD_1));
+  }
+
+  @Test
+  public void testCleanupSkipsCommittedSwitchOver() {
+    DocCollection coll =
+        collection(
+            slice(PARENT, Slice.State.INACTIVE),
+            slice(CHILD_0, Slice.State.ACTIVE),
+            slice(CHILD_1, Slice.State.ACTIVE));
+
+    assertNull(SplitShardCmd.buildCleanupShardStateUpdates(coll, PARENT, 
CHILDREN));
+  }
+
+  @Test
+  public void testStaleCleanupWithoutParentLeavesParentInactive() {
+    ClusterState afterSuccess = apply(initialState(), successSwitch());
+    assertSame(
+        Slice.State.INACTIVE, 
afterSuccess.getCollection(COLLECTION).getSlice(PARENT).getState());
+    assertSame(
+        Slice.State.ACTIVE, 
afterSuccess.getCollection(COLLECTION).getSlice(CHILD_0).getState());
+
+    ClusterState afterStaleCleanup = apply(afterSuccess, 
staleCleanupWithoutParent());
+    assertSame(
+        "omitting parent=active after the success switch is the race",
+        Slice.State.INACTIVE,
+        
afterStaleCleanup.getCollection(COLLECTION).getSlice(PARENT).getState());
+    assertSame(
+        Slice.State.CONSTRUCTION,
+        
afterStaleCleanup.getCollection(COLLECTION).getSlice(CHILD_0).getState());
+  }
+
+  @Test
+  public void testFixedCleanupRestoresParentAfterQueuedSuccessSwitch() {
+    DocCollection staleSnapshot = initialState().getCollection(COLLECTION);
+    Map<String, Object> cleanup =
+        SplitShardCmd.buildCleanupShardStateUpdates(staleSnapshot, PARENT, 
CHILDREN);
+    assertNotNull(cleanup);
+    assertEquals(Slice.State.ACTIVE.toString(), cleanup.get(PARENT));
+
+    ClusterState afterSuccess = apply(initialState(), successSwitch());
+    ClusterState afterCleanup = apply(afterSuccess, cleanup);
+
+    assertSame(
+        Slice.State.ACTIVE, 
afterCleanup.getCollection(COLLECTION).getSlice(PARENT).getState());
+    assertSame(
+        Slice.State.CONSTRUCTION,
+        afterCleanup.getCollection(COLLECTION).getSlice(CHILD_0).getState());
+    assertSame(
+        Slice.State.CONSTRUCTION,
+        afterCleanup.getCollection(COLLECTION).getSlice(CHILD_1).getState());
+  }
+
+  private static ClusterState initialState() {
+    return cluster(
+        collection(
+            slice(PARENT, Slice.State.ACTIVE),
+            slice(CHILD_0, Slice.State.CONSTRUCTION),
+            slice(CHILD_1, Slice.State.CONSTRUCTION)));
+  }
+
+  private static Map<String, Object> successSwitch() {
+    Map<String, Object> props = baseUpdate();
+    props.put(PARENT, Slice.State.INACTIVE.toString());
+    props.put(CHILD_0, Slice.State.ACTIVE.toString());
+    props.put(CHILD_1, Slice.State.ACTIVE.toString());
+    return props;
+  }
+
+  private static Map<String, Object> staleCleanupWithoutParent() {
+    Map<String, Object> props = baseUpdate();
+    props.put(CHILD_0, Slice.State.CONSTRUCTION.toString());
+    props.put(CHILD_1, Slice.State.CONSTRUCTION.toString());
+    return props;
+  }
+
+  private static Map<String, Object> baseUpdate() {
+    Map<String, Object> props = new HashMap<>();
+    props.put(Overseer.QUEUE_OPERATION, 
OverseerAction.UPDATESHARDSTATE.toLower());
+    props.put(ZkStateReader.COLLECTION_PROP, COLLECTION);
+    return props;
+  }
+
+  private static ClusterState apply(ClusterState state, Map<String, Object> 
update) {
+    SolrCloudManager cloudManager = mock(SolrCloudManager.class);
+    
when(cloudManager.getDistribStateManager()).thenReturn(mock(DistribStateManager.class));
+    when(cloudManager.getTimeSource()).thenReturn(TimeSource.NANO_TIME);
+    ZkWriteCommand cmd =
+        new SliceMutator(cloudManager).updateShardState(state, new 
ZkNodeProps(update));
+    return new ClusterState(Set.of(), Map.of(COLLECTION, cmd.collection));
+  }
+
+  private static ClusterState cluster(DocCollection collection) {
+    return new ClusterState(Set.of(), Map.of(COLLECTION, collection));
+  }
+
+  private static Slice slice(String name, Slice.State state) {
+    return new Slice(
+        name, Map.of(), Map.of(ZkStateReader.STATE_PROP, state.toString()), 
COLLECTION);
+  }
+
+  private static DocCollection collection(Slice... slices) {
+    Map<String, Slice> map = new LinkedHashMap<>();
+    for (Slice slice : slices) {
+      map.put(slice.getName(), slice);
+    }
+    return DocCollection.create(
+        COLLECTION,
+        map,
+        Map.of(ZkStateReader.CONFIGNAME_PROP, 
ConfigSetsHandler.DEFAULT_CONFIGSET_NAME),
+        DocRouter.DEFAULT,
+        1,
+        Instant.EPOCH,
+        null);
+  }
+}

Reply via email to