lucene-commits mailing list archives

Site index · List index
Message view « Date » · « Thread »
Top « Date » · « Thread »
From a.@apache.org
Subject [lucene-solr] branch master updated: SOLR-15055: Re-implement 'withCollection'.
Date Tue, 26 Jan 2021 14:16:50 GMT
This is an automated email from the ASF dual-hosted git repository.

ab pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/lucene-solr.git


The following commit(s) were added to refs/heads/master by this push:
     new 6e9185a  SOLR-15055: Re-implement 'withCollection'.
6e9185a is described below

commit 6e9185a33aefa039934adb4c14764030f8e702ea
Author: Andrzej Bialecki <ab@apache.org>
AuthorDate: Tue Jan 26 15:16:04 2021 +0100

    SOLR-15055: Re-implement 'withCollection'.
---
 solr/CHANGES.txt                                   |   3 +
 .../apache/solr/cloud/ExclusiveSliceProperty.java  |   6 +-
 .../solr/cloud/api/collections/AddReplicaCmd.java  |   8 +-
 .../apache/solr/cloud/api/collections/Assign.java  |  47 ++++++-
 .../cloud/api/collections/CreateCollectionCmd.java |  12 +-
 .../cloud/api/collections/DeleteCollectionCmd.java |   7 +
 .../solr/cloud/api/collections/DeleteNodeCmd.java  |   3 +-
 .../cloud/api/collections/DeleteReplicaCmd.java    |  54 ++++++--
 .../solr/cloud/api/collections/MigrateCmd.java     |   2 +-
 .../OverseerCollectionMessageHandler.java          |   6 +-
 .../solr/cloud/api/collections/ReplaceNodeCmd.java |   2 +-
 .../solr/cloud/api/collections/RestoreCmd.java     |   2 +-
 .../solr/cloud/api/collections/SplitShardCmd.java  |   7 +-
 .../apache/solr/cloud/overseer/ReplicaMutator.java |  11 +-
 .../apache/solr/cloud/overseer/SliceMutator.java   |   4 +-
 .../impl/CollectionsRepairEventListener.java       |   8 +-
 .../cluster/placement/DeleteCollectionRequest.java |  23 +++
 .../cluster/placement/DeleteReplicasRequest.java   |  29 ++++
 .../cluster/placement/DeleteShardsRequest.java     |  27 ++++
 .../cluster/placement/ModificationRequest.java     |  30 ++++
 .../solr/cluster/placement/PlacementContext.java   |  44 ++++++
 .../placement/PlacementModificationException.java  |  62 +++++++++
 .../solr/cluster/placement/PlacementPlugin.java    |  25 ++--
 .../solr/cluster/placement/PlacementRequest.java   |   7 +-
 .../placement/impl/ModificationRequestImpl.java    | 109 +++++++++++++++
 .../impl/PlacementPluginAssignStrategy.java        |  38 ++++-
 .../cluster/placement/impl/ReplicaMetricImpl.java  |   3 +
 .../placement/impl/SimplePlacementContextImpl.java |  40 ++++++
 .../placement/plugins/AffinityPlacementConfig.java |  40 +++++-
 .../plugins/AffinityPlacementFactory.java          | 143 ++++++++++++++++++-
 .../plugins/MinimizeCoresPlacementFactory.java     |  12 +-
 .../placement/plugins/RandomPlacementFactory.java  |  13 +-
 .../solr/handler/admin/CollectionsHandler.java     |  22 +--
 .../apache/solr/cluster/placement/Builders.java    |  24 ++++
 .../impl/PlacementPluginIntegrationTest.java       |  85 +++++++++++-
 .../plugins/AffinityPlacementFactoryTest.java      | 154 ++++++++++++++++-----
 .../solr/common/params/CollectionAdminParams.java  |   5 +
 37 files changed, 971 insertions(+), 146 deletions(-)

diff --git a/solr/CHANGES.txt b/solr/CHANGES.txt
index 0331237..7edd319 100644
--- a/solr/CHANGES.txt
+++ b/solr/CHANGES.txt
@@ -25,6 +25,9 @@ New Features
 
 * SOLR-15019: Replica placement API needs a way to fetch existing replica metrics. (ab, ilan)
 
+* SOLR-15055: Re-implement 'withCollection'. This also adds the placement plugin support
+  for rejecting replica / collection deletions that would violate placement constraints. (ab, ilan)
+
 Improvements
 ----------------------
 * LUCENE-8984: MoreLikeThis MLT is biased for uncommon fields (Andy Hind via Anshum Gupta)
diff --git a/solr/core/src/java/org/apache/solr/cloud/ExclusiveSliceProperty.java b/solr/core/src/java/org/apache/solr/cloud/ExclusiveSliceProperty.java
index 448f455..01592ff 100644
--- a/solr/core/src/java/org/apache/solr/cloud/ExclusiveSliceProperty.java
+++ b/solr/core/src/java/org/apache/solr/cloud/ExclusiveSliceProperty.java
@@ -29,7 +29,6 @@ import java.util.Random;
 import java.util.Set;
 
 import org.apache.commons.lang3.StringUtils;
-import org.apache.solr.cloud.api.collections.OverseerCollectionMessageHandler;
 import org.apache.solr.cloud.overseer.ClusterStateMutator;
 import org.apache.solr.cloud.overseer.CollectionMutator;
 import org.apache.solr.cloud.overseer.SliceMutator;
@@ -40,6 +39,7 @@ import org.apache.solr.common.cloud.Replica;
 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.params.CollectionAdminParams;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
@@ -74,8 +74,8 @@ class ExclusiveSliceProperty {
   ExclusiveSliceProperty(ClusterState clusterState, ZkNodeProps message) {
     this.clusterState = clusterState;
     String tmp = message.getStr(ZkStateReader.PROPERTY_PROP);
-    if (StringUtils.startsWith(tmp, OverseerCollectionMessageHandler.COLL_PROP_PREFIX) == false) {
-      tmp = OverseerCollectionMessageHandler.COLL_PROP_PREFIX + tmp;
+    if (!StringUtils.startsWith(tmp, CollectionAdminParams.PROPERTY_PREFIX)) {
+      tmp = CollectionAdminParams.PROPERTY_PREFIX + tmp;
     }
     this.property = tmp.toLowerCase(Locale.ROOT);
     collectionName = message.getStr(ZkStateReader.COLLECTION_PROP);
diff --git a/solr/core/src/java/org/apache/solr/cloud/api/collections/AddReplicaCmd.java b/solr/core/src/java/org/apache/solr/cloud/api/collections/AddReplicaCmd.java
index b24e442..c3f5004 100644
--- a/solr/core/src/java/org/apache/solr/cloud/api/collections/AddReplicaCmd.java
+++ b/solr/core/src/java/org/apache/solr/cloud/api/collections/AddReplicaCmd.java
@@ -45,7 +45,6 @@ import org.apache.solr.client.solrj.cloud.SolrCloudManager;
 import org.apache.solr.cloud.ActiveReplicaWatcher;
 import org.apache.solr.cloud.Overseer;
 import org.apache.solr.cloud.api.collections.OverseerCollectionMessageHandler.ShardRequestTracker;
-import org.apache.solr.cluster.placement.PlacementPlugin;
 import org.apache.solr.common.SolrCloseableLatch;
 import org.apache.solr.common.SolrException;
 import org.apache.solr.common.cloud.ClusterState;
@@ -61,6 +60,7 @@ import org.apache.solr.common.params.ModifiableSolrParams;
 import org.apache.solr.common.params.ShardParams;
 import org.apache.solr.common.util.NamedList;
 import org.apache.solr.common.util.Utils;
+import org.apache.solr.core.CoreContainer;
 import org.apache.solr.handler.component.ShardHandler;
 import org.apache.zookeeper.KeeperException;
 import org.slf4j.Logger;
@@ -141,7 +141,7 @@ public class AddReplicaCmd implements OverseerCollectionMessageHandler.Cmd {
     }
 
     List<CreateReplica> createReplicas = buildReplicaPositions(ocmh.cloudManager, clusterState, collectionName, message, replicaTypesVsCount,
-        ocmh.overseer.getCoreContainer().getPlacementPluginFactory().createPluginInstance())
+        ocmh.overseer.getCoreContainer())
           .stream()
           .map(replicaPosition -> assignReplicaDetails(ocmh.cloudManager, clusterState, message, replicaPosition))
           .collect(Collectors.toList());
@@ -302,7 +302,7 @@ public class AddReplicaCmd implements OverseerCollectionMessageHandler.Cmd {
   public static List<ReplicaPosition> buildReplicaPositions(SolrCloudManager cloudManager, ClusterState clusterState,
                                                             String collectionName, ZkNodeProps message,
                                                             EnumMap<Replica.Type, Integer> replicaTypeVsCount,
-                                                            PlacementPlugin placementPlugin) throws IOException, InterruptedException {
+                                                            CoreContainer coreContainer) throws IOException, InterruptedException {
     boolean skipCreateReplicaInClusterState = message.getBool(SKIP_CREATE_REPLICA_IN_CLUSTER_STATE, false);
     boolean skipNodeAssignment = message.getBool(CollectionAdminParams.SKIP_NODE_ASSIGNMENT, false);
     String sliceName = message.getStr(SHARD_ID_PROP);
@@ -326,7 +326,7 @@ public class AddReplicaCmd implements OverseerCollectionMessageHandler.Cmd {
     if (!skipCreateReplicaInClusterState && !skipNodeAssignment) {
 
       positions = Assign.getNodesForNewReplicas(clusterState, collection.getName(), sliceName, numNrtReplicas,
-                    numTlogReplicas, numPullReplicas, createNodeSetStr, cloudManager, placementPlugin);
+                    numTlogReplicas, numPullReplicas, createNodeSetStr, cloudManager, coreContainer);
     }
 
     if (positions == null)  {
diff --git a/solr/core/src/java/org/apache/solr/cloud/api/collections/Assign.java b/solr/core/src/java/org/apache/solr/cloud/api/collections/Assign.java
index 786bfa9..c2f09ac 100644
--- a/solr/core/src/java/org/apache/solr/cloud/api/collections/Assign.java
+++ b/solr/core/src/java/org/apache/solr/cloud/api/collections/Assign.java
@@ -51,6 +51,7 @@ 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.StrUtils;
+import org.apache.solr.core.CoreContainer;
 import org.apache.solr.util.NumberUtils;
 import org.apache.zookeeper.CreateMode;
 import org.apache.zookeeper.KeeperException;
@@ -270,7 +271,7 @@ public class Assign {
   public static List<ReplicaPosition> getNodesForNewReplicas(ClusterState clusterState, String collectionName,
                                                           String shard, int nrtReplicas, int tlogReplicas, int pullReplicas,
                                                           Object createNodeSet, SolrCloudManager cloudManager,
-                                                          PlacementPlugin placementPlugin) throws IOException, InterruptedException, AssignmentException {
+                                                          CoreContainer coreContainer) throws IOException, InterruptedException, AssignmentException {
     log.debug("getNodesForNewReplicas() shard: {} , nrtReplicas : {} , tlogReplicas: {} , pullReplicas: {} , createNodeSet {}"
         , shard, nrtReplicas, tlogReplicas, pullReplicas, createNodeSet);
     DocCollection coll = clusterState.getCollection(collectionName);
@@ -296,7 +297,7 @@ public class Assign {
         .assignPullReplicas(pullReplicas)
         .onNodes(createNodeList)
         .build();
-    AssignStrategy assignStrategy = createAssignStrategy(placementPlugin, clusterState, coll);
+    AssignStrategy assignStrategy = createAssignStrategy(coreContainer, clusterState, coll);
     return assignStrategy.assign(cloudManager, assignRequest);
   }
 
@@ -379,9 +380,46 @@ public class Assign {
     }
   }
 
+  /**
+   * Strategy for assigning replicas to nodes.
+   */
   public interface AssignStrategy {
+
+    /**
+     * Assign new replicas to nodes.
+     * @param solrCloudManager current instance of {@link SolrCloudManager}.
+     * @param assignRequest assign request.
+     * @return list of {@link ReplicaPosition}-s for new replicas.
+     * @throws AssignmentException when assignment request cannot produce any valid assignments.
+     */
     List<ReplicaPosition> assign(SolrCloudManager solrCloudManager, AssignRequest assignRequest)
-        throws Assign.AssignmentException, IOException, InterruptedException;
+        throws AssignmentException, IOException, InterruptedException;
+
+    /**
+     * Verify that deleting a collection doesn't violate the replica assignment constraints.
+     * @param solrCloudManager current instance of {@link SolrCloudManager}.
+     * @param collection collection to delete.
+     * @throws AssignmentException when deleting the collection would violate replica assignment constraints.
+     * @throws IOException on general errors.
+     */
+    default void verifyDeleteCollection(SolrCloudManager solrCloudManager, DocCollection collection)
+        throws AssignmentException, IOException, InterruptedException {
+
+    }
+
+    /**
+     * Verify that deleting these replicas doesn't violate the replica assignment constraints.
+     * @param solrCloudManager current instance of {@link SolrCloudManager}.
+     * @param collection collection to delete replicas from.
+     * @param shardName shard name.
+     * @param replicas replicas to delete.
+     * @throws AssignmentException when deleting the replicas would violate replica assignment constraints.
+     * @throws IOException on general errors.
+     */
+    default void verifyDeleteReplicas(SolrCloudManager solrCloudManager, DocCollection collection, String shardName, Set<Replica> replicas)
+        throws AssignmentException, IOException, InterruptedException {
+
+    }
   }
 
   public static class AssignRequest {
@@ -495,7 +533,8 @@ public class Assign {
    * <p>If {@link PlacementPlugin} instance is null this call will return {@link LegacyAssignStrategy}, otherwise
    * {@link PlacementPluginAssignStrategy} will be used.</p>
    */
-  public static AssignStrategy createAssignStrategy(PlacementPlugin placementPlugin, ClusterState clusterState, DocCollection collection) {
+  public static AssignStrategy createAssignStrategy(CoreContainer coreContainer, ClusterState clusterState, DocCollection collection) {
+    PlacementPlugin placementPlugin = coreContainer.getPlacementPluginFactory().createPluginInstance();
     if (placementPlugin != null) {
       // If a cluster wide placement plugin is configured (and that's the only way to define a placement plugin)
       return new PlacementPluginAssignStrategy(collection, placementPlugin);
diff --git a/solr/core/src/java/org/apache/solr/cloud/api/collections/CreateCollectionCmd.java b/solr/core/src/java/org/apache/solr/cloud/api/collections/CreateCollectionCmd.java
index 2e2a06c..d9de9a5 100644
--- a/solr/core/src/java/org/apache/solr/cloud/api/collections/CreateCollectionCmd.java
+++ b/solr/core/src/java/org/apache/solr/cloud/api/collections/CreateCollectionCmd.java
@@ -41,7 +41,6 @@ import org.apache.solr.cloud.Overseer;
 import org.apache.solr.cloud.ZkController;
 import org.apache.solr.cloud.api.collections.OverseerCollectionMessageHandler.ShardRequestTracker;
 import org.apache.solr.cloud.overseer.ClusterStateMutator;
-import org.apache.solr.cluster.placement.PlacementPlugin;
 import org.apache.solr.common.SolrException;
 import org.apache.solr.common.SolrException.ErrorCode;
 import org.apache.solr.common.cloud.Aliases;
@@ -63,6 +62,7 @@ import org.apache.solr.common.util.NamedList;
 import org.apache.solr.common.util.SimpleOrderedMap;
 import org.apache.solr.common.util.TimeSource;
 import org.apache.solr.common.util.Utils;
+import org.apache.solr.core.CoreContainer;
 import org.apache.solr.handler.component.ShardHandler;
 import org.apache.solr.handler.component.ShardRequest;
 import org.apache.solr.util.TimeOut;
@@ -169,8 +169,8 @@ public class CreateCollectionCmd implements OverseerCollectionMessageHandler.Cmd
 
       List<ReplicaPosition> replicaPositions = null;
       try {
-        replicaPositions = buildReplicaPositions(ocmh.cloudManager, clusterState, clusterState.getCollection(collectionName),
-            message, shardNames, ocmh.overseer.getCoreContainer().getPlacementPluginFactory().createPluginInstance());
+        replicaPositions = buildReplicaPositions(ocmh.overseer.getCoreContainer(), ocmh.cloudManager, clusterState, clusterState.getCollection(collectionName),
+            message, shardNames);
       } catch (Assign.AssignmentException e) {
         ZkNodeProps deleteMessage = new ZkNodeProps("name", collectionName);
         new DeleteCollectionCmd(ocmh).call(clusterState, deleteMessage, results);
@@ -288,10 +288,10 @@ public class CreateCollectionCmd implements OverseerCollectionMessageHandler.Cmd
     }
   }
 
-  private static List<ReplicaPosition> buildReplicaPositions(SolrCloudManager cloudManager, ClusterState clusterState,
+  private static List<ReplicaPosition> buildReplicaPositions(CoreContainer coreContainer, SolrCloudManager cloudManager, ClusterState clusterState,
                                                              DocCollection docCollection,
                                                              ZkNodeProps message,
-                                                             List<String> shardNames, PlacementPlugin placementPlugin) throws IOException, InterruptedException, Assign.AssignmentException {
+                                                             List<String> shardNames) throws IOException, InterruptedException, Assign.AssignmentException {
     final String collectionName = message.getStr(NAME);
     // look at the replication factor and see if it matches reality
     // if it does not, find best nodes to create more cores
@@ -330,7 +330,7 @@ public class CreateCollectionCmd implements OverseerCollectionMessageHandler.Cmd
           .assignPullReplicas(numPullReplicas)
           .onNodes(nodeList)
           .build();
-      Assign.AssignStrategy assignStrategy = Assign.createAssignStrategy(placementPlugin, clusterState, docCollection);
+      Assign.AssignStrategy assignStrategy = Assign.createAssignStrategy(coreContainer, clusterState, docCollection);
       replicaPositions = assignStrategy.assign(cloudManager, assignRequest);
     }
     return replicaPositions;
diff --git a/solr/core/src/java/org/apache/solr/cloud/api/collections/DeleteCollectionCmd.java b/solr/core/src/java/org/apache/solr/cloud/api/collections/DeleteCollectionCmd.java
index d9b6679..8c0a1e4 100644
--- a/solr/core/src/java/org/apache/solr/cloud/api/collections/DeleteCollectionCmd.java
+++ b/solr/core/src/java/org/apache/solr/cloud/api/collections/DeleteCollectionCmd.java
@@ -92,6 +92,13 @@ public class DeleteCollectionCmd implements OverseerCollectionMessageHandler.Cmd
       collection = extCollection;
     }
 
+    // verify the placement modifications caused by the deletion are allowed
+    DocCollection coll = state.getCollectionOrNull(collection);
+    if (coll != null) {
+      Assign.AssignStrategy assignStrategy = Assign.createAssignStrategy(ocmh.overseer.getCoreContainer(), state, coll);
+      assignStrategy.verifyDeleteCollection(ocmh.cloudManager, coll);
+    }
+
     final boolean deleteHistory = message.getBool(CoreAdminParams.DELETE_METRICS_HISTORY, true);
 
     boolean removeCounterNode = true;
diff --git a/solr/core/src/java/org/apache/solr/cloud/api/collections/DeleteNodeCmd.java b/solr/core/src/java/org/apache/solr/cloud/api/collections/DeleteNodeCmd.java
index 19865d3..c69675b 100644
--- a/solr/core/src/java/org/apache/solr/cloud/api/collections/DeleteNodeCmd.java
+++ b/solr/core/src/java/org/apache/solr/cloud/api/collections/DeleteNodeCmd.java
@@ -18,6 +18,7 @@
 package org.apache.solr.cloud.api.collections;
 
 
+import java.io.IOException;
 import java.lang.invoke.MethodHandles;
 import java.util.ArrayList;
 import java.util.List;
@@ -98,7 +99,7 @@ public class DeleteNodeCmd implements OverseerCollectionMessageHandler.Cmd {
                               List<ZkNodeProps> sourceReplicas,
                               OverseerCollectionMessageHandler ocmh,
                               String node,
-                              String async) throws InterruptedException {
+                              String async) throws IOException, InterruptedException {
     CountDownLatch cleanupLatch = new CountDownLatch(sourceReplicas.size());
     for (ZkNodeProps sourceReplica : sourceReplicas) {
       String coll = sourceReplica.getStr(COLLECTION_PROP);
diff --git a/solr/core/src/java/org/apache/solr/cloud/api/collections/DeleteReplicaCmd.java b/solr/core/src/java/org/apache/solr/cloud/api/collections/DeleteReplicaCmd.java
index 4d7975d..4c5f757 100644
--- a/solr/core/src/java/org/apache/solr/cloud/api/collections/DeleteReplicaCmd.java
+++ b/solr/core/src/java/org/apache/solr/cloud/api/collections/DeleteReplicaCmd.java
@@ -23,6 +23,7 @@ import static org.apache.solr.common.params.CollectionAdminParams.COUNT_PROP;
 import static org.apache.solr.common.params.CollectionAdminParams.FOLLOW_ALIASES;
 import static org.apache.solr.common.params.CommonAdminParams.ASYNC;
 
+import java.io.IOException;
 import java.lang.invoke.MethodHandles;
 import java.util.ArrayList;
 import java.util.Collection;
@@ -31,6 +32,7 @@ import java.util.HashSet;
 import java.util.Map;
 import java.util.Set;
 import java.util.concurrent.Callable;
+import java.util.stream.Collectors;
 
 import org.apache.solr.cloud.api.collections.OverseerCollectionMessageHandler.Cmd;
 import org.apache.solr.cloud.api.collections.OverseerCollectionMessageHandler.ShardRequestTracker;
@@ -69,7 +71,7 @@ public class DeleteReplicaCmd implements Cmd {
 
   @SuppressWarnings("unchecked")
   void deleteReplica(ClusterState clusterState, ZkNodeProps message, @SuppressWarnings({"rawtypes"})NamedList results, Runnable onComplete)
-          throws KeeperException, InterruptedException {
+          throws KeeperException, IOException, InterruptedException {
     if (log.isDebugEnabled()) {
       log.debug("deleteReplica() : {}", Utils.toJSONString(message));
     }
@@ -101,9 +103,7 @@ public class DeleteReplicaCmd implements Cmd {
       throw new SolrException(SolrException.ErrorCode.BAD_REQUEST,
               "Invalid shard name : " +  shard + " in collection : " +  collectionName);
     }
-
-    deleteCore(slice, collectionName, replicaName, message, shard, results, onComplete,  parallel);
-
+    deleteCore(clusterState, coll, shard, replicaName, message, results, onComplete, parallel, true);
   }
 
 
@@ -117,7 +117,7 @@ public class DeleteReplicaCmd implements Cmd {
                                  @SuppressWarnings({"rawtypes"})NamedList results,
                                  Runnable onComplete,
                                  boolean parallel)
-          throws KeeperException, InterruptedException {
+          throws KeeperException, IOException, InterruptedException {
     ocmh.checkRequired(message, COLLECTION_PROP, COUNT_PROP);
     int count = Integer.parseInt(message.getStr(COUNT_PROP));
     String collectionName = message.getStr(COLLECTION_PROP);
@@ -147,6 +147,17 @@ public class DeleteReplicaCmd implements Cmd {
       }
     }
 
+    // verify that all replicas can be deleted
+    Assign.AssignStrategy assignStrategy = Assign.createAssignStrategy(ocmh.overseer.getCoreContainer(), clusterState, coll);
+    for (Map.Entry<Slice, Set<String>> entry : shardToReplicasMapping.entrySet()) {
+      Slice shardSlice = entry.getKey();
+      String shardId = shardSlice.getName();
+      Set<String> replicaNames = entry.getValue();
+      Set<Replica> replicas = replicaNames.stream()
+          .map(name -> shardSlice.getReplica(name)).collect(Collectors.toSet());
+      assignStrategy.verifyDeleteReplicas(ocmh.cloudManager, coll, shardId, replicas);
+    }
+
     for (Map.Entry<Slice, Set<String>> entry : shardToReplicasMapping.entrySet()) {
       Slice shardSlice = entry.getKey();
       String shardId = shardSlice.getName();
@@ -154,7 +165,8 @@ public class DeleteReplicaCmd implements Cmd {
       //callDeleteReplica on all replicas
       for (String replica: replicas) {
         log.debug("Deleting replica {}  for shard {} based on count {}", replica, shardId, count);
-        deleteCore(shardSlice, collectionName, replica, message, shard, results, onComplete, parallel);
+        // don't verify with the placement plugin - we already did it
+        deleteCore(clusterState, coll, shardId, replica, message, results, onComplete, parallel, false);
       }
       results.add("shard_id", shardId);
       results.add("replicas_deleted", replicas);
@@ -212,25 +224,39 @@ public class DeleteReplicaCmd implements Cmd {
   }
 
   @SuppressWarnings({"unchecked"})
-  void deleteCore(Slice slice, String collectionName, String replicaName,ZkNodeProps message, String shard, @SuppressWarnings({"rawtypes"})NamedList results, Runnable onComplete, boolean parallel) throws KeeperException, InterruptedException {
-
+  void deleteCore(ClusterState clusterState, DocCollection coll,
+                  String shardId,
+                  String replicaName,
+                  ZkNodeProps message,
+                  @SuppressWarnings({"rawtypes"})NamedList results,
+                  Runnable onComplete,
+                  boolean parallel,
+                  boolean verifyPlacement) throws KeeperException, IOException, InterruptedException {
+
+    Slice slice = coll.getSlice(shardId);
     Replica replica = slice.getReplica(replicaName);
     if (replica == null) {
       ArrayList<String> l = new ArrayList<>();
       for (Replica r : slice.getReplicas())
         l.add(r.getName());
       throw new SolrException(SolrException.ErrorCode.BAD_REQUEST, "Invalid replica : " +  replicaName + " in shard/collection : " +
-              shard  + "/" + collectionName + " available replicas are " +  StrUtils.join(l, ','));
+              shardId  + "/" + coll.getName() + " available replicas are " +  StrUtils.join(l, ','));
     }
 
     // If users are being safe and only want to remove a shard if it is down, they can specify onlyIfDown=true
     // on the command.
     if (Boolean.parseBoolean(message.getStr(OverseerCollectionMessageHandler.ONLY_IF_DOWN)) && replica.getState() != Replica.State.DOWN) {
       throw new SolrException(SolrException.ErrorCode.BAD_REQUEST,
-              "Attempted to remove replica : " + collectionName + "/"  + shard + "/" + replicaName +
+              "Attempted to remove replica : " + coll.getName() + "/"  + shardId + "/" + replicaName +
               " with onlyIfDown='true', but state is '" + replica.getStr(ZkStateReader.STATE_PROP) + "'");
     }
 
+    // verify that we are allowed to delete this replica
+    if (verifyPlacement) {
+      Assign.AssignStrategy assignStrategy = Assign.createAssignStrategy(ocmh.overseer.getCoreContainer(), clusterState, coll);
+      assignStrategy.verifyDeleteReplicas(ocmh.cloudManager, coll, shardId, Set.of(replica));
+    }
+
     ShardHandler shardHandler = ocmh.shardHandlerFactory.getShardHandler();
     String core = replica.getStr(ZkStateReader.CORE_NAME_PROP);
     String asyncId = message.getStr(ASYNC);
@@ -256,12 +282,12 @@ public class DeleteReplicaCmd implements Cmd {
           shardRequestTracker.processResponses(results, shardHandler, false, null);
 
           //check if the core unload removed the corenode zk entry
-          if (ocmh.waitForCoreNodeGone(collectionName, shard, replicaName, 30000)) return Boolean.TRUE;
+          if (ocmh.waitForCoreNodeGone(coll.getName(), shardId, replicaName, 30000)) return Boolean.TRUE;
         }
 
         // try and ensure core info is removed from cluster state
-        ocmh.deleteCoreNode(collectionName, replicaName, replica, core);
-        if (ocmh.waitForCoreNodeGone(collectionName, shard, replicaName, 30000)) return Boolean.TRUE;
+        ocmh.deleteCoreNode(coll.getName(), replicaName, replica, core);
+        if (ocmh.waitForCoreNodeGone(coll.getName(), shardId, replicaName, 30000)) return Boolean.TRUE;
         return Boolean.FALSE;
       } catch (Exception e) {
         results.add("failure", "Could not complete delete " + e.getMessage());
@@ -275,7 +301,7 @@ public class DeleteReplicaCmd implements Cmd {
       try {
         if (!callable.call())
           throw new SolrException(SolrException.ErrorCode.SERVER_ERROR,
-                  "Could not remove replica : " + collectionName + "/" + shard + "/" + replicaName);
+                  "Could not remove replica : " + coll.getName() + "/" + shardId + "/" + replicaName);
       } catch (InterruptedException | KeeperException e) {
         throw e;
       } catch (Exception ex) {
diff --git a/solr/core/src/java/org/apache/solr/cloud/api/collections/MigrateCmd.java b/solr/core/src/java/org/apache/solr/cloud/api/collections/MigrateCmd.java
index 85bac4b..2b094b2 100644
--- a/solr/core/src/java/org/apache/solr/cloud/api/collections/MigrateCmd.java
+++ b/solr/core/src/java/org/apache/solr/cloud/api/collections/MigrateCmd.java
@@ -304,7 +304,7 @@ public class MigrateCmd implements OverseerCollectionMessageHandler.Cmd {
     props.put(CoreAdminParams.NAME, tempCollectionReplica2);
     // copy over property params:
     for (String key : message.keySet()) {
-      if (key.startsWith(OverseerCollectionMessageHandler.COLL_PROP_PREFIX)) {
+      if (key.startsWith(CollectionAdminParams.PROPERTY_PREFIX)) {
         props.put(key, message.getStr(key));
       }
     }
diff --git a/solr/core/src/java/org/apache/solr/cloud/api/collections/OverseerCollectionMessageHandler.java b/solr/core/src/java/org/apache/solr/cloud/api/collections/OverseerCollectionMessageHandler.java
index b69d4d4..e321de1 100644
--- a/solr/core/src/java/org/apache/solr/cloud/api/collections/OverseerCollectionMessageHandler.java
+++ b/solr/core/src/java/org/apache/solr/cloud/api/collections/OverseerCollectionMessageHandler.java
@@ -127,8 +127,6 @@ public class OverseerCollectionMessageHandler implements OverseerMessageHandler,
 
   public static final String REQUESTID = "requestid";
 
-  public static final String COLL_PROP_PREFIX = "property.";
-
   public static final String ONLY_IF_DOWN = "onlyIfDown";
 
   public static final String SHARD_UNIQUE = "shardUnique";
@@ -561,7 +559,7 @@ public class OverseerCollectionMessageHandler implements OverseerMessageHandler,
   void addPropertyParams(ZkNodeProps message, ModifiableSolrParams params) {
     // Now add the property.key=value pairs
     for (String key : message.keySet()) {
-      if (key.startsWith(COLL_PROP_PREFIX)) {
+      if (key.startsWith(CollectionAdminParams.PROPERTY_PREFIX)) {
         params.set(key, message.getStr(key));
       }
     }
@@ -570,7 +568,7 @@ public class OverseerCollectionMessageHandler implements OverseerMessageHandler,
   void addPropertyParams(ZkNodeProps message, Map<String, Object> map) {
     // Now add the property.key=value pairs
     for (String key : message.keySet()) {
-      if (key.startsWith(COLL_PROP_PREFIX)) {
+      if (key.startsWith(CollectionAdminParams.PROPERTY_PREFIX)) {
         map.put(key, message.getStr(key));
       }
     }
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 271677f..7140946 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
@@ -121,7 +121,7 @@ public class ReplaceNodeCmd implements OverseerCollectionMessageHandler.Cmd {
               .onNodes(new ArrayList<>(ocmh.cloudManager.getClusterStateProvider().getLiveNodes()))
               .build();
           Assign.AssignStrategy assignStrategy = Assign.createAssignStrategy(
-              ocmh.overseer.getCoreContainer().getPlacementPluginFactory().createPluginInstance(),
+              ocmh.overseer.getCoreContainer(),
               clusterState, clusterState.getCollection(sourceCollection));
           targetNode = assignStrategy.assign(ocmh.cloudManager, assignRequest).get(0).node;
         }
diff --git a/solr/core/src/java/org/apache/solr/cloud/api/collections/RestoreCmd.java b/solr/core/src/java/org/apache/solr/cloud/api/collections/RestoreCmd.java
index c7c941a..03cb47a 100644
--- a/solr/core/src/java/org/apache/solr/cloud/api/collections/RestoreCmd.java
+++ b/solr/core/src/java/org/apache/solr/cloud/api/collections/RestoreCmd.java
@@ -230,7 +230,7 @@ public class RestoreCmd implements OverseerCollectionMessageHandler.Cmd {
             .onNodes(nodeList)
             .build();
     Assign.AssignStrategy assignStrategy = Assign.createAssignStrategy(
-        ocmh.overseer.getCoreContainer().getPlacementPluginFactory().createPluginInstance(),
+        ocmh.overseer.getCoreContainer(),
         clusterState, restoreCollection);
     List<ReplicaPosition> replicaPositions = assignStrategy.assign(ocmh.cloudManager, assignRequest);
 
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 770dfac..ff9df3b 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
@@ -30,6 +30,7 @@ import org.apache.solr.common.SolrException;
 import org.apache.solr.common.SolrException.ErrorCode;
 import org.apache.solr.common.cloud.*;
 import org.apache.solr.common.cloud.rule.ImplicitSnitch;
+import org.apache.solr.common.params.CollectionAdminParams;
 import org.apache.solr.common.params.CommonAdminParams;
 import org.apache.solr.common.params.CommonParams;
 import org.apache.solr.common.params.CoreAdminParams;
@@ -304,7 +305,7 @@ public class SplitShardCmd implements OverseerCollectionMessageHandler.Cmd {
         propMap.put(CommonAdminParams.WAIT_FOR_FINAL_STATE, Boolean.toString(waitForFinalState));
         // copy over property params:
         for (String key : message.keySet()) {
-          if (key.startsWith(OverseerCollectionMessageHandler.COLL_PROP_PREFIX)) {
+          if (key.startsWith(CollectionAdminParams.PROPERTY_PREFIX)) {
             propMap.put(key, message.getStr(key));
           }
         }
@@ -435,7 +436,7 @@ public class SplitShardCmd implements OverseerCollectionMessageHandler.Cmd {
           .onNodes(new ArrayList<>(clusterState.getLiveNodes()))
           .build();
       Assign.AssignStrategy assignStrategy = Assign.createAssignStrategy(
-          ocmh.overseer.getCoreContainer().getPlacementPluginFactory().createPluginInstance(),
+          ocmh.overseer.getCoreContainer(),
           clusterState, collection);
       List<ReplicaPosition> replicaPositions = assignStrategy.assign(ocmh.cloudManager, assignRequest);
       t.stop();
@@ -472,7 +473,7 @@ public class SplitShardCmd implements OverseerCollectionMessageHandler.Cmd {
         propMap.put(CoreAdminParams.NAME, solrCoreName);
         // copy over property params:
         for (String key : message.keySet()) {
-          if (key.startsWith(OverseerCollectionMessageHandler.COLL_PROP_PREFIX)) {
+          if (key.startsWith(CollectionAdminParams.PROPERTY_PREFIX)) {
             propMap.put(key, message.getStr(key));
           }
         }
diff --git a/solr/core/src/java/org/apache/solr/cloud/overseer/ReplicaMutator.java b/solr/core/src/java/org/apache/solr/cloud/overseer/ReplicaMutator.java
index ecf06e4..32e27c7 100644
--- a/solr/core/src/java/org/apache/solr/cloud/overseer/ReplicaMutator.java
+++ b/solr/core/src/java/org/apache/solr/cloud/overseer/ReplicaMutator.java
@@ -46,6 +46,7 @@ import org.apache.solr.common.cloud.Slice;
 import org.apache.solr.common.cloud.SolrZkClient;
 import org.apache.solr.common.cloud.ZkNodeProps;
 import org.apache.solr.common.cloud.ZkStateReader;
+import org.apache.solr.common.params.CollectionAdminParams;
 import org.apache.solr.common.util.Utils;
 import org.apache.solr.util.TestInjection;
 import org.slf4j.Logger;
@@ -121,8 +122,8 @@ public class ReplicaMutator {
     String sliceName = message.getStr(ZkStateReader.SHARD_ID_PROP);
     String replicaName = message.getStr(ZkStateReader.REPLICA_PROP);
     String property = message.getStr(ZkStateReader.PROPERTY_PROP).toLowerCase(Locale.ROOT);
-    if (StringUtils.startsWith(property, OverseerCollectionMessageHandler.COLL_PROP_PREFIX) == false) {
-      property = OverseerCollectionMessageHandler.COLL_PROP_PREFIX + property;
+    if (!StringUtils.startsWith(property, CollectionAdminParams.PROPERTY_PREFIX)) {
+      property = CollectionAdminParams.PROPERTY_PREFIX + property;
     }
     property = property.toLowerCase(Locale.ROOT);
     String propVal = message.getStr(ZkStateReader.PROPERTY_VALUE_PROP);
@@ -186,8 +187,8 @@ public class ReplicaMutator {
     String sliceName = message.getStr(ZkStateReader.SHARD_ID_PROP);
     String replicaName = message.getStr(ZkStateReader.REPLICA_PROP);
     String property = message.getStr(ZkStateReader.PROPERTY_PROP).toLowerCase(Locale.ROOT);
-    if (StringUtils.startsWith(property, OverseerCollectionMessageHandler.COLL_PROP_PREFIX) == false) {
-      property = OverseerCollectionMessageHandler.COLL_PROP_PREFIX + property;
+    if (StringUtils.startsWith(property, CollectionAdminParams.PROPERTY_PREFIX) == false) {
+      property = CollectionAdminParams.PROPERTY_PREFIX + property;
     }
 
     DocCollection collection = clusterState.getCollection(collectionName);
@@ -319,7 +320,7 @@ public class ReplicaMutator {
         replicaProps.put(ZkStateReader.REPLICA_TYPE, oldReplica.getType().toString());
         // Move custom props over.
         for (Map.Entry<String, Object> ent : oldReplica.getProperties().entrySet()) {
-          if (ent.getKey().startsWith(OverseerCollectionMessageHandler.COLL_PROP_PREFIX)) {
+          if (ent.getKey().startsWith(CollectionAdminParams.PROPERTY_PREFIX)) {
             replicaProps.put(ent.getKey(), ent.getValue());
           }
         }
diff --git a/solr/core/src/java/org/apache/solr/cloud/overseer/SliceMutator.java b/solr/core/src/java/org/apache/solr/cloud/overseer/SliceMutator.java
index 019339e..2594ee4 100644
--- a/solr/core/src/java/org/apache/solr/cloud/overseer/SliceMutator.java
+++ b/solr/core/src/java/org/apache/solr/cloud/overseer/SliceMutator.java
@@ -28,7 +28,6 @@ import org.apache.solr.client.solrj.cloud.SolrCloudManager;
 import org.apache.solr.client.solrj.impl.SolrClientCloudManager;
 import org.apache.solr.cloud.Overseer;
 import org.apache.solr.cloud.api.collections.Assign;
-import org.apache.solr.cloud.api.collections.OverseerCollectionMessageHandler;
 import org.apache.solr.common.cloud.ClusterState;
 import org.apache.solr.common.cloud.DocCollection;
 import org.apache.solr.common.cloud.PerReplicaStatesOps;
@@ -40,6 +39,7 @@ import org.apache.solr.common.cloud.SolrZkClient;
 import org.apache.solr.common.cloud.ZkCoreNodeProps;
 import org.apache.solr.common.cloud.ZkNodeProps;
 import org.apache.solr.common.cloud.ZkStateReader;
+import org.apache.solr.common.params.CollectionAdminParams;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
@@ -49,7 +49,7 @@ import static org.apache.solr.common.util.Utils.makeMap;
 public class SliceMutator {
   private static final Logger log = LoggerFactory.getLogger(MethodHandles.lookup().lookupClass());
 
-  public static final String PREFERRED_LEADER_PROP = OverseerCollectionMessageHandler.COLL_PROP_PREFIX + "preferredleader";
+  public static final String PREFERRED_LEADER_PROP = CollectionAdminParams.PROPERTY_PREFIX + "preferredleader";
 
   public static final Set<String> SLICE_UNIQUE_BOOLEAN_PROPERTIES = ImmutableSet.of(PREFERRED_LEADER_PROP);
 
diff --git a/solr/core/src/java/org/apache/solr/cluster/events/impl/CollectionsRepairEventListener.java b/solr/core/src/java/org/apache/solr/cluster/events/impl/CollectionsRepairEventListener.java
index 8984d1d..be4533b 100644
--- a/solr/core/src/java/org/apache/solr/cluster/events/impl/CollectionsRepairEventListener.java
+++ b/solr/core/src/java/org/apache/solr/cluster/events/impl/CollectionsRepairEventListener.java
@@ -41,8 +41,6 @@ import org.apache.solr.cloud.api.collections.Assign;
 import org.apache.solr.cluster.events.ClusterEvent;
 import org.apache.solr.cluster.events.ClusterEventListener;
 import org.apache.solr.cluster.events.NodesDownEvent;
-import org.apache.solr.cluster.placement.PlacementPluginConfig;
-import org.apache.solr.cluster.placement.PlacementPluginFactory;
 import org.apache.solr.common.cloud.ClusterState;
 import org.apache.solr.common.cloud.Replica;
 import org.apache.solr.common.cloud.ReplicaPosition;
@@ -72,18 +70,18 @@ public class CollectionsRepairEventListener implements ClusterEventListener, Clu
 
   private final SolrClient solrClient;
   private final SolrCloudManager solrCloudManager;
+  private final CoreContainer cc;
 
   private State state = State.STOPPED;
 
   private int waitForSecond = DEFAULT_WAIT_FOR_SEC;
 
   private ScheduledThreadPoolExecutor waitForExecutor;
-  private final PlacementPluginFactory<? extends PlacementPluginConfig> placementPluginFactory;
 
   public CollectionsRepairEventListener(CoreContainer cc) {
+    this.cc = cc;
     this.solrClient = cc.getSolrClientCache().getCloudSolrClient(cc.getZkController().getZkClient().getZkServerAddress());
     this.solrCloudManager = cc.getZkController().getSolrCloudManager();
-    this.placementPluginFactory = cc.getPlacementPluginFactory();
   }
 
   @VisibleForTesting
@@ -169,7 +167,7 @@ public class CollectionsRepairEventListener implements ClusterEventListener, Clu
                 .incrementAndGet();
           }
         });
-        Assign.AssignStrategy assignStrategy = Assign.createAssignStrategy(placementPluginFactory.createPluginInstance(), clusterState, coll);
+        Assign.AssignStrategy assignStrategy = Assign.createAssignStrategy(cc, clusterState, coll);
         lostReplicas.forEach((shard, types) -> {
           Assign.AssignRequestBuilder assignRequestBuilder = new Assign.AssignRequestBuilder()
               .forCollection(coll.getName())
diff --git a/solr/core/src/java/org/apache/solr/cluster/placement/DeleteCollectionRequest.java b/solr/core/src/java/org/apache/solr/cluster/placement/DeleteCollectionRequest.java
new file mode 100644
index 0000000..fdd3f8b
--- /dev/null
+++ b/solr/core/src/java/org/apache/solr/cluster/placement/DeleteCollectionRequest.java
@@ -0,0 +1,23 @@
+/*
+ * 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.cluster.placement;
+
+/**
+ * Delete collection request.
+ */
+public interface DeleteCollectionRequest extends ModificationRequest {
+}
diff --git a/solr/core/src/java/org/apache/solr/cluster/placement/DeleteReplicasRequest.java b/solr/core/src/java/org/apache/solr/cluster/placement/DeleteReplicasRequest.java
new file mode 100644
index 0000000..3ce755d
--- /dev/null
+++ b/solr/core/src/java/org/apache/solr/cluster/placement/DeleteReplicasRequest.java
@@ -0,0 +1,29 @@
+/*
+ * 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.cluster.placement;
+
+import org.apache.solr.cluster.Replica;
+
+import java.util.Set;
+
+/**
+ * Delete replicas request.
+ */
+public interface DeleteReplicasRequest extends ModificationRequest {
+  Set<Replica> getReplicas();
+}
diff --git a/solr/core/src/java/org/apache/solr/cluster/placement/DeleteShardsRequest.java b/solr/core/src/java/org/apache/solr/cluster/placement/DeleteShardsRequest.java
new file mode 100644
index 0000000..3638223
--- /dev/null
+++ b/solr/core/src/java/org/apache/solr/cluster/placement/DeleteShardsRequest.java
@@ -0,0 +1,27 @@
+/*
+ * 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.cluster.placement;
+
+import java.util.Set;
+
+/**
+ * Delete shards request.
+ */
+public interface DeleteShardsRequest extends ModificationRequest {
+  Set<String> getShardNames();
+}
diff --git a/solr/core/src/java/org/apache/solr/cluster/placement/ModificationRequest.java b/solr/core/src/java/org/apache/solr/cluster/placement/ModificationRequest.java
new file mode 100644
index 0000000..4aed2ba
--- /dev/null
+++ b/solr/core/src/java/org/apache/solr/cluster/placement/ModificationRequest.java
@@ -0,0 +1,30 @@
+/*
+ * 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.cluster.placement;
+
+import org.apache.solr.cluster.SolrCollection;
+
+/**
+ * Collection modification request.
+ */
+public interface ModificationRequest {
+  /**
+   * The {@link SolrCollection} to modify.
+   */
+  SolrCollection getCollection();
+}
diff --git a/solr/core/src/java/org/apache/solr/cluster/placement/PlacementContext.java b/solr/core/src/java/org/apache/solr/cluster/placement/PlacementContext.java
new file mode 100644
index 0000000..617f6f6
--- /dev/null
+++ b/solr/core/src/java/org/apache/solr/cluster/placement/PlacementContext.java
@@ -0,0 +1,44 @@
+/*
+ * 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.cluster.placement;
+
+import org.apache.solr.cluster.Cluster;
+
+/**
+ * Placement context makes it easier to pass around and access main placement-related components.
+ */
+public interface PlacementContext {
+  /**
+   * Initial state of the cluster. Note there are {@link java.util.Set}'s and {@link java.util.Map}'s
+   * accessible from the {@link Cluster} and other reachable instances. These collection will not change
+   * while the plugin is executing and will be thrown away once the plugin is done. The plugin code can
+   * therefore modify them if needed.
+   */
+  Cluster getCluster();
+
+  /**
+   * Factory used by the plugin to fetch additional attributes from the cluster nodes, such as
+   * count of cores, system properties etc..
+   */
+  AttributeFetcher getAttributeFetcher();
+
+  /**
+   * Factory used to create instances of {@link PlacementPlan} to return computed decision.
+   */
+  PlacementPlanFactory getPlacementPlanFactory();
+}
diff --git a/solr/core/src/java/org/apache/solr/cluster/placement/PlacementModificationException.java b/solr/core/src/java/org/apache/solr/cluster/placement/PlacementModificationException.java
new file mode 100644
index 0000000..d9162e1
--- /dev/null
+++ b/solr/core/src/java/org/apache/solr/cluster/placement/PlacementModificationException.java
@@ -0,0 +1,62 @@
+package org.apache.solr.cluster.placement;
+
+import java.util.HashMap;
+import java.util.Map;
+
+/**
+ * Exception thrown when a placement modification is rejected by the placement plugin.
+ * Additional details about the reasons are provided if available
+ * in {@link #getRejectedModifications()} or in the {@link #toString()} methods.
+ */
+public class PlacementModificationException extends PlacementException {
+  private final Map<String, String> rejectedModifications = new HashMap<>();
+
+  public PlacementModificationException() {
+    super();
+  }
+
+  public PlacementModificationException(String message) {
+    super(message);
+  }
+
+  public PlacementModificationException(String message, Throwable cause) {
+    super(message, cause);
+  }
+
+  public PlacementModificationException(Throwable cause) {
+    super(cause);
+  }
+
+  /**
+   * Add information about the modification that cause this exception.
+   * @param modification requested modification details
+   * @param reason reason for rejection
+   */
+  public void addRejectedModification(String modification, String reason) {
+    rejectedModifications.put(modification, reason);
+  }
+
+  /**
+   * Return rejected modifications and reasons for rejections.
+   */
+  public Map<String, String> getRejectedModifications() {
+    return rejectedModifications;
+  }
+
+  @Override
+  public String toString() {
+    StringBuilder sb = new StringBuilder(super.toString());
+    if (!rejectedModifications.isEmpty()) {
+        sb.append(": ")
+          .append(rejectedModifications.size())
+          .append(" rejections:");
+      rejectedModifications.forEach((modification, reason) ->
+          sb.append("\n")
+              .append(modification)
+              .append("\t")
+              .append(reason));
+
+    }
+    return sb.toString();
+  }
+}
diff --git a/solr/core/src/java/org/apache/solr/cluster/placement/PlacementPlugin.java b/solr/core/src/java/org/apache/solr/cluster/placement/PlacementPlugin.java
index bbb52cb..97d1957 100644
--- a/solr/core/src/java/org/apache/solr/cluster/placement/PlacementPlugin.java
+++ b/solr/core/src/java/org/apache/solr/cluster/placement/PlacementPlugin.java
@@ -17,8 +17,6 @@
 
 package org.apache.solr.cluster.placement;
 
-import org.apache.solr.cluster.Cluster;
-
 /**
  * <p>Implemented by external plugins to control replica placement and movement on the search cluster (as well as other things
  * such as cluster elasticity?) when cluster changes are required (initiated elsewhere, most likely following a Collection
@@ -36,16 +34,21 @@ public interface PlacementPlugin {
    *
    * <p>Configuration is passed upon creation of a new instance of this class by {@link PlacementPluginFactory#createPluginInstance}.
    *
-   * @param cluster              initial state of the cluster. Note there are {@link java.util.Set}'s and {@link java.util.Map}'s
-   *                             accessible from the {@link Cluster} and other reachable instances. These collection will not change
-   *                             while the plugin is executing and will be thrown away once the plugin is done. The plugin code can
-   *                             therefore modify them if needed.
    * @param placementRequest     request for placing new replicas or moving existing replicas on the cluster.
-   * @param attributeFetcher     Factory used by the plugin to fetch additional attributes from the cluster nodes, such as
-   *                             count of coresm ssytem properties etc..
-   * @param placementPlanFactory Factory used to create instances of {@link PlacementPlan} to return computed decision.
    * @return plan satisfying the placement request.
    */
-  PlacementPlan computePlacement(Cluster cluster, PlacementRequest placementRequest, AttributeFetcher attributeFetcher,
-                                 PlacementPlanFactory placementPlanFactory) throws PlacementException, InterruptedException;
+  PlacementPlan computePlacement(PlacementRequest placementRequest, PlacementContext placementContext) throws PlacementException, InterruptedException;
+
+  /**
+   * Verify that a collection layout modification doesn't violate constraints on replica placements
+   * required by this plugin. Default implementation is a no-op (any modifications are allowed).
+   * @param modificationRequest modification request.
+   * @param placementContext placement context.
+   * @throws PlacementModificationException if the requested modification would violate replica
+   * placement constraints.
+   */
+  default void verifyAllowedModification(ModificationRequest modificationRequest, PlacementContext placementContext)
+    throws PlacementModificationException, InterruptedException {
+
+  }
 }
diff --git a/solr/core/src/java/org/apache/solr/cluster/placement/PlacementRequest.java b/solr/core/src/java/org/apache/solr/cluster/placement/PlacementRequest.java
index 44222a2..0ece962 100644
--- a/solr/core/src/java/org/apache/solr/cluster/placement/PlacementRequest.java
+++ b/solr/core/src/java/org/apache/solr/cluster/placement/PlacementRequest.java
@@ -30,12 +30,7 @@ import java.util.Set;
  * <p>The set of {@link Node}s on which the replicas should be placed
  * is specified (defaults to being equal to the set returned by {@link Cluster#getLiveNodes()}).
  */
-public interface PlacementRequest {
-  /**
-   * The {@link SolrCollection} to add {@link Replica}(s) to.
-   */
-  SolrCollection getCollection();
-
+public interface PlacementRequest extends ModificationRequest {
   /**
    * <p>Shard name(s) for which new replicas placement should be computed. The shard(s) might exist or not (that's why this
    * method returns a {@link Set} of {@link String}'s and not directly a set of {@link Shard} instances).
diff --git a/solr/core/src/java/org/apache/solr/cluster/placement/impl/ModificationRequestImpl.java b/solr/core/src/java/org/apache/solr/cluster/placement/impl/ModificationRequestImpl.java
new file mode 100644
index 0000000..1bfdc3e
--- /dev/null
+++ b/solr/core/src/java/org/apache/solr/cluster/placement/impl/ModificationRequestImpl.java
@@ -0,0 +1,109 @@
+/*
+ * 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.cluster.placement.impl;
+
+import org.apache.solr.cluster.Replica;
+import org.apache.solr.cluster.Shard;
+import org.apache.solr.cluster.SolrCollection;
+import org.apache.solr.cluster.placement.DeleteCollectionRequest;
+import org.apache.solr.cluster.placement.DeleteReplicasRequest;
+import org.apache.solr.cluster.placement.DeleteShardsRequest;
+import org.apache.solr.common.cloud.DocCollection;
+import org.apache.solr.common.cloud.Slice;
+
+import java.util.HashSet;
+import java.util.Set;
+
+/**
+ * Helper class to create modification request instances.
+ */
+public class ModificationRequestImpl {
+
+  public static DeleteCollectionRequest createDeleteCollectionRequest(DocCollection docCollection) {
+    SolrCollection solrCollection = SimpleClusterAbstractionsImpl.SolrCollectionImpl.fromDocCollection(docCollection);
+    return () -> solrCollection;
+  }
+
+  /**
+   * Create a delete replicas request.
+   * @param collection collection to delete replicas from
+   * @param replicas replicas to delete
+   */
+  public static DeleteReplicasRequest createDeleteReplicasRequest(SolrCollection collection, Set<Replica> replicas) {
+    return new DeleteReplicasRequest() {
+      @Override
+      public Set<Replica> getReplicas() {
+        return replicas;
+      }
+
+      @Override
+      public SolrCollection getCollection() {
+        return collection;
+      }
+
+      @Override
+      public String toString() {
+        return "DeleteReplicasRequest{collection=" + collection.getName() +
+            ",replicas=" + replicas;
+      }
+    };
+  }
+
+  /**
+   * Create a delete replicas request using the internal Solr API.
+   * @param docCollection Solr collection.
+   * @param shardName shard name.
+   * @param replicas Solr replicas (belonging to the shard).
+   */
+  public static DeleteReplicasRequest createDeleteReplicasRequest(DocCollection docCollection, String shardName, Set<org.apache.solr.common.cloud.Replica> replicas) {
+    SolrCollection solrCollection = SimpleClusterAbstractionsImpl.SolrCollectionImpl.fromDocCollection(docCollection);
+    Shard shard = solrCollection.getShard(shardName);
+    Slice slice = docCollection.getSlice(shardName);
+    Set<Replica> solrReplicas = new HashSet<>();
+    replicas.forEach(replica -> {
+      solrReplicas.add(shard.getReplica(replica.getName()));
+    });
+    return createDeleteReplicasRequest(solrCollection, solrReplicas);
+  }
+
+
+  public static DeleteShardsRequest createDeleteShardsRequest(SolrCollection collection, Set<String> shardNames) {
+    return new DeleteShardsRequest() {
+      @Override
+      public Set<String> getShardNames() {
+        return shardNames;
+      }
+
+      @Override
+      public SolrCollection getCollection() {
+        return collection;
+      }
+
+      @Override
+      public String toString() {
+        return "DeleteShardsRequest{collection=" + collection.getName() +
+            ",shards=" + shardNames;
+      }
+    };
+  }
+
+  public static DeleteShardsRequest createDeleteShardsRequest(DocCollection docCollection, Set<String> shardNames) {
+    SolrCollection solrCollection = SimpleClusterAbstractionsImpl.SolrCollectionImpl.fromDocCollection(docCollection);
+    return createDeleteShardsRequest(solrCollection, shardNames);
+  }
+}
diff --git a/solr/core/src/java/org/apache/solr/cluster/placement/impl/PlacementPluginAssignStrategy.java b/solr/core/src/java/org/apache/solr/cluster/placement/impl/PlacementPluginAssignStrategy.java
index c4c5667..32abe84 100644
--- a/solr/core/src/java/org/apache/solr/cluster/placement/impl/PlacementPluginAssignStrategy.java
+++ b/solr/core/src/java/org/apache/solr/cluster/placement/impl/PlacementPluginAssignStrategy.java
@@ -19,15 +19,19 @@ package org.apache.solr.cluster.placement.impl;
 
 import java.io.IOException;
 import java.util.List;
+import java.util.Set;
 
 import org.apache.solr.client.solrj.cloud.SolrCloudManager;
 import org.apache.solr.cloud.api.collections.Assign;
-import org.apache.solr.cluster.Cluster;
 import org.apache.solr.cluster.SolrCollection;
+import org.apache.solr.cluster.placement.DeleteCollectionRequest;
+import org.apache.solr.cluster.placement.DeleteReplicasRequest;
+import org.apache.solr.cluster.placement.PlacementContext;
 import org.apache.solr.cluster.placement.PlacementException;
 import org.apache.solr.cluster.placement.PlacementPlugin;
 import org.apache.solr.cluster.placement.PlacementPlan;
 import org.apache.solr.common.cloud.DocCollection;
+import org.apache.solr.common.cloud.Replica;
 import org.apache.solr.common.cloud.ReplicaPosition;
 
 /**
@@ -35,8 +39,6 @@ import org.apache.solr.common.cloud.ReplicaPosition;
  */
 public class PlacementPluginAssignStrategy implements Assign.AssignStrategy {
 
-  private static final PlacementPlanFactoryImpl PLACEMENT_PLAN_FACTORY = new PlacementPlanFactoryImpl();
-
   private final PlacementPlugin plugin;
   private final DocCollection collection;
 
@@ -53,18 +55,40 @@ public class PlacementPluginAssignStrategy implements Assign.AssignStrategy {
   public List<ReplicaPosition> assign(SolrCloudManager solrCloudManager, Assign.AssignRequest assignRequest)
       throws Assign.AssignmentException, IOException, InterruptedException {
 
-    Cluster cluster = new SimpleClusterAbstractionsImpl.ClusterImpl(solrCloudManager);
-    SolrCollection solrCollection = new SimpleClusterAbstractionsImpl.SolrCollectionImpl(collection);
+    PlacementContext placementContext = new SimplePlacementContextImpl(solrCloudManager);
+    SolrCollection solrCollection = placementContext.getCluster().getCollection(collection.getName());
 
-    PlacementRequestImpl placementRequest = PlacementRequestImpl.toPlacementRequest(cluster, solrCollection, assignRequest);
+    PlacementRequestImpl placementRequest = PlacementRequestImpl.toPlacementRequest(placementContext.getCluster(), solrCollection, assignRequest);
 
     final PlacementPlan placementPlan;
     try {
-      placementPlan = plugin.computePlacement(cluster, placementRequest, new AttributeFetcherImpl(solrCloudManager), PLACEMENT_PLAN_FACTORY);
+      placementPlan = plugin.computePlacement(placementRequest, placementContext);
     } catch (PlacementException pe) {
       throw new Assign.AssignmentException(pe);
     }
 
     return ReplicaPlacementImpl.toReplicaPositions(placementPlan.getReplicaPlacements());
   }
+
+  @Override
+  public void verifyDeleteCollection(SolrCloudManager solrCloudManager, DocCollection collection) throws Assign.AssignmentException, IOException, InterruptedException {
+    PlacementContext placementContext = new SimplePlacementContextImpl(solrCloudManager);
+    DeleteCollectionRequest modificationRequest = ModificationRequestImpl.createDeleteCollectionRequest(collection);
+    try {
+      plugin.verifyAllowedModification(modificationRequest, placementContext);
+    } catch (PlacementException pe) {
+      throw new Assign.AssignmentException(pe);
+    }
+  }
+
+  @Override
+  public void verifyDeleteReplicas(SolrCloudManager solrCloudManager, DocCollection collection, String shardId, Set<Replica> replicas) throws Assign.AssignmentException, IOException, InterruptedException {
+    PlacementContext placementContext = new SimplePlacementContextImpl(solrCloudManager);
+    DeleteReplicasRequest modificationRequest = ModificationRequestImpl.createDeleteReplicasRequest(collection, shardId, replicas);
+    try {
+      plugin.verifyAllowedModification(modificationRequest, placementContext);
+    } catch (PlacementException pe) {
+      throw new Assign.AssignmentException(pe);
+    }
+  }
 }
diff --git a/solr/core/src/java/org/apache/solr/cluster/placement/impl/ReplicaMetricImpl.java b/solr/core/src/java/org/apache/solr/cluster/placement/impl/ReplicaMetricImpl.java
index f793a64..1d822b2 100644
--- a/solr/core/src/java/org/apache/solr/cluster/placement/impl/ReplicaMetricImpl.java
+++ b/solr/core/src/java/org/apache/solr/cluster/placement/impl/ReplicaMetricImpl.java
@@ -26,9 +26,12 @@ import java.util.function.Function;
  */
 public class ReplicaMetricImpl<T> extends MetricImpl<T> implements ReplicaMetric<T> {
 
+  /** Replica index size in GB. */
   public static final ReplicaMetricImpl<Double> INDEX_SIZE_GB = new ReplicaMetricImpl<>("sizeGB", "INDEX.sizeInBytes", BYTES_TO_GB_CONVERTER);
 
+  /** 1-min query rate of the /select handler. */
   public static final ReplicaMetricImpl<Double> QUERY_RATE_1MIN = new ReplicaMetricImpl<>("queryRate", "QUERY./select.requestTimes:1minRate");
+  /** 1-min update rate of the /update handler. */
   public static final ReplicaMetricImpl<Double> UPDATE_RATE_1MIN = new ReplicaMetricImpl<>("updateRate", "UPDATE./update.requestTimes:1minRate");
 
   public ReplicaMetricImpl(String name, String internalName) {
diff --git a/solr/core/src/java/org/apache/solr/cluster/placement/impl/SimplePlacementContextImpl.java b/solr/core/src/java/org/apache/solr/cluster/placement/impl/SimplePlacementContextImpl.java
new file mode 100644
index 0000000..6850a95
--- /dev/null
+++ b/solr/core/src/java/org/apache/solr/cluster/placement/impl/SimplePlacementContextImpl.java
@@ -0,0 +1,40 @@
+package org.apache.solr.cluster.placement.impl;
+
+import org.apache.solr.client.solrj.cloud.SolrCloudManager;
+import org.apache.solr.cluster.Cluster;
+import org.apache.solr.cluster.placement.AttributeFetcher;
+import org.apache.solr.cluster.placement.PlacementContext;
+import org.apache.solr.cluster.placement.PlacementPlanFactory;
+
+import java.io.IOException;
+
+/**
+ * Implementation of {@link PlacementContext} that uses {@link SimpleClusterAbstractionsImpl}
+ * to create components necessary for the placement plugins to use.
+ */
+public class SimplePlacementContextImpl implements PlacementContext {
+
+  private final Cluster cluster;
+  private final AttributeFetcher attributeFetcher;
+  private final PlacementPlanFactory placementPlanFactory = new PlacementPlanFactoryImpl();
+
+  public SimplePlacementContextImpl(SolrCloudManager solrCloudManager) throws IOException {
+    cluster = new SimpleClusterAbstractionsImpl.ClusterImpl(solrCloudManager);
+    attributeFetcher = new AttributeFetcherImpl(solrCloudManager);
+  }
+
+  @Override
+  public Cluster getCluster() {
+    return cluster;
+  }
+
+  @Override
+  public AttributeFetcher getAttributeFetcher() {
+    return attributeFetcher;
+  }
+
+  @Override
+  public PlacementPlanFactory getPlacementPlanFactory() {
+    return placementPlanFactory;
+  }
+}
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 bbf8dc8..b45e6a9 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
@@ -20,12 +20,15 @@ package org.apache.solr.cluster.placement.plugins;
 import org.apache.solr.cluster.placement.PlacementPluginConfig;
 import org.apache.solr.common.annotation.JsonProperty;
 
+import java.util.Map;
+import java.util.Objects;
+
 /**
  * Configuration bean for {@link AffinityPlacementFactory}.
  */
 public class AffinityPlacementConfig implements PlacementPluginConfig {
 
-  public static final AffinityPlacementConfig DEFAULT = new AffinityPlacementConfig();
+  public static final AffinityPlacementConfig DEFAULT = new AffinityPlacementConfig(20L, 100L);
 
   /**
    * If a node has strictly less GB of free disk than this value, the node is excluded from assignment decisions.
@@ -43,14 +46,43 @@ public class AffinityPlacementConfig implements PlacementPluginConfig {
   @JsonProperty
   public long prioritizedFreeDiskGB;
 
-  // no-arg public constructor required for deserialization
+  /**
+   * This property defines an additional constraint that primary collections (keys) should be
+   * located on the same nodes as 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.
+   */
+  @JsonProperty
+  public Map<String, String> withCollections;
+
+  /**
+   * Zero-arguments public constructor required for deserialization - don't use.
+   */
   public AffinityPlacementConfig() {
-    minimalFreeDiskGB = 20L;
-    prioritizedFreeDiskGB = 100L;
+    this(0L, 0L);
   }
 
+  /**
+   * Configuration for the {@link AffinityPlacementFactory}.
+   * @param minimalFreeDiskGB minimal free disk GB.
+   * @param prioritizedFreeDiskGB prioritized free disk GB.
+   */
   public AffinityPlacementConfig(long minimalFreeDiskGB, long prioritizedFreeDiskGB) {
+    this(minimalFreeDiskGB, prioritizedFreeDiskGB, Map.of());
+  }
+
+  /**
+   * Configuration for the {@link AffinityPlacementFactory}.
+   * @param minimalFreeDiskGB minimal free disk GB.
+   * @param prioritizedFreeDiskGB prioritized free disk GB.
+   * @param withCollections configuration of co-located collections: keys are
+   *                        primary collection names and values are secondary
+   *                        collection names.
+   */
+  public AffinityPlacementConfig(long minimalFreeDiskGB, long prioritizedFreeDiskGB, Map<String, String> withCollections) {
     this.minimalFreeDiskGB = minimalFreeDiskGB;
     this.prioritizedFreeDiskGB = prioritizedFreeDiskGB;
+    Objects.requireNonNull(withCollections);
+    this.withCollections = withCollections;
   }
 }
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 9c50289..79d1f92 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
@@ -27,8 +27,10 @@ import org.apache.solr.common.util.SuppressForbidden;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
+import java.io.IOException;
 import java.lang.invoke.MethodHandles;
 import java.util.*;
+import java.util.concurrent.atomic.AtomicInteger;
 import java.util.stream.Collectors;
 
 /**
@@ -147,7 +149,7 @@ public class AffinityPlacementFactory implements PlacementPluginFactory<Affinity
 
   @Override
   public PlacementPlugin createPluginInstance() {
-    return new AffinityPlacementPlugin(config.minimalFreeDiskGB, config.prioritizedFreeDiskGB);
+    return new AffinityPlacementPlugin(config.minimalFreeDiskGB, config.prioritizedFreeDiskGB, config.withCollections);
   }
 
   @Override
@@ -171,14 +173,29 @@ public class AffinityPlacementFactory implements PlacementPluginFactory<Affinity
 
     private final long prioritizedFreeDiskGB;
 
+    // primary to secondary (1:1)
+    private final Map<String, String> withCollections;
+    // secondary to primary (1:N)
+    private final Map<String, Set<String>> colocatedWith;
+
     private final Random replicaPlacementRandom = new Random(); // ok even if random sequence is predictable.
 
     /**
      * The factory has decoded the configuration for the plugin instance and passes it the parameters it needs.
      */
-    private AffinityPlacementPlugin(long minimalFreeDiskGB, long prioritizedFreeDiskGB) {
+    private AffinityPlacementPlugin(long minimalFreeDiskGB, long prioritizedFreeDiskGB, Map<String, String> withCollections) {
       this.minimalFreeDiskGB = minimalFreeDiskGB;
       this.prioritizedFreeDiskGB = prioritizedFreeDiskGB;
+      Objects.requireNonNull(withCollections, "withCollections must not be null");
+      this.withCollections = withCollections;
+      if (withCollections.isEmpty()) {
+        colocatedWith = Map.of();
+      } else {
+        colocatedWith = new HashMap<>();
+        withCollections.forEach((primary, secondary) ->
+            colocatedWith.computeIfAbsent(secondary, s -> new HashSet<>())
+                .add(primary));
+      }
 
       // We make things reproducible in tests by using test seed if any
       String seed = System.getProperty("tests.seed");
@@ -187,13 +204,16 @@ public class AffinityPlacementFactory implements PlacementPluginFactory<Affinity
       }
     }
 
+    @Override
     @SuppressForbidden(reason = "Ordering.arbitrary() has no equivalent in Comparator class. Rather reuse than copy.")
-    public PlacementPlan computePlacement(Cluster cluster, PlacementRequest request, AttributeFetcher attributeFetcher,
-                                          PlacementPlanFactory placementPlanFactory) throws PlacementException {
+    public PlacementPlan computePlacement(PlacementRequest request, PlacementContext placementContext) throws PlacementException {
       Set<Node> nodes = request.getTargetNodes();
       SolrCollection solrCollection = request.getCollection();
 
+      nodes = filterNodesWithCollection(placementContext.getCluster(), request, nodes);
+
       // Request all needed attributes
+      AttributeFetcher attributeFetcher = placementContext.getAttributeFetcher();
       attributeFetcher.requestNodeSystemProperty(AVAILABILITY_ZONE_SYSPROP).requestNodeSystemProperty(REPLICA_TYPE_SYSPROP);
       attributeFetcher
           .requestNodeMetric(NodeMetricImpl.NUM_CORES)
@@ -238,11 +258,94 @@ public class AffinityPlacementFactory implements PlacementPluginFactory<Affinity
         // failure. Current code does fail if placement is impossible (constraint is at most one replica of a shard on any node).
         for (Replica.ReplicaType replicaType : Replica.ReplicaType.values()) {
           makePlacementDecisions(solrCollection, shardName, availabilityZones, replicaType, request.getCountReplicasToCreate(replicaType),
-              attrValues, replicaTypeToNodes, nodesWithReplicas, coresOnNodes, placementPlanFactory, replicaPlacements);
+              attrValues, replicaTypeToNodes, nodesWithReplicas, coresOnNodes, placementContext.getPlacementPlanFactory(), replicaPlacements);
         }
       }
 
-      return placementPlanFactory.createPlacementPlan(request, replicaPlacements);
+      return placementContext.getPlacementPlanFactory().createPlacementPlan(request, replicaPlacements);
+    }
+
+    @Override
+    public void verifyAllowedModification(ModificationRequest modificationRequest, PlacementContext placementContext) throws PlacementModificationException, InterruptedException {
+      if (modificationRequest instanceof DeleteShardsRequest) {
+        log.warn("DeleteShardsRequest not implemented yet, skipping: {}", modificationRequest);
+      } else if (modificationRequest instanceof DeleteCollectionRequest) {
+        verifyDeleteCollection((DeleteCollectionRequest) modificationRequest, placementContext);
+      } else if (modificationRequest instanceof DeleteReplicasRequest) {
+        verifyDeleteReplicas((DeleteReplicasRequest) modificationRequest, placementContext);
+      } else {
+        log.warn("unsupported request type, skipping: {}", modificationRequest);
+      }
+    }
+
+    private void verifyDeleteCollection(DeleteCollectionRequest deleteCollectionRequest, PlacementContext placementContext) throws PlacementModificationException, InterruptedException {
+      Cluster cluster = placementContext.getCluster();
+      Set<String> colocatedCollections = colocatedWith.getOrDefault(deleteCollectionRequest.getCollection().getName(), Set.of());
+      for (String primaryName : colocatedCollections) {
+        try {
+          if (cluster.getCollection(primaryName) != null) {
+            // still exists
+            throw new PlacementModificationException("colocated collection " + primaryName +
+                " of " + deleteCollectionRequest.getCollection().getName() + " still present");
+          }
+        } catch (IOException e) {
+          throw new PlacementModificationException("failed to retrieve colocated collection information", e);
+        }
+      }
+    }
+
+    private void verifyDeleteReplicas(DeleteReplicasRequest deleteReplicasRequest, PlacementContext placementContext) throws PlacementModificationException, InterruptedException {
+      Cluster cluster = placementContext.getCluster();
+      SolrCollection secondaryCollection = deleteReplicasRequest.getCollection();
+      Set<String> colocatedCollections = colocatedWith.get(secondaryCollection.getName());
+      if (colocatedCollections == null) {
+        return;
+      }
+      Map<Node, Map<String, AtomicInteger>> secondaryNodeShardReplicas = new HashMap<>();
+      secondaryCollection.shards().forEach(shard ->
+          shard.replicas().forEach(replica -> {
+            secondaryNodeShardReplicas.computeIfAbsent(replica.getNode(), n -> new HashMap<>())
+                .computeIfAbsent(replica.getShard().getShardName(), s -> new AtomicInteger())
+                .incrementAndGet();
+          }));
+
+      // find the colocated-with collections
+      Map<Node, Set<String>> colocatingNodes = new HashMap<>();
+      try {
+        for (String colocatedCollection : colocatedCollections) {
+          SolrCollection coll = cluster.getCollection(colocatedCollection);
+          coll.shards().forEach(shard ->
+              shard.replicas().forEach(replica -> {
+                colocatingNodes.computeIfAbsent(replica.getNode(), n -> new HashSet<>())
+                    .add(coll.getName());
+              }));
+        }
+      } catch (IOException ioe) {
+        throw new PlacementModificationException("failed to retrieve colocated collection information", ioe);
+      }
+      PlacementModificationException exception = null;
+      for (Replica replica : deleteReplicasRequest.getReplicas()) {
+        if (!colocatingNodes.containsKey(replica.getNode())) {
+          continue;
+        }
+        // check that there will be at least one replica remaining
+        AtomicInteger secondaryCount = secondaryNodeShardReplicas
+            .getOrDefault(replica.getNode(), Map.of())
+            .getOrDefault(replica.getShard().getShardName(), new AtomicInteger());
+        if (secondaryCount.get() > 1) {
+          // we can delete it - record the deletion
+          secondaryCount.decrementAndGet();
+          continue;
+        }
+        // fail - this replica cannot be removed
+        if (exception == null) {
+          exception = new PlacementModificationException("delete replica(s) rejected");
+        }
+        exception.addRejectedModification(replica.toString(), "co-located with replicas of " + colocatingNodes.get(replica.getNode()));
+      }
+      if (exception != null) {
+        throw exception;
+      }
     }
 
     private Set<String> getZonesFromNodes(Set<Node> nodes, final AttributeValues attrValues) {
@@ -467,7 +570,7 @@ public class AffinityPlacementFactory implements PlacementPluginFactory<Affinity
         if (candidateAzEntries == null) {
           // This can happen because not enough nodes for the placement request or already too many nodes with replicas of
           // the shard that can't accept new replicas or not enough nodes with enough free disk space.
-          throw new PlacementException("Not enough nodes to place " + numReplicas + " replica(s) of type " + replicaType +
+          throw new PlacementException("Not enough eligible nodes to place " + numReplicas + " replica(s) of type " + replicaType +
               " for shard " + shardName + " of collection " + solrCollection.getName());
         }
 
@@ -529,6 +632,32 @@ public class AffinityPlacementFactory implements PlacementPluginFactory<Affinity
       }
     }
 
+    private Set<Node> filterNodesWithCollection(Cluster cluster, PlacementRequest request, Set<Node> initialNodes) throws PlacementException {
+      // if there's a `withCollection` constraint for this collection then remove nodes
+      // that are not eligible
+      String withCollectionName = withCollections.get(request.getCollection().getName());
+      if (withCollectionName == null) {
+        return initialNodes;
+      }
+      SolrCollection withCollection;
+      try {
+        withCollection = cluster.getCollection(withCollectionName);
+      } catch (Exception e) {
+        throw new PlacementException("Error getting info of withCollection=" + withCollectionName, e);
+      }
+      Set<Node> withCollectionNodes = new HashSet<>();
+      withCollection.shards().forEach(s -> s.replicas().forEach(r -> withCollectionNodes.add(r.getNode())));
+      if (withCollectionNodes.isEmpty()) {
+        throw new PlacementException("Collection " + withCollection + " defined in `withCollection` has no replicas on eligible nodes.");
+      }
+      HashSet<Node> filteredNodes = new HashSet<>(initialNodes);
+      filteredNodes.retainAll(withCollectionNodes);
+      if (filteredNodes.isEmpty()) {
+        throw new PlacementException("Collection " + withCollection + " defined in `withCollection` has no replicas on eligible nodes.");
+      }
+      return filteredNodes;
+    }
+
     /**
      * Comparator implementing the placement strategy based on free space and number of cores: we want to place new replicas
      * on nodes with the less number of cores, but only if they do have enough disk space (expressed as a threshold value).
diff --git a/solr/core/src/java/org/apache/solr/cluster/placement/plugins/MinimizeCoresPlacementFactory.java b/solr/core/src/java/org/apache/solr/cluster/placement/plugins/MinimizeCoresPlacementFactory.java
index bb1e762..df7735e 100644
--- a/solr/core/src/java/org/apache/solr/cluster/placement/plugins/MinimizeCoresPlacementFactory.java
+++ b/solr/core/src/java/org/apache/solr/cluster/placement/plugins/MinimizeCoresPlacementFactory.java
@@ -26,7 +26,6 @@ import java.util.Map;
 
 import com.google.common.collect.Ordering;
 import com.google.common.collect.TreeMultimap;
-import org.apache.solr.cluster.Cluster;
 import org.apache.solr.cluster.Node;
 import org.apache.solr.cluster.Replica;
 import org.apache.solr.cluster.SolrCollection;
@@ -50,15 +49,15 @@ public class MinimizeCoresPlacementFactory implements PlacementPluginFactory<Pla
 
   static private class MinimizeCoresPlacementPlugin implements PlacementPlugin {
 
+    @Override
     @SuppressForbidden(reason = "Ordering.arbitrary() has no equivalent in Comparator class. Rather reuse than copy.")
-    public PlacementPlan computePlacement(Cluster cluster, PlacementRequest request, AttributeFetcher attributeFetcher,
-                                          PlacementPlanFactory placementPlanFactory) throws PlacementException {
+    public PlacementPlan computePlacement(PlacementRequest request, PlacementContext placementContext) throws PlacementException {
       int totalReplicasPerShard = 0;
       for (Replica.ReplicaType rt : Replica.ReplicaType.values()) {
         totalReplicasPerShard += request.getCountReplicasToCreate(rt);
       }
 
-      if (cluster.getLiveNodes().size() < totalReplicasPerShard) {
+      if (placementContext.getCluster().getLiveNodes().size() < totalReplicasPerShard) {
         throw new PlacementException("Cluster size too small for number of replicas per shard");
       }
 
@@ -67,6 +66,7 @@ public class MinimizeCoresPlacementFactory implements PlacementPluginFactory<Pla
 
       Set<Node> nodes = request.getTargetNodes();
 
+      AttributeFetcher attributeFetcher = placementContext.getAttributeFetcher();
       attributeFetcher.requestNodeMetric(NodeMetricImpl.NUM_CORES);
       attributeFetcher.fetchFrom(nodes);
       AttributeValues attrValues = attributeFetcher.fetchAttributes();
@@ -106,11 +106,11 @@ public class MinimizeCoresPlacementFactory implements PlacementPluginFactory<Pla
         }
 
         for (Replica.ReplicaType replicaType : Replica.ReplicaType.values()) {
-          placeReplicas(request.getCollection(), nodeEntriesToAssign, placementPlanFactory, replicaPlacements, shardName, request, replicaType);
+          placeReplicas(request.getCollection(), nodeEntriesToAssign, placementContext.getPlacementPlanFactory(), replicaPlacements, shardName, request, replicaType);
         }
       }
 
-      return placementPlanFactory.createPlacementPlan(request, replicaPlacements);
+      return placementContext.getPlacementPlanFactory().createPlacementPlan(request, replicaPlacements);
     }
 
     private void placeReplicas(SolrCollection solrCollection, ArrayList<Map.Entry<Integer, Node>> nodeEntriesToAssign,
diff --git a/solr/core/src/java/org/apache/solr/cluster/placement/plugins/RandomPlacementFactory.java b/solr/core/src/java/org/apache/solr/cluster/placement/plugins/RandomPlacementFactory.java
index 0b27d21..e222e14 100644
--- a/solr/core/src/java/org/apache/solr/cluster/placement/plugins/RandomPlacementFactory.java
+++ b/solr/core/src/java/org/apache/solr/cluster/placement/plugins/RandomPlacementFactory.java
@@ -23,7 +23,6 @@ import java.util.HashSet;
 import java.util.Random;
 import java.util.Set;
 
-import org.apache.solr.cluster.Cluster;
 import org.apache.solr.cluster.Node;
 import org.apache.solr.cluster.Replica;
 import org.apache.solr.cluster.SolrCollection;
@@ -53,14 +52,14 @@ public class RandomPlacementFactory implements PlacementPluginFactory<PlacementP
       }
     }
 
-    public PlacementPlan computePlacement(Cluster cluster, PlacementRequest request, AttributeFetcher attributeFetcher,
-                                          PlacementPlanFactory placementPlanFactory) throws PlacementException {
+    @Override
+    public PlacementPlan computePlacement(PlacementRequest request, PlacementContext placementContext) throws PlacementException {
       int totalReplicasPerShard = 0;
       for (Replica.ReplicaType rt : Replica.ReplicaType.values()) {
         totalReplicasPerShard += request.getCountReplicasToCreate(rt);
       }
 
-      if (cluster.getLiveNodes().size() < totalReplicasPerShard) {
+      if (placementContext.getCluster().getLiveNodes().size() < totalReplicasPerShard) {
         throw new PlacementException("Cluster size too small for number of replicas per shard");
       }
 
@@ -69,15 +68,15 @@ public class RandomPlacementFactory implements PlacementPluginFactory<PlacementP
       // Now place randomly all replicas of all shards on available nodes
       for (String shardName : request.getShardNames()) {
         // Shuffle the nodes for each shard so that replicas for a shard are placed on distinct yet random nodes
-        ArrayList<Node> nodesToAssign = new ArrayList<>(cluster.getLiveNodes());
+        ArrayList<Node> nodesToAssign = new ArrayList<>(placementContext.getCluster().getLiveNodes());
         Collections.shuffle(nodesToAssign, replicaPlacementRandom);
 
         for (Replica.ReplicaType rt : Replica.ReplicaType.values()) {
-          placeForReplicaType(request.getCollection(), nodesToAssign, placementPlanFactory, replicaPlacements, shardName, request, rt);
+          placeForReplicaType(request.getCollection(), nodesToAssign, placementContext.getPlacementPlanFactory(), replicaPlacements, shardName, request, rt);
         }
       }
 
-      return placementPlanFactory.createPlacementPlan(request, replicaPlacements);
+      return placementContext.getPlacementPlanFactory().createPlacementPlan(request, replicaPlacements);
     }
 
     private void placeForReplicaType(SolrCollection solrCollection, ArrayList<Node> nodesToAssign, PlacementPlanFactory placementPlanFactory,
diff --git a/solr/core/src/java/org/apache/solr/handler/admin/CollectionsHandler.java b/solr/core/src/java/org/apache/solr/handler/admin/CollectionsHandler.java
index 288f6c5..daf82f6 100644
--- a/solr/core/src/java/org/apache/solr/handler/admin/CollectionsHandler.java
+++ b/solr/core/src/java/org/apache/solr/handler/admin/CollectionsHandler.java
@@ -95,7 +95,7 @@ import static org.apache.solr.client.solrj.response.RequestStatusState.NOT_FOUND
 import static org.apache.solr.client.solrj.response.RequestStatusState.RUNNING;
 import static org.apache.solr.client.solrj.response.RequestStatusState.SUBMITTED;
 import static org.apache.solr.cloud.Overseer.QUEUE_OPERATION;
-import static org.apache.solr.cloud.api.collections.OverseerCollectionMessageHandler.COLL_PROP_PREFIX;
+import static org.apache.solr.common.params.CollectionAdminParams.PROPERTY_PREFIX;
 import static org.apache.solr.cloud.api.collections.OverseerCollectionMessageHandler.CREATE_NODE_SET;
 import static org.apache.solr.cloud.api.collections.OverseerCollectionMessageHandler.CREATE_NODE_SET_EMPTY;
 import static org.apache.solr.cloud.api.collections.OverseerCollectionMessageHandler.CREATE_NODE_SET_SHUFFLE;
@@ -486,7 +486,7 @@ public class CollectionsHandler extends RequestHandlerBase implements Permission
       if (shardsParam == null) h.copyFromClusterProp(props, NUM_SLICES);
       for (String prop : ImmutableSet.of(NRT_REPLICAS, PULL_REPLICAS, TLOG_REPLICAS))
         h.copyFromClusterProp(props, prop);
-      copyPropertiesWithPrefix(req.getParams(), props, COLL_PROP_PREFIX);
+      copyPropertiesWithPrefix(req.getParams(), props, PROPERTY_PREFIX);
       return copyPropertiesWithPrefix(req.getParams(), props, "router.");
 
     }),
@@ -737,7 +737,7 @@ public class CollectionsHandler extends RequestHandlerBase implements Permission
           SPLIT_FUZZ,
           SPLIT_BY_PREFIX,
           FOLLOW_ALIASES);
-      return copyPropertiesWithPrefix(req.getParams(), map, COLL_PROP_PREFIX);
+      return copyPropertiesWithPrefix(req.getParams(), map, PROPERTY_PREFIX);
     }),
     DELETESHARD_OP(DELETESHARD, (req, rsp, h) -> {
       Map<String, Object> map = copy(req.getParams().required(), null,
@@ -775,7 +775,7 @@ public class CollectionsHandler extends RequestHandlerBase implements Permission
           CREATE_NODE_SET,
           WAIT_FOR_FINAL_STATE,
           FOLLOW_ALIASES);
-      return copyPropertiesWithPrefix(req.getParams(), map, COLL_PROP_PREFIX);
+      return copyPropertiesWithPrefix(req.getParams(), map, PROPERTY_PREFIX);
     }),
     DELETEREPLICA_OP(DELETEREPLICA, (req, rsp, h) -> {
       Map<String, Object> map = copy(req.getParams().required(), null,
@@ -917,7 +917,7 @@ public class CollectionsHandler extends RequestHandlerBase implements Permission
           CREATE_NODE_SET,
           FOLLOW_ALIASES,
           SKIP_NODE_ASSIGNMENT);
-      return copyPropertiesWithPrefix(req.getParams(), props, COLL_PROP_PREFIX);
+      return copyPropertiesWithPrefix(req.getParams(), props, PROPERTY_PREFIX);
     }),
     OVERSEERSTATUS_OP(OVERSEERSTATUS, (req, rsp, h) -> new LinkedHashMap<>()),
 
@@ -958,8 +958,8 @@ public class CollectionsHandler extends RequestHandlerBase implements Permission
           PROPERTY_VALUE_PROP);
       copy(req.getParams(), map, SHARD_UNIQUE);
       String property = (String) map.get(PROPERTY_PROP);
-      if (!property.startsWith(COLL_PROP_PREFIX)) {
-        property = COLL_PROP_PREFIX + property;
+      if (!property.startsWith(PROPERTY_PREFIX)) {
+        property = PROPERTY_PREFIX + property;
       }
 
       boolean uniquePerSlice = Boolean.parseBoolean((String) map.get(SHARD_UNIQUE));
@@ -992,8 +992,8 @@ public class CollectionsHandler extends RequestHandlerBase implements Permission
           PROPERTY_PROP);
       Boolean shardUnique = Boolean.parseBoolean(req.getParams().get(SHARD_UNIQUE));
       String prop = req.getParams().get(PROPERTY_PROP).toLowerCase(Locale.ROOT);
-      if (!StringUtils.startsWith(prop, COLL_PROP_PREFIX)) {
-        prop = COLL_PROP_PREFIX + prop;
+      if (!StringUtils.startsWith(prop, PROPERTY_PREFIX)) {
+        prop = PROPERTY_PREFIX + prop;
       }
 
       if (!shardUnique && !SliceMutator.SLICE_UNIQUE_BOOLEAN_PROPERTIES.contains(prop)) {
@@ -1011,7 +1011,7 @@ public class CollectionsHandler extends RequestHandlerBase implements Permission
     // XXX should this command support followAliases?
     MODIFYCOLLECTION_OP(MODIFYCOLLECTION, (req, rsp, h) -> {
       Map<String, Object> m = copy(req.getParams(), null, CollectionAdminRequest.MODIFIABLE_COLLECTION_PROPERTIES);
-      copyPropertiesWithPrefix(req.getParams(), m, COLL_PROP_PREFIX);
+      copyPropertiesWithPrefix(req.getParams(), m, PROPERTY_PREFIX);
       if (m.isEmpty()) {
         throw new SolrException(ErrorCode.BAD_REQUEST,
             formatString("no supported values provided {0}", CollectionAdminRequest.MODIFIABLE_COLLECTION_PROPERTIES.toString()));
@@ -1139,7 +1139,7 @@ public class CollectionsHandler extends RequestHandlerBase implements Permission
       // from CREATE_OP:
       copy(req.getParams(), params, COLL_CONF, REPLICATION_FACTOR, NRT_REPLICAS, TLOG_REPLICAS,
           PULL_REPLICAS, CREATE_NODE_SET, CREATE_NODE_SET_SHUFFLE);
-      copyPropertiesWithPrefix(req.getParams(), params, COLL_PROP_PREFIX);
+      copyPropertiesWithPrefix(req.getParams(), params, PROPERTY_PREFIX);
       return params;
     }),
     CREATESNAPSHOT_OP(CREATESNAPSHOT, (req, rsp, h) -> {
diff --git a/solr/core/src/test/org/apache/solr/cluster/placement/Builders.java b/solr/core/src/test/org/apache/solr/cluster/placement/Builders.java
index 21b8369..43de56e 100644
--- a/solr/core/src/test/org/apache/solr/cluster/placement/Builders.java
+++ b/solr/core/src/test/org/apache/solr/cluster/placement/Builders.java
@@ -22,6 +22,7 @@ import org.apache.solr.cluster.placement.impl.AttributeFetcherImpl;
 import org.apache.solr.cluster.placement.impl.AttributeValuesImpl;
 import org.apache.solr.cluster.placement.impl.CollectionMetricsBuilder;
 import org.apache.solr.cluster.placement.impl.NodeMetricImpl;
+import org.apache.solr.cluster.placement.impl.PlacementPlanFactoryImpl;
 import org.apache.solr.cluster.placement.impl.ReplicaMetricImpl;
 import org.apache.solr.common.util.Pair;
 import org.junit.Assert;
@@ -92,6 +93,29 @@ public class Builders {
       return clusterCollections;
     }
 
+    private static final PlacementPlanFactory PLACEMENT_PLAN_FACTORY = new PlacementPlanFactoryImpl();
+
+    public PlacementContext buildPlacementContext() {
+      Cluster cluster = build();
+      AttributeFetcher attributeFetcher = buildAttributeFetcher();
+      return new PlacementContext() {
+        @Override
+        public Cluster getCluster() {
+          return cluster;
+        }
+
+        @Override
+        public AttributeFetcher getAttributeFetcher() {
+          return attributeFetcher;
+        }
+
+        @Override
+        public PlacementPlanFactory getPlacementPlanFactory() {
+          return PLACEMENT_PLAN_FACTORY;
+        }
+      };
+    }
+
     public AttributeFetcher buildAttributeFetcher() {
       Map<String, Map<Node, String>> sysprops = new HashMap<>();
       Map<NodeMetric<?>, Map<Node, Object>> metrics = new HashMap<>();
diff --git a/solr/core/src/test/org/apache/solr/cluster/placement/impl/PlacementPluginIntegrationTest.java b/solr/core/src/test/org/apache/solr/cluster/placement/impl/PlacementPluginIntegrationTest.java
index 6967550..199d779 100644
--- a/solr/core/src/test/org/apache/solr/cluster/placement/impl/PlacementPluginIntegrationTest.java
+++ b/solr/core/src/test/org/apache/solr/cluster/placement/impl/PlacementPluginIntegrationTest.java
@@ -55,6 +55,7 @@ import org.slf4j.LoggerFactory;
 import java.lang.invoke.MethodHandles;
 import java.util.Arrays;
 import java.util.HashMap;
+import java.util.HashSet;
 import java.util.Map;
 import java.util.Optional;
 import java.util.Set;
@@ -71,7 +72,7 @@ import static java.util.Collections.singletonMap;
 public class PlacementPluginIntegrationTest extends SolrCloudTestCase {
   private static final Logger log = LoggerFactory.getLogger(MethodHandles.lookup().lookupClass());
 
-  private static final String COLLECTION = PlacementPluginIntegrationTest.class.getName() + "_collection";
+  private static final String COLLECTION = PlacementPluginIntegrationTest.class.getSimpleName() + "_collection";
 
   private static SolrCloudManager cloudManager;
   private static CoreContainer cc;
@@ -232,6 +233,88 @@ public class PlacementPluginIntegrationTest extends SolrCloudTestCase {
   }
 
   @Test
+  public void testWithCollectionIntegration() throws Exception {
+    PlacementPluginFactory<? extends PlacementPluginConfig> pluginFactory = cc.getPlacementPluginFactory();
+    assertTrue("wrong type " + pluginFactory.getClass().getName(), pluginFactory instanceof DelegatingPlacementPluginFactory);
+    DelegatingPlacementPluginFactory wrapper = (DelegatingPlacementPluginFactory) pluginFactory;
+
+    int version = wrapper.getVersion();
+    log.debug("--initial version={}", version);
+
+    Set<String> nodeSet = new HashSet<>();
+    for (String node : cloudManager.getClusterStateProvider().getLiveNodes()) {
+      if (nodeSet.size() > 1) {
+        break;
+      }
+      nodeSet.add(node);
+    }
+
+    String SECONDARY_COLLECTION = COLLECTION + "_secondary";
+    PluginMeta plugin = new PluginMeta();
+    plugin.name = PlacementPluginFactory.PLUGIN_NAME;
+    plugin.klass = AffinityPlacementFactory.class.getName();
+    plugin.config = new AffinityPlacementConfig(1, 2, Map.of(COLLECTION, SECONDARY_COLLECTION));
+    V2Request req = new V2Request.Builder("/cluster/plugin")
+        .forceV2(true)
+        .POST()
+        .withPayload(singletonMap("add", plugin))
+        .build();
+    req.process(cluster.getSolrClient());
+
+    version = waitForVersionChange(version, wrapper, 10);
+
+    CollectionAdminResponse rsp = CollectionAdminRequest.createCollection(SECONDARY_COLLECTION, "conf", 1, 3)
+        .process(cluster.getSolrClient());
+    assertTrue(rsp.isSuccess());
+    cluster.waitForActiveCollection(SECONDARY_COLLECTION, 1, 3);
+    DocCollection secondary = cloudManager.getClusterStateProvider().getClusterState().getCollection(SECONDARY_COLLECTION);
+    Set<String> secondaryNodes = new HashSet<>();
+    secondary.forEachReplica((shard, replica) -> secondaryNodes.add(replica.getNodeName()));
+
+    rsp = CollectionAdminRequest.createCollection(COLLECTION, "conf", 2, 2)
+        .setCreateNodeSet(String.join(",", nodeSet))
+        .process(cluster.getSolrClient());
+    assertTrue(rsp.isSuccess());
+    cluster.waitForActiveCollection(COLLECTION, 2, 4);
+    // make sure the primary replicas were placed on the nodeset
+    DocCollection primary = cloudManager.getClusterStateProvider().getClusterState().getCollection(COLLECTION);
+    primary.forEachReplica((shard, replica) ->
+        assertTrue("primary replica not on secondary node!", nodeSet.contains(replica.getNodeName())));
+
+    // try deleting secondary replica from node without the primary replica
+    Optional<String> onlySecondaryReplica = secondary.getReplicas().stream()
+        .filter(replica -> !nodeSet.contains(replica.getNodeName()))
+        .map(replica -> replica.getName()).findFirst();
+    assertTrue("no secondary node without primary replica", onlySecondaryReplica.isPresent());
+
+    rsp = CollectionAdminRequest.deleteReplica(SECONDARY_COLLECTION, "shard1", onlySecondaryReplica.get())
+        .process(cluster.getSolrClient());
+    assertTrue("delete of a lone secondary replica should succeed", rsp.isSuccess());
+
+    // try deleting secondary replica from node WITH the primary replica - should fail
+    Optional<String> secondaryWithPrimaryReplica = secondary.getReplicas().stream()
+        .filter(replica -> nodeSet.contains(replica.getNodeName()))
+        .map(replica -> replica.getName()).findFirst();
+    assertTrue("no secondary node with primary replica", secondaryWithPrimaryReplica.isPresent());
+    try {
+      rsp = CollectionAdminRequest.deleteReplica(SECONDARY_COLLECTION, "shard1", secondaryWithPrimaryReplica.get())
+          .process(cluster.getSolrClient());
+      fail("should have failed: " + rsp);
+    } catch (Exception e) {
+      assertTrue(e.toString(), e.toString().contains("co-located with replicas"));
+    }
+
+    // try deleting secondary collection
+    try {
+      rsp = CollectionAdminRequest.deleteCollection(SECONDARY_COLLECTION)
+          .process(cluster.getSolrClient());
+      fail("should have failed: " + rsp);
+    } catch (Exception e) {
+      assertTrue(e.toString(), e.toString().contains("colocated collection"));
+    }
+  }
+
+  @Test
   public void testAttributeFetcherImpl() throws Exception {
     CollectionAdminResponse rsp = CollectionAdminRequest.createCollection(COLLECTION, "conf", 2, 2)
         .process(cluster.getSolrClient());
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 81dda9d..2fd02a0 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
@@ -25,7 +25,7 @@ import org.apache.solr.cluster.Shard;
 import org.apache.solr.cluster.SolrCollection;
 import org.apache.solr.cluster.placement.*;
 import org.apache.solr.cluster.placement.Builders;
-import org.apache.solr.cluster.placement.impl.PlacementPlanFactoryImpl;
+import org.apache.solr.cluster.placement.impl.ModificationRequestImpl;
 import org.apache.solr.cluster.placement.impl.PlacementRequestImpl;
 import org.apache.solr.common.util.Pair;
 import org.junit.BeforeClass;
@@ -50,10 +50,15 @@ public class AffinityPlacementFactoryTest extends SolrTestCaseJ4 {
 
   private final static long MINIMAL_FREE_DISK_GB = 10L;
   private final static long PRIORITIZED_FREE_DISK_GB = 50L;
+  private final static String secondaryCollectionName = "withCollection_secondary";
+  private final static String primaryCollectionName = "withCollection_primary";
 
   @BeforeClass
   public static void setupPlugin() {
-    AffinityPlacementConfig config = new AffinityPlacementConfig(MINIMAL_FREE_DISK_GB, PRIORITIZED_FREE_DISK_GB);
+    AffinityPlacementConfig config = new AffinityPlacementConfig(
+        MINIMAL_FREE_DISK_GB,
+        PRIORITIZED_FREE_DISK_GB,
+        Map.of(primaryCollectionName, secondaryCollectionName));
     AffinityPlacementFactory factory = new AffinityPlacementFactory();
     factory.configure(config);
     plugin = factory.createPluginInstance();
@@ -93,8 +98,7 @@ public class AffinityPlacementFactoryTest extends SolrTestCaseJ4 {
       collectionBuilder.initializeShardsReplicas(1, 0, 0, 0, List.of());
     }
 
-    Cluster cluster = clusterBuilder.build();
-    AttributeFetcher attributeFetcher = clusterBuilder.buildAttributeFetcher();
+    PlacementContext placementContext = clusterBuilder.buildPlacementContext();
 
     SolrCollection solrCollection = collectionBuilder.build();
     List<Node> liveNodes = clusterBuilder.buildLiveNodes();
@@ -104,7 +108,7 @@ public class AffinityPlacementFactoryTest extends SolrTestCaseJ4 {
         Set.of(solrCollection.shards().iterator().next().getShardName()), new HashSet<>(liveNodes),
         1, 0, 0);
 
-    PlacementPlan pp = plugin.computePlacement(cluster, placementRequest, attributeFetcher, new PlacementPlanFactoryImpl());
+    PlacementPlan pp = plugin.computePlacement(placementRequest, placementContext);
 
     assertEquals(1, pp.getReplicaPlacements().size());
     ReplicaPlacement rp = pp.getReplicaPlacements().iterator().next();
@@ -144,7 +148,7 @@ public class AffinityPlacementFactoryTest extends SolrTestCaseJ4 {
     PlacementRequestImpl placementRequest = new PlacementRequestImpl(solrCollection, solrCollection.getShardNames(), new HashSet<>(liveNodes),
         2, 2, 2);
 
-    PlacementPlan pp = plugin.computePlacement(clusterBuilder.build(), placementRequest, clusterBuilder.buildAttributeFetcher(), new PlacementPlanFactoryImpl());
+    PlacementPlan pp = plugin.computePlacement(placementRequest, clusterBuilder.buildPlacementContext());
 
     assertEquals(18, pp.getReplicaPlacements().size()); // 3 shards, 6 replicas total each
     Set<Pair<String, Node>> placements = new HashSet<>();
@@ -157,7 +161,7 @@ public class AffinityPlacementFactoryTest extends SolrTestCaseJ4 {
     // Verify that if we ask for 7 replicas, the placement will use the low free space node
     placementRequest = new PlacementRequestImpl(solrCollection, solrCollection.getShardNames(), new HashSet<>(liveNodes),
         7, 0, 0);
-    pp = plugin.computePlacement(clusterBuilder.build(), placementRequest, clusterBuilder.buildAttributeFetcher(), new PlacementPlanFactoryImpl());
+    pp = plugin.computePlacement(placementRequest, clusterBuilder.buildPlacementContext());
     assertEquals(21, pp.getReplicaPlacements().size()); // 3 shards, 7 replicas each
     placements = new HashSet<>();
     for (ReplicaPlacement rp : pp.getReplicaPlacements()) {
@@ -170,7 +174,7 @@ public class AffinityPlacementFactoryTest extends SolrTestCaseJ4 {
     try {
       placementRequest = new PlacementRequestImpl(solrCollection, solrCollection.getShardNames(), new HashSet<>(liveNodes),
           8, 0, 0);
-      plugin.computePlacement(clusterBuilder.build(), placementRequest, clusterBuilder.buildAttributeFetcher(), new PlacementPlanFactoryImpl());
+      plugin.computePlacement(placementRequest, clusterBuilder.buildPlacementContext());
       fail("Placing 8 replicas should not be possible given only 7 nodes have enough space");
     } catch (PlacementException e) {
       // expected
@@ -214,7 +218,7 @@ public class AffinityPlacementFactoryTest extends SolrTestCaseJ4 {
     // The replicas must be placed on the most appropriate nodes, i.e. those that do not already have a replica for the
     // shard and then on the node with the lowest number of cores.
     // NRT are placed first and given the cluster state here the placement is deterministic (easier to test, only one good placement).
-    PlacementPlan pp = plugin.computePlacement(clusterBuilder.build(), placementRequest, clusterBuilder.buildAttributeFetcher(), new PlacementPlanFactoryImpl());
+    PlacementPlan pp = plugin.computePlacement(placementRequest, clusterBuilder.buildPlacementContext());
 
     // Each expected placement is represented as a string "shard replica-type node"
     Set<String> expectedPlacements = Set.of("1 NRT 1", "1 TLOG 2", "2 NRT 0", "2 TLOG 4");
@@ -312,7 +316,7 @@ public class AffinityPlacementFactoryTest extends SolrTestCaseJ4 {
     // Add 2 NRT and one TLOG to each shard.
     PlacementRequestImpl placementRequest = new PlacementRequestImpl(solrCollection, solrCollection.getShardNames(), new HashSet<>(liveNodes),
         2, 1, 0);
-    PlacementPlan pp = plugin.computePlacement(clusterBuilder.build(), placementRequest, clusterBuilder.buildAttributeFetcher(), new PlacementPlanFactoryImpl());
+    PlacementPlan pp = plugin.computePlacement(placementRequest, clusterBuilder.buildPlacementContext());
     // Shard 1: The NRT's should go to the med cores node on AZ2 and low core on az3 (even though
     // a low core node can take the replica in az1, there's already an NRT replica there and we want spreading across AZ's),
     // the TLOG to the TLOG node on AZ2 (because the tlog node on AZ1 has low free disk)
@@ -326,7 +330,7 @@ public class AffinityPlacementFactoryTest extends SolrTestCaseJ4 {
     // If we add instead 2 PULL replicas to each shard
     placementRequest = new PlacementRequestImpl(solrCollection, solrCollection.getShardNames(), new HashSet<>(liveNodes),
         0, 0, 2);
-    pp = plugin.computePlacement(clusterBuilder.build(), placementRequest, clusterBuilder.buildAttributeFetcher(), new PlacementPlanFactoryImpl());
+    pp = plugin.computePlacement(placementRequest, clusterBuilder.buildPlacementContext());
     // Shard 1: Given node AZ3_TLOGPULL is taken by the TLOG replica, the PULL should go to AZ1_TLOGPULL_LOWFREEDISK and AZ2_TLOGPULL
     // Shard 2: Similarly AZ2_TLOGPULL is taken. Replicas should go to AZ1_TLOGPULL_LOWFREEDISK and AZ3_TLOGPULL
     expectedPlacements = Set.of("1 PULL " + AZ1_TLOGPULL_LOWFREEDISK, "1 PULL " + AZ2_TLOGPULL,
@@ -373,7 +377,7 @@ public class AffinityPlacementFactoryTest extends SolrTestCaseJ4 {
     for (int countNrtToPlace = 1; countNrtToPlace <= 9; countNrtToPlace++) {
       PlacementRequestImpl placementRequest = new PlacementRequestImpl(solrCollection, solrCollection.getShardNames(), new HashSet<>(liveNodes),
           countNrtToPlace, 0, 0);
-      PlacementPlan pp = plugin.computePlacement(clusterBuilder.build(), placementRequest, clusterBuilder.buildAttributeFetcher(), new PlacementPlanFactoryImpl());
+      PlacementPlan pp = plugin.computePlacement(placementRequest, clusterBuilder.buildPlacementContext());
       verifyPlacements(placements.get(countNrtToPlace - 1), pp, collectionBuilder.getShardBuilders(), liveNodes);
     }
   }
@@ -409,7 +413,7 @@ public class AffinityPlacementFactoryTest extends SolrTestCaseJ4 {
     PlacementRequestImpl placementRequest = new PlacementRequestImpl(solrCollection, Set.of(solrCollection.iterator().next().getShardName()), new HashSet<>(liveNodes),
         0, 0, 1);
 
-    PlacementPlan pp = plugin.computePlacement(clusterBuilder.build(), placementRequest, clusterBuilder.buildAttributeFetcher(), new PlacementPlanFactoryImpl());
+    PlacementPlan pp = plugin.computePlacement(placementRequest, clusterBuilder.buildPlacementContext());
 
     // Each expected placement is represented as a string "shard replica-type node"
     // Node 0 has less cores than node 1 (0 vs 1) so the placement should go there.
@@ -422,7 +426,7 @@ public class AffinityPlacementFactoryTest extends SolrTestCaseJ4 {
     it.next(); // skip first shard to do placement for the second one...
     placementRequest = new PlacementRequestImpl(solrCollection, Set.of(it.next().getShardName()), new HashSet<>(liveNodes),
         0, 0, 1);
-    pp = plugin.computePlacement(clusterBuilder.build(), placementRequest, clusterBuilder.buildAttributeFetcher(), new PlacementPlanFactoryImpl());
+    pp = plugin.computePlacement(placementRequest, clusterBuilder.buildPlacementContext());
     expectedPlacements = Set.of("2 PULL 0");
     verifyPlacements(expectedPlacements, pp, collectionBuilder.getShardBuilders(), liveNodes);
   }
@@ -505,7 +509,8 @@ public class AffinityPlacementFactoryTest extends SolrTestCaseJ4 {
     collectionBuilder.initializeShardsReplicas(2, 0, 0, 0, clusterBuilder.getLiveNodeBuilders());
     clusterBuilder.addCollection(collectionBuilder);
 
-    Cluster cluster = clusterBuilder.build();
+    PlacementContext placementContext = clusterBuilder.buildPlacementContext();
+    Cluster cluster = placementContext.getCluster();
 
     SolrCollection solrCollection = cluster.getCollection(collectionName);
 
@@ -514,14 +519,12 @@ public class AffinityPlacementFactoryTest extends SolrTestCaseJ4 {
             .map(Shard::getShardName).collect(Collectors.toSet()),
         cluster.getLiveNodes(), 2, 2, 2);
 
-    PlacementPlanFactory placementPlanFactory = new PlacementPlanFactoryImpl();
-    AttributeFetcher attributeFetcher = clusterBuilder.buildAttributeFetcher();
-    PlacementPlan pp = plugin.computePlacement(cluster, placementRequest, attributeFetcher, placementPlanFactory);
+    PlacementPlan pp = plugin.computePlacement(placementRequest, placementContext);
     // 2 shards, 6 replicas
     assertEquals(12, pp.getReplicaPlacements().size());
     // shard -> AZ -> replica count
     Map<Replica.ReplicaType, Map<String, Map<String, AtomicInteger>>> replicas = new HashMap<>();
-    AttributeValues attributeValues = attributeFetcher.fetchAttributes();
+    AttributeValues attributeValues = placementContext.getAttributeFetcher().fetchAttributes();
     for (ReplicaPlacement rp : pp.getReplicaPlacements()) {
       Optional<String> azOptional = attributeValues.getSystemProperty(rp.getNode(), AffinityPlacementFactory.AVAILABILITY_ZONE_SYSPROP);
       if (!azOptional.isPresent()) {
@@ -565,7 +568,8 @@ public class AffinityPlacementFactoryTest extends SolrTestCaseJ4 {
     collectionBuilder.initializeShardsReplicas(2, 0, 0, 0, clusterBuilder.getLiveNodeBuilders());
     clusterBuilder.addCollection(collectionBuilder);
 
-    Cluster cluster = clusterBuilder.build();
+    PlacementContext placementContext = clusterBuilder.buildPlacementContext();
+    Cluster cluster = placementContext.getCluster();
 
     SolrCollection solrCollection = cluster.getCollection(collectionName);
 
@@ -574,14 +578,12 @@ public class AffinityPlacementFactoryTest extends SolrTestCaseJ4 {
             .map(Shard::getShardName).collect(Collectors.toSet()),
         cluster.getLiveNodes(), 2, 2, 2);
 
-    PlacementPlanFactory placementPlanFactory = new PlacementPlanFactoryImpl();
-    AttributeFetcher attributeFetcher = clusterBuilder.buildAttributeFetcher();
-    PlacementPlan pp = plugin.computePlacement(cluster, placementRequest, attributeFetcher, placementPlanFactory);
+    PlacementPlan pp = plugin.computePlacement(placementRequest, placementContext);
     // 2 shards, 6 replicas
     assertEquals(12, pp.getReplicaPlacements().size());
     // shard -> group -> replica count
     Map<Replica.ReplicaType, Map<String, Map<String, AtomicInteger>>> replicas = new HashMap<>();
-    AttributeValues attributeValues = attributeFetcher.fetchAttributes();
+    AttributeValues attributeValues = placementContext.getAttributeFetcher().fetchAttributes();
     for (ReplicaPlacement rp : pp.getReplicaPlacements()) {
       Optional<String> groupOptional = attributeValues.getSystemProperty(rp.getNode(), "group");
       if (!groupOptional.isPresent()) {
@@ -632,7 +634,8 @@ public class AffinityPlacementFactoryTest extends SolrTestCaseJ4 {
     collectionBuilder.initializeShardsReplicas(2, 0, 0, 0, clusterBuilder.getLiveNodeBuilders());
     clusterBuilder.addCollection(collectionBuilder);
 
-    Cluster cluster = clusterBuilder.build();
+    PlacementContext placementContext = clusterBuilder.buildPlacementContext();
+    Cluster cluster = placementContext.getCluster();
 
     SolrCollection solrCollection = cluster.getCollection(collectionName);
 
@@ -641,15 +644,104 @@ public class AffinityPlacementFactoryTest extends SolrTestCaseJ4 {
             .map(Shard::getShardName).collect(Collectors.toSet()),
         cluster.getLiveNodes(), 1, 0, 1);
 
-    PlacementPlanFactory placementPlanFactory = new PlacementPlanFactoryImpl();
-    AttributeFetcher attributeFetcher = clusterBuilder.buildAttributeFetcher();
-    PlacementPlan pp = plugin.computePlacement(cluster, placementRequest, attributeFetcher, placementPlanFactory);
+    PlacementPlan pp = plugin.computePlacement(placementRequest, placementContext);
     assertEquals(4, pp.getReplicaPlacements().size());
     for (ReplicaPlacement rp : pp.getReplicaPlacements()) {
       assertFalse("should not put any replicas on " + smallNode, rp.getNode().equals(smallNode));
     }
   }
 
+  @Test
+  public void testWithCollectionPlacement() throws Exception {
+    int NUM_NODES = 3;
+    Builders.ClusterBuilder clusterBuilder = Builders.newClusterBuilder().initializeLiveNodes(NUM_NODES);
+    Builders.CollectionBuilder collectionBuilder = Builders.newCollectionBuilder(secondaryCollectionName);
+    collectionBuilder.initializeShardsReplicas(1, 2, 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())));
+
+    PlacementRequestImpl placementRequest = new PlacementRequestImpl(primaryCollection,
+      Set.of("shard1", "shard2"), 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())));
+
+    placementRequest = new PlacementRequestImpl(primaryCollection,
+        Set.of("shard1"), cluster.getLiveNodes(), 3, 0, 0);
+    try {
+      pp = plugin.computePlacement(placementRequest, placementContext);
+      fail("should generate 'Not enough eligible nodes' failure here");
+    } catch (PlacementException pe) {
+      assertTrue(pe.toString().contains("Not enough eligible nodes"));
+    }
+  }
+
+  @Test
+  public void testWithCollectionModificationRejected() throws Exception {
+    int NUM_NODES = 2;
+    Builders.ClusterBuilder clusterBuilder = Builders.newClusterBuilder().initializeLiveNodes(NUM_NODES);
+    Builders.CollectionBuilder collectionBuilder = Builders.newCollectionBuilder(secondaryCollectionName);
+    collectionBuilder.initializeShardsReplicas(1, 4, 0, 0, clusterBuilder.getLiveNodeBuilders());
+    clusterBuilder.addCollection(collectionBuilder);
+
+    collectionBuilder = Builders.newCollectionBuilder(primaryCollectionName);
+    collectionBuilder.initializeShardsReplicas(2, 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);
+
+    Node node = cluster.getLiveNodes().iterator().next();
+    Set<Replica> secondaryReplicas = new HashSet<>();
+    secondaryCollection.shards().forEach(shard ->
+        shard.replicas().forEach(replica -> {
+          if (secondaryReplicas.size() < 1 && replica.getNode().equals(node)) {
+            secondaryReplicas.add(replica);
+          }
+        }));
+
+    DeleteReplicasRequest deleteReplicasRequest = ModificationRequestImpl.createDeleteReplicasRequest(secondaryCollection, secondaryReplicas);
+    try {
+      plugin.verifyAllowedModification(deleteReplicasRequest, placementContext);
+    } catch (PlacementException pe) {
+      fail("should have succeeded: " + pe.toString());
+    }
+
+    secondaryCollection.shards().forEach(shard ->
+        shard.replicas().forEach(replica -> {
+          if (secondaryReplicas.size() < 2 && replica.getNode().equals(node)) {
+            secondaryReplicas.add(replica);
+          }
+        }));
+
+    deleteReplicasRequest = ModificationRequestImpl.createDeleteReplicasRequest(secondaryCollection, secondaryReplicas);
+    try {
+      plugin.verifyAllowedModification(deleteReplicasRequest, placementContext);
+      fail("should have failed: " + deleteReplicasRequest);
+    } catch (PlacementException pe) {
+    }
+  }
+
   @Test @Slow
   public void testScalability() throws Exception {
     log.info("==== numNodes ====");
@@ -684,9 +776,7 @@ public class AffinityPlacementFactoryTest extends SolrTestCaseJ4 {
     Builders.CollectionBuilder collectionBuilder = Builders.newCollectionBuilder(collectionName);
     collectionBuilder.initializeShardsReplicas(numShards, 0, 0, 0, List.of());
 
-    Cluster cluster = clusterBuilder.build();
-    AttributeFetcher attributeFetcher = clusterBuilder.buildAttributeFetcher();
-
+    PlacementContext placementContext = clusterBuilder.buildPlacementContext();
     SolrCollection solrCollection = collectionBuilder.build();
     List<Node> liveNodes = clusterBuilder.buildLiveNodes();
 
@@ -695,7 +785,7 @@ public class AffinityPlacementFactoryTest extends SolrTestCaseJ4 {
         new HashSet<>(liveNodes), nrtReplicas, tlogReplicas, pullReplicas);
 
     long start = System.nanoTime();
-    PlacementPlan pp = plugin.computePlacement(cluster, placementRequest, attributeFetcher, new PlacementPlanFactoryImpl());
+    PlacementPlan pp = plugin.computePlacement(placementRequest, placementContext);
     long end = System.nanoTime();
 
     final int REPLICAS_PER_SHARD = nrtReplicas + tlogReplicas + pullReplicas;
diff --git a/solr/solrj/src/java/org/apache/solr/common/params/CollectionAdminParams.java b/solr/solrj/src/java/org/apache/solr/common/params/CollectionAdminParams.java
index fee3b7a..c38f397 100644
--- a/solr/solrj/src/java/org/apache/solr/common/params/CollectionAdminParams.java
+++ b/solr/solrj/src/java/org/apache/solr/common/params/CollectionAdminParams.java
@@ -115,4 +115,9 @@ public interface CollectionAdminParams {
    * for the add replica API. If set to true, a valid "node" should be specified.
    */
   String SKIP_NODE_ASSIGNMENT = "skipNodeAssignment";
+
+  /**
+   * Prefix for arbitrary collection or replica properties.
+   */
+  String PROPERTY_PREFIX = "property.";
 }


Mime
View raw message