You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@solr.apache.org by mk...@apache.org on 2023/06/16 15:28:36 UTC

[solr] branch SOLR-16837-afp-withCollectionShards created (now ba869c4a1ff)

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

mkhl pushed a change to branch SOLR-16837-afp-withCollectionShards
in repository https://gitbox.apache.org/repos/asf/solr.git


      at ba869c4a1ff SOLR-16837: introducing AffinityPlacementFactory.withCollectionShards

This branch includes the following new commits:

     new ba869c4a1ff SOLR-16837: introducing AffinityPlacementFactory.withCollectionShards

The 1 revisions listed above as "new" are entirely new to this
repository and will be described in separate emails.  The revisions
listed as "add" were already present in the repository and have only
been added to this reference.



[solr] 01/01: SOLR-16837: introducing AffinityPlacementFactory.withCollectionShards

Posted by mk...@apache.org.
This is an automated email from the ASF dual-hosted git repository.

mkhl pushed a commit to branch SOLR-16837-afp-withCollectionShards
in repository https://gitbox.apache.org/repos/asf/solr.git

commit ba869c4a1ff6ef8b7ec0ef5057d064953cf7b68c
Author: Mikhail Khludnev <mk...@apache.org>
AuthorDate: Wed Jun 14 09:57:36 2023 +0300

    SOLR-16837: introducing AffinityPlacementFactory.withCollectionShards
---
 solr/CHANGES.txt                                   |   2 +
 .../placement/plugins/AffinityPlacementConfig.java |  36 ++++
 .../plugins/AffinityPlacementFactory.java          |  55 ++++--
 .../plugins/OrderedNodePlacementPlugin.java        |  23 +++
 .../plugins/AffinityPlacementFactoryTest.java      | 202 +++++++++++++++++++++
 .../pages/replica-placement-plugins.adoc           |  18 +-
 .../placement/ClusterAbstractionsForTest.java      |   5 +
 7 files changed, 326 insertions(+), 15 deletions(-)

diff --git a/solr/CHANGES.txt b/solr/CHANGES.txt
index 0db629d08f5..1c7f729b72e 100644
--- a/solr/CHANGES.txt
+++ b/solr/CHANGES.txt
@@ -70,6 +70,8 @@ New Features
 * SOLR-16806: Solr now provides a BalanceReplicas API at `POST /api/cluster/replicas/balance` (v2), to spread replicas
   across a given set of nodes. No v1 API is available. (Houston Putman, Tomás Fernández Löbbe, Jason Gerlowski, Radu Gheorghe)
 
+* SOLR-16837: Introducing AffinityPlacementFactory.withCollectionShards to collocate shards for this operation. (Mikhail Khludnev)
+
 Improvements
 ---------------------
 
diff --git a/solr/core/src/java/org/apache/solr/cluster/placement/plugins/AffinityPlacementConfig.java b/solr/core/src/java/org/apache/solr/cluster/placement/plugins/AffinityPlacementConfig.java
index b561b1f24aa..2a5d2bdb4a6 100644
--- a/solr/core/src/java/org/apache/solr/cluster/placement/plugins/AffinityPlacementConfig.java
+++ b/solr/core/src/java/org/apache/solr/cluster/placement/plugins/AffinityPlacementConfig.java
@@ -17,9 +17,12 @@
 
 package org.apache.solr.cluster.placement.plugins;
 
+import java.util.ArrayList;
+import java.util.Collections;
 import java.util.Map;
 import java.util.Objects;
 import org.apache.solr.cluster.placement.PlacementPluginConfig;
+import org.apache.solr.common.SolrException;
 import org.apache.solr.common.annotation.JsonProperty;
 
 /** Configuration bean for {@link AffinityPlacementFactory}. */
@@ -103,6 +106,11 @@ public class AffinityPlacementConfig implements PlacementPluginConfig {
    * acceptable node types).
    */
   @JsonProperty public Map<String, String> collectionNodeType;
+  /**
+   * Same as {@link AffinityPlacementConfig#withCollection} but ensures shard to shard
+   * correspondence. should be disjoint with {@link AffinityPlacementConfig#withCollection}.
+   */
+  @JsonProperty public Map<String, String> withCollectionShards;
 
   /**
    * When this property is set to {@code true}, Solr will try to place replicas for the same shard
@@ -161,12 +169,40 @@ public class AffinityPlacementConfig implements PlacementPluginConfig {
       long minimalFreeDiskGB,
       long prioritizedFreeDiskGB,
       Map<String, String> withCollection,
+      Map<String, String> withCollectionShards,
       Map<String, String> collectionNodeType) {
     this.minimalFreeDiskGB = minimalFreeDiskGB;
     this.prioritizedFreeDiskGB = prioritizedFreeDiskGB;
     Objects.requireNonNull(withCollection);
+    Objects.requireNonNull(withCollectionShards);
     Objects.requireNonNull(collectionNodeType);
     this.withCollection = withCollection;
+    this.withCollectionShards = withCollectionShards;
     this.collectionNodeType = collectionNodeType;
   }
+
+  public AffinityPlacementConfig(
+      long minimalFreeDiskGB,
+      long prioritizedFreeDiskGB,
+      Map<String, String> withCollection,
+      Map<String, String> collectionNodeType) {
+    this(
+        minimalFreeDiskGB,
+        prioritizedFreeDiskGB,
+        withCollection,
+        Collections.emptyMap(),
+        collectionNodeType);
+  }
+
+  public void validate() {
+    if (!Collections.disjoint(withCollection.keySet(), withCollectionShards.keySet())) {
+      final ArrayList<String> collections = new ArrayList<>(withCollection.keySet());
+      collections.retainAll(withCollectionShards.keySet());
+      throw new SolrException(
+          SolrException.ErrorCode.BAD_REQUEST,
+          "withCollection and withCollectionShards should be disjoint. But there are "
+              + collections
+              + " in common.");
+    }
+  }
 }
diff --git a/solr/core/src/java/org/apache/solr/cluster/placement/plugins/AffinityPlacementFactory.java b/solr/core/src/java/org/apache/solr/cluster/placement/plugins/AffinityPlacementFactory.java
index 80d2d43af96..3e305303feb 100644
--- a/solr/core/src/java/org/apache/solr/cluster/placement/plugins/AffinityPlacementFactory.java
+++ b/solr/core/src/java/org/apache/solr/cluster/placement/plugins/AffinityPlacementFactory.java
@@ -31,6 +31,7 @@ import java.util.Objects;
 import java.util.Optional;
 import java.util.Set;
 import java.util.stream.Collectors;
+import java.util.stream.Stream;
 import org.apache.solr.cluster.Cluster;
 import org.apache.solr.cluster.Node;
 import org.apache.solr.cluster.Replica;
@@ -123,10 +124,12 @@ public class AffinityPlacementFactory implements PlacementPluginFactory<Affinity
 
   @Override
   public PlacementPlugin createPluginInstance() {
+    config.validate();
     return new AffinityPlacementPlugin(
         config.minimalFreeDiskGB,
         config.prioritizedFreeDiskGB,
         config.withCollection,
+        config.withCollectionShards,
         config.collectionNodeType,
         config.spreadAcrossDomains);
   }
@@ -134,6 +137,7 @@ public class AffinityPlacementFactory implements PlacementPluginFactory<Affinity
   @Override
   public void configure(AffinityPlacementConfig cfg) {
     Objects.requireNonNull(cfg, "configuration must never be null");
+    cfg.validate();
     this.config = cfg;
   }
 
@@ -154,7 +158,9 @@ public class AffinityPlacementFactory implements PlacementPluginFactory<Affinity
 
     // primary to secondary (1:1)
     private final Map<String, String> withCollections;
-    // secondary to primary (1:N)
+    // same but shardwise
+    private final Map<String, String> withCollectionShards;
+    // secondary to primary (1:N) + shard-wise_primary (1:N)
     private final Map<String, Set<String>> collocatedWith;
 
     private final Map<String, Set<String>> nodeTypes;
@@ -169,6 +175,7 @@ public class AffinityPlacementFactory implements PlacementPluginFactory<Affinity
         long minimalFreeDiskGB,
         long prioritizedFreeDiskGB,
         Map<String, String> withCollections,
+        Map<String, String> withCollectionShards,
         Map<String, String> collectionNodeTypes,
         boolean spreadAcrossDomains) {
       this.minimalFreeDiskGB = minimalFreeDiskGB;
@@ -177,14 +184,17 @@ public class AffinityPlacementFactory implements PlacementPluginFactory<Affinity
       Objects.requireNonNull(collectionNodeTypes, "collectionNodeTypes must not be null");
       this.spreadAcrossDomains = spreadAcrossDomains;
       this.withCollections = withCollections;
-      if (withCollections.isEmpty()) {
-        collocatedWith = Map.of();
-      } else {
-        collocatedWith = new HashMap<>();
-        withCollections.forEach(
-            (primary, secondary) ->
-                collocatedWith.computeIfAbsent(secondary, s -> new HashSet<>()).add(primary));
-      }
+      this.withCollectionShards = withCollectionShards;
+      Map<String, Set<String>> collocated = new HashMap<>();
+      List.of(this.withCollections, this.withCollectionShards)
+          .forEach(
+              collns ->
+                  collns.forEach(
+                      (primary, secondary) ->
+                          collocated
+                              .computeIfAbsent(secondary, s -> new HashSet<>())
+                              .add(primary)));
+      this.collocatedWith = Collections.unmodifiableMap(collocated);
 
       if (collectionNodeTypes.isEmpty()) {
         nodeTypes = Map.of();
@@ -521,6 +531,12 @@ public class AffinityPlacementFactory implements PlacementPluginFactory<Affinity
             && Optional.ofNullable(withCollections.get(collection))
                 .map(this::hasCollectionOnNode)
                 .orElse(true)
+            // Ensure same shard is collocated if required
+            && Optional.ofNullable(withCollectionShards.get(collection))
+                .map(
+                    shardWiseOf ->
+                        getShardsOnNode(shardWiseOf).contains(replica.getShard().getShardName()))
+                .orElse(true)
             // Ensure the disk space will not go below the minimum if the replica is added
             && (minimalFreeDiskGB <= 0
                 || nodeFreeDiskGB - getProjectedSizeOfReplica(replica) > minimalFreeDiskGB);
@@ -547,6 +563,14 @@ public class AffinityPlacementFactory implements PlacementPluginFactory<Affinity
             continue;
           }
 
+          Stream<String> shardWiseCollocations =
+              collocatedCollections.stream()
+                  .filter(
+                      priColl -> collection.getName().equals(withCollectionShards.get(priColl)));
+          final Set<String> mandatoryShardsOrAll =
+              shardWiseCollocations
+                  .flatMap(priColl -> getShardsOnNode(priColl).stream())
+                  .collect(Collectors.toSet());
           // There are collocatedCollections for this shard, so make sure there is a replica of this
           // shard left on the node after it is removed
           Set<Replica> replicasRemovedForShard =
@@ -555,11 +579,14 @@ public class AffinityPlacementFactory implements PlacementPluginFactory<Affinity
                       replica.getShard().getCollection().getName(), k -> new HashMap<>())
                   .computeIfAbsent(replica.getShard().getShardName(), k -> new HashSet<>());
           replicasRemovedForShard.add(replica);
-
-          if (replicasRemovedForShard.size()
-              >= getReplicasForShardOnNode(replica.getShard()).size()) {
-            replicaRemovalExceptions.put(
-                replica, "co-located with replicas of " + collocatedCollections);
+          // either if all shards are mandatory, or the current one is mandatory
+          if (mandatoryShardsOrAll.isEmpty()
+              || mandatoryShardsOrAll.contains(replica.getShard().getShardName())) {
+            if (replicasRemovedForShard.size()
+                >= getReplicasForShardOnNode(replica.getShard()).size()) {
+              replicaRemovalExceptions.put(
+                  replica, "co-located with replicas of " + collocatedCollections);
+            }
           }
         }
         return replicaRemovalExceptions;
diff --git a/solr/core/src/java/org/apache/solr/cluster/placement/plugins/OrderedNodePlacementPlugin.java b/solr/core/src/java/org/apache/solr/cluster/placement/plugins/OrderedNodePlacementPlugin.java
index a9d1f4ea048..0a3beff2706 100644
--- a/solr/core/src/java/org/apache/solr/cluster/placement/plugins/OrderedNodePlacementPlugin.java
+++ b/solr/core/src/java/org/apache/solr/cluster/placement/plugins/OrderedNodePlacementPlugin.java
@@ -613,6 +613,11 @@ public abstract class OrderedNodePlacementPlugin implements PlacementPlugin {
         }
       }
     }
+
+    @Override
+    public String toString() {
+      return "WeightedNode{" + "node=" + node + ", lastSortedWeight=" + lastSortedWeight + '}';
+    }
   }
 
   /**
@@ -666,6 +671,15 @@ public abstract class OrderedNodePlacementPlugin implements PlacementPlugin {
           public ShardState getState() {
             return null;
           }
+
+          @Override
+          public String toString() {
+            return Optional.ofNullable(collection)
+                    .map(SolrCollection::getName)
+                    .orElse("<no collection>")
+                + "/"
+                + shardName;
+          }
         };
     return new Replica() {
       @Override
@@ -697,6 +711,15 @@ public abstract class OrderedNodePlacementPlugin implements PlacementPlugin {
       public Node getNode() {
         return node;
       }
+
+      @Override
+      public String toString() {
+        return Optional.ofNullable(shard).map(Shard::getShardName).orElse("<no shard>")
+            + "@"
+            + Optional.ofNullable(node).map(Node::getName).orElse("<no node>")
+            + " of "
+            + type;
+      }
     };
   }
 }
diff --git a/solr/core/src/test/org/apache/solr/cluster/placement/plugins/AffinityPlacementFactoryTest.java b/solr/core/src/test/org/apache/solr/cluster/placement/plugins/AffinityPlacementFactoryTest.java
index c71c823e59b..424161dafa4 100644
--- a/solr/core/src/test/org/apache/solr/cluster/placement/plugins/AffinityPlacementFactoryTest.java
+++ b/solr/core/src/test/org/apache/solr/cluster/placement/plugins/AffinityPlacementFactoryTest.java
@@ -18,10 +18,13 @@
 package org.apache.solr.cluster.placement.plugins;
 
 import java.lang.invoke.MethodHandles;
+import java.util.ArrayList;
+import java.util.Arrays;
 import java.util.Collections;
 import java.util.HashMap;
 import java.util.HashSet;
 import java.util.Iterator;
+import java.util.LinkedHashSet;
 import java.util.List;
 import java.util.Map;
 import java.util.Optional;
@@ -29,6 +32,7 @@ import java.util.Set;
 import java.util.concurrent.TimeUnit;
 import java.util.concurrent.atomic.AtomicInteger;
 import java.util.stream.Collectors;
+import java.util.stream.Stream;
 import java.util.stream.StreamSupport;
 import org.apache.solr.cluster.Cluster;
 import org.apache.solr.cluster.Node;
@@ -48,6 +52,7 @@ import org.apache.solr.cluster.placement.ReplicaPlacement;
 import org.apache.solr.cluster.placement.impl.BalanceRequestImpl;
 import org.apache.solr.cluster.placement.impl.ModificationRequestImpl;
 import org.apache.solr.cluster.placement.impl.PlacementRequestImpl;
+import org.apache.solr.common.SolrException;
 import org.apache.solr.common.util.Pair;
 import org.apache.solr.common.util.StrUtils;
 import org.junit.Before;
@@ -793,6 +798,18 @@ public class AffinityPlacementFactoryTest extends AbstractPlacementFactoryTest {
         () -> plugin.computePlacement(badShardPlacementRequest, placementContext));
   }
 
+  @Test(expected = SolrException.class)
+  public void testWithCollectionDisjointWithShards() {
+    AffinityPlacementConfig config =
+        new AffinityPlacementConfig(
+            MINIMAL_FREE_DISK_GB,
+            PRIORITIZED_FREE_DISK_GB,
+            Map.of(primaryCollectionName, secondaryCollectionName),
+            Map.of(primaryCollectionName, secondaryCollectionName),
+            Map.of());
+    configurePlugin(config);
+  }
+
   @Test
   public void testWithCollectionPlacement() throws Exception {
     AffinityPlacementConfig config =
@@ -851,6 +868,96 @@ public class AffinityPlacementFactoryTest extends AbstractPlacementFactoryTest {
     }
   }
 
+  @Test
+  public void testWithCollectionShardsPlacement() throws Exception {
+    AffinityPlacementConfig config =
+        new AffinityPlacementConfig(
+            MINIMAL_FREE_DISK_GB,
+            PRIORITIZED_FREE_DISK_GB,
+            Map.of(),
+            Map.of(primaryCollectionName, secondaryCollectionName),
+            Map.of());
+    configurePlugin(config);
+
+    int NUM_NODES = 3;
+    Builders.ClusterBuilder clusterBuilder =
+        Builders.newClusterBuilder().initializeLiveNodes(NUM_NODES);
+    Builders.CollectionBuilder collectionBuilder =
+        Builders.newCollectionBuilder(secondaryCollectionName);
+    collectionBuilder.initializeShardsReplicas(2, 1, 0, 0, clusterBuilder.getLiveNodeBuilders());
+    clusterBuilder.addCollection(collectionBuilder);
+
+    collectionBuilder = Builders.newCollectionBuilder(primaryCollectionName);
+    collectionBuilder.initializeShardsReplicas(0, 0, 0, 0, clusterBuilder.getLiveNodeBuilders());
+    clusterBuilder.addCollection(collectionBuilder);
+
+    PlacementContext placementContext = clusterBuilder.buildPlacementContext();
+    Cluster cluster = placementContext.getCluster();
+
+    SolrCollection secondaryCollection = cluster.getCollection(secondaryCollectionName);
+    SolrCollection primaryCollection = cluster.getCollection(primaryCollectionName);
+
+    Set<Node> secondaryNodes = new HashSet<>();
+    secondaryCollection
+        .shards()
+        .forEach(s -> s.replicas().forEach(r -> secondaryNodes.add(r.getNode())));
+
+    final List<Node> liveNodes = new ArrayList<>(cluster.getLiveNodes());
+    Collections.shuffle(liveNodes, random());
+    PlacementRequestImpl placementRequest =
+        new PlacementRequestImpl(
+            primaryCollection,
+            shuffle(Arrays.asList("shard2", "shard1")),
+            shuffle(cluster.getLiveNodes()),
+            1,
+            0,
+            0);
+
+    PlacementPlan pp = plugin.computePlacement(placementRequest, placementContext);
+    assertEquals(2, pp.getReplicaPlacements().size());
+    // verify that all placements are on nodes with the secondary replica
+    pp.getReplicaPlacements()
+        .forEach(
+            placement -> {
+              assertTrue(
+                  "placement node " + placement.getNode() + " not in secondary=" + secondaryNodes,
+                  secondaryNodes.contains(placement.getNode()));
+              boolean collocated = false;
+              final Shard shard = secondaryCollection.getShard(placement.getShardName());
+              StringBuilder msg = new StringBuilder();
+              for (Iterator<Replica> secReplicas = shard.iterator();
+                  secReplicas.hasNext() && !collocated; ) {
+                final Replica secReplica = secReplicas.next();
+                collocated |= placement.getNode().getName().equals(secReplica.getNode().getName());
+                msg.append(secReplica.getReplicaName());
+                msg.append("@");
+                msg.append(secReplica.getNode().getName());
+                msg.append(", ");
+              }
+              assertTrue(placement + " is expected to be collocated with " + msg, collocated);
+            });
+
+    placementRequest =
+        new PlacementRequestImpl(
+            primaryCollection, Set.of("shard3"), cluster.getLiveNodes(), 1, 0, 0);
+    try {
+      pp = plugin.computePlacement(placementRequest, placementContext);
+      fail("should generate 'has no replicas on eligible nodes' failure here");
+    } catch (PlacementException pe) {
+      assertTrue(pe.toString(), pe.toString().contains("Not enough eligible nodes"));
+    }
+  }
+
+  private <T> Set<T> shuffle(Set<T> liveNodes) {
+    final List<T> nodes = new ArrayList<>(liveNodes);
+    return shuffle(nodes);
+  }
+
+  private static <T> Set<T> shuffle(List<T> nodes) {
+    Collections.shuffle(nodes, random());
+    return new LinkedHashSet<>(nodes);
+  }
+
   @Test
   public void testWithCollectionModificationRejected() throws Exception {
     AffinityPlacementConfig config =
@@ -924,6 +1031,101 @@ public class AffinityPlacementFactoryTest extends AbstractPlacementFactoryTest {
     }
   }
 
+  @Test
+  public void testWithCollectionShardsModificationRejected() throws Exception {
+    AffinityPlacementConfig config =
+        new AffinityPlacementConfig(
+            MINIMAL_FREE_DISK_GB,
+            PRIORITIZED_FREE_DISK_GB,
+            Map.of(),
+            Map.of(primaryCollectionName, secondaryCollectionName),
+            Map.of());
+    configurePlugin(config);
+
+    int NUM_NODES = 2;
+    Builders.ClusterBuilder clusterBuilder =
+        Builders.newClusterBuilder().initializeLiveNodes(NUM_NODES);
+    Builders.CollectionBuilder collectionBuilder =
+        Builders.newCollectionBuilder(secondaryCollectionName);
+    collectionBuilder.initializeShardsReplicas(2, 3, 0, 0, clusterBuilder.getLiveNodeBuilders());
+    clusterBuilder.addCollection(collectionBuilder);
+
+    collectionBuilder = Builders.newCollectionBuilder(primaryCollectionName);
+    collectionBuilder.initializeShardsReplicas(
+        2, random().nextBoolean() ? 1 : 2, 0, 0, clusterBuilder.getLiveNodeBuilders());
+    clusterBuilder.addCollection(collectionBuilder);
+
+    PlacementContext placementContext = clusterBuilder.buildPlacementContext();
+    Cluster cluster = placementContext.getCluster();
+
+    SolrCollection secondaryCollection = cluster.getCollection(secondaryCollectionName);
+    SolrCollection primaryCollection = cluster.getCollection(primaryCollectionName);
+
+    final ArrayList<Node> nodes = new ArrayList<>(cluster.getLiveNodes());
+    Collections.shuffle(nodes, random());
+    Set<Replica> toRemove = new HashSet<>();
+    DeleteReplicasRequest deleteReplicasRequest;
+    for (Node node : nodes) {
+      Set<String> seen = new HashSet<>();
+      final Set<String> mustHaveShards =
+          replicas(primaryCollection)
+              .filter(r -> r.getNode().getName().equals(node.getName()))
+              .map(r -> r.getShard().getShardName())
+              .collect(Collectors.toSet());
+
+      replicas(secondaryCollection)
+          .filter(r -> r.getNode().getName().equals(node.getName()))
+          .forEach(
+              r -> {
+                final String secRepShard = r.getShard().getShardName();
+                if (mustHaveShards.contains(secRepShard)) {
+                  if (seen.contains(secRepShard)) {
+                    toRemove.add(r);
+                  } else {
+                    seen.add(secRepShard);
+                  }
+                } else {
+                  toRemove.add(r);
+                }
+              });
+
+      assertFalse(toRemove.isEmpty());
+      deleteReplicasRequest =
+          ModificationRequestImpl.createDeleteReplicasRequest(secondaryCollection, toRemove);
+      try {
+        plugin.verifyAllowedModification(deleteReplicasRequest, placementContext);
+      } catch (PlacementException pe) {
+        fail("should have succeeded: " + pe);
+      }
+    }
+    final List<Replica> remainingReplicas =
+        replicas(secondaryCollection)
+            .filter(r -> !toRemove.contains(r))
+            .collect(Collectors.toList());
+    Collections.shuffle(remainingReplicas, random());
+    toRemove.add(remainingReplicas.iterator().next());
+
+    deleteReplicasRequest =
+        ModificationRequestImpl.createDeleteReplicasRequest(secondaryCollection, toRemove);
+    try {
+      plugin.verifyAllowedModification(deleteReplicasRequest, placementContext);
+      fail("should have failed: " + deleteReplicasRequest);
+    } catch (PlacementException pe) {
+    }
+  }
+
+  private Stream<Replica> replicas(SolrCollection primaryCollection) {
+    return shards(primaryCollection).flatMap(shard -> replicas(shard));
+  }
+
+  private Stream<Replica> replicas(Shard shard) {
+    return StreamSupport.stream(shard.replicas().spliterator(), false);
+  }
+
+  private static Stream<Shard> shards(SolrCollection primaryCollection) {
+    return StreamSupport.stream(primaryCollection.shards().spliterator(), false);
+  }
+
   @Test
   public void testNodeType() throws Exception {
     Builders.ClusterBuilder clusterBuilder = Builders.newClusterBuilder().initializeLiveNodes(9);
diff --git a/solr/solr-ref-guide/modules/configuration-guide/pages/replica-placement-plugins.adoc b/solr/solr-ref-guide/modules/configuration-guide/pages/replica-placement-plugins.adoc
index e3769bc00b1..e2541d7c885 100644
--- a/solr/solr-ref-guide/modules/configuration-guide/pages/replica-placement-plugins.adoc
+++ b/solr/solr-ref-guide/modules/configuration-guide/pages/replica-placement-plugins.adoc
@@ -113,6 +113,7 @@ The autoscaling specification in the configuration linked above aimed to do the
 It also supports additional per-collection constraints:
 
 * `withCollection` enforces the placement of co-located collections' replicas on the same nodes, and prevents deletions of collections and replicas that would break this constraint.
+* `withCollectionShards` same as above but also collocates shards. ie. shardN is placed at the same node where shardN from the referred collection is located. Note: keys of `withCollectionShards` should be disjoint with `withCollection` keys.
 * `collectionNodeType` limits the nodes eligible for placement to only those that match one or more of the specified node types.
 
 See below for more details on these constraints.
@@ -120,7 +121,7 @@ See below for more details on these constraints.
 Overall strategy of this plugin:
 
 * The set of nodes in the cluster is obtained.
-If `withCollection` is defined and applicable to the current collection then this candidate set is filtered so that only eligible nodes remain according to this constraint.
+If `withCollection` or `withCollectionShards` are defined and applicable to the current collection then this candidate set is filtered so that only eligible nodes remain according to this constraint.
 * The resulting node set is transformed into 3 independent sets (that can overlap) of nodes accepting each of the three replica types (NRT, TLOG, and PULL).
 * For each shard on which placing replicas is required and then for each replica type to place (starting with NRT, then TLOG, then PULL):
 ** The set of candidates nodes corresponding to the replica type is used and from that set are removed nodes that already have a replica (of any type) for that shard.
@@ -173,6 +174,18 @@ The plugin will assume that the secondary collection replicas are already in pla
 +
 See the section <<withCollection constraint>> below.
 
+`withCollectionShards`::
++
+[%autowidth,frame=none]
+|===
+|Optional |Default: none
+|===
++
+Defines an additional constraint that shards of the primary collections (keys) must be located on the same nodes as the shards of the secondary collections (values).
+The plugin will assume that the secondary collection replicas are already in place and ignore candidate nodes where they are not already present. Keys should be disjoint with `withCollection`.
++
+See the section <<withCollectionShards constraint>> below.
+
 `collectionNodeType`::
 +
 [%autowidth,frame=none]
@@ -199,6 +212,9 @@ The plugin preserves this co-location by rejecting delete operation of secondary
 In order to delete a secondary collection (or its replicas) from these nodes first the replicas of the primary collection must be removed from the co-located nodes, or the configuration must be changed to remove the
 co-location mapping for the primary collection.
 
+==== withCollectionShards constraint
+Almost same as above, but collocates shards of the paired collections.
+
 == Example Configurations
 This is a simple configuration that uses default values:
 
diff --git a/solr/test-framework/src/java/org/apache/solr/cluster/placement/ClusterAbstractionsForTest.java b/solr/test-framework/src/java/org/apache/solr/cluster/placement/ClusterAbstractionsForTest.java
index c6e59364c3f..f84e6353215 100644
--- a/solr/test-framework/src/java/org/apache/solr/cluster/placement/ClusterAbstractionsForTest.java
+++ b/solr/test-framework/src/java/org/apache/solr/cluster/placement/ClusterAbstractionsForTest.java
@@ -231,6 +231,11 @@ class ClusterAbstractionsForTest {
     public int hashCode() {
       return Objects.hash(shardName, collection, shardState);
     }
+
+    @Override
+    public String toString() {
+      return "ShardImpl{" + "shardName='" + shardName + '\'' + '}';
+    }
   }
 
   static class ReplicaImpl implements Replica {