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

[solr] branch branch_9x updated: SOLR-16717: join multiple shards on both sides. cherry-pick (#1691) (#1763)

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

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


The following commit(s) were added to refs/heads/branch_9x by this push:
     new 0c80c7fdc67 SOLR-16717: join multiple shards on both sides. cherry-pick (#1691)  (#1763)
0c80c7fdc67 is described below

commit 0c80c7fdc679b2c73dcc5fe8775d1f7b071ac6f7
Author: Mikhail Khludnev <mk...@users.noreply.github.com>
AuthorDate: Fri Jul 7 09:47:47 2023 +0300

    SOLR-16717: join multiple shards on both sides. cherry-pick (#1691)  (#1763)
---
 solr/CHANGES.txt                                   |   4 +-
 .../plugins/AffinityPlacementFactory.java          |   4 +-
 .../org/apache/solr/search/JoinQParserPlugin.java  |   9 +-
 .../solr/search/join/ScoreJoinQParserPlugin.java   | 273 ++++++++++++++++++---
 .../plugins/StubShardAffinityPlacementFactory.java | 100 ++++++++
 .../solr/search/join/ShardJoinCompositeTest.java   | 158 ++++++++++++
 .../solr/search/join/ShardJoinImplicitTest.java    |  97 ++++++++
 .../solr/search/join/ShardJoinRouterTest.java      |  49 ++++
 .../solr/search/join/ShardToShardJoinAbstract.java | 265 ++++++++++++++++++++
 .../query-guide/pages/join-query-parser.adoc       |  39 ++-
 .../cluster/placement/AttributeFetcherForTest.java |   2 +-
 11 files changed, 963 insertions(+), 37 deletions(-)

diff --git a/solr/CHANGES.txt b/solr/CHANGES.txt
index c51cc15ca4c..8f79c98682a 100644
--- a/solr/CHANGES.txt
+++ b/solr/CHANGES.txt
@@ -44,6 +44,8 @@ New Features
 * SOLR-16855: Solr now provides a MigrateReplicas API at `POST /api/cluster/replicas/migrate` (v2), to move replicas
   off of a given set of nodes. This extends the functionality of the existing ReplaceNode API. (Houston Putman)
 
+* SOLR-16717: {!join} can join collections with multiple shards on both sides. (Mikhail Khludnev)
+
 Improvements
 ---------------------
 
@@ -120,7 +122,7 @@ Improvements
 
 * SOLR-16759: Introducing logAll parameter in the feature logger (Anna Ruggero, Alessandro Benedetti)
 
-* SOLR-9378: Internal shard requests no longer include the wasteful shard.url param.  [shard] transformer now defaults to returning 
+* SOLR-9378: Internal shard requests no longer include the wasteful shard.url param.  [shard] transformer now defaults to returning
   only the shard id (based on luceneMatchVersion), but can be configured to return the legacy list of replicas. (hossman)
 
 * SOLR-16394: The v2 list and delete (collection) backup APIs have been tweaked to be more intuitive: backup listing now uses
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 74b2384f595..b397cab6722 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
@@ -111,7 +111,7 @@ import org.slf4j.LoggerFactory;
 public class AffinityPlacementFactory implements PlacementPluginFactory<AffinityPlacementConfig> {
   private static final Logger log = LoggerFactory.getLogger(MethodHandles.lookup().lookupClass());
 
-  private AffinityPlacementConfig config = AffinityPlacementConfig.DEFAULT;
+  AffinityPlacementConfig config = AffinityPlacementConfig.DEFAULT;
 
   /**
    * Empty public constructor is used to instantiate this factory. Using a factory pattern to allow
@@ -171,7 +171,7 @@ public class AffinityPlacementFactory implements PlacementPluginFactory<Affinity
      * The factory has decoded the configuration for the plugin instance and passes it the
      * parameters it needs.
      */
-    private AffinityPlacementPlugin(
+    AffinityPlacementPlugin(
         long minimalFreeDiskGB,
         long prioritizedFreeDiskGB,
         Map<String, String> withCollections,
diff --git a/solr/core/src/java/org/apache/solr/search/JoinQParserPlugin.java b/solr/core/src/java/org/apache/solr/search/JoinQParserPlugin.java
index cceef3e7ccc..d77dd5e0367 100644
--- a/solr/core/src/java/org/apache/solr/search/JoinQParserPlugin.java
+++ b/solr/core/src/java/org/apache/solr/search/JoinQParserPlugin.java
@@ -153,7 +153,14 @@ public class JoinQParserPlugin extends QParserPlugin {
         CoreContainer container = qparser.req.getCoreContainer();
 
         // if in SolrCloud mode, fromIndex should be the name of a single-sharded collection
-        coreName = ScoreJoinQParserPlugin.getCoreName(fromIndex, container);
+        coreName =
+            ScoreJoinQParserPlugin.getCoreName(
+                fromIndex,
+                container,
+                qparser.req.getCore(),
+                toField,
+                fromField,
+                qparser.localParams);
 
         final SolrCore fromCore = container.getCore(coreName);
         if (fromCore == null) {
diff --git a/solr/core/src/java/org/apache/solr/search/join/ScoreJoinQParserPlugin.java b/solr/core/src/java/org/apache/solr/search/join/ScoreJoinQParserPlugin.java
index 73ca32b7167..cc227b086b4 100644
--- a/solr/core/src/java/org/apache/solr/search/join/ScoreJoinQParserPlugin.java
+++ b/solr/core/src/java/org/apache/solr/search/join/ScoreJoinQParserPlugin.java
@@ -17,7 +17,10 @@
 package org.apache.solr.search.join;
 
 import java.io.IOException;
+import java.lang.invoke.MethodHandles;
 import java.util.Objects;
+import java.util.function.Supplier;
+import java.util.stream.Collectors;
 import org.apache.lucene.index.DocValuesType;
 import org.apache.lucene.search.IndexSearcher;
 import org.apache.lucene.search.Query;
@@ -25,9 +28,15 @@ import org.apache.lucene.search.QueryVisitor;
 import org.apache.lucene.search.Weight;
 import org.apache.lucene.search.join.JoinUtil;
 import org.apache.lucene.search.join.ScoreMode;
+import org.apache.solr.cloud.CloudDescriptor;
 import org.apache.solr.cloud.ZkController;
 import org.apache.solr.common.SolrException;
 import org.apache.solr.common.cloud.Aliases;
+import org.apache.solr.common.cloud.CompositeIdRouter;
+import org.apache.solr.common.cloud.DocCollection;
+import org.apache.solr.common.cloud.DocRouter;
+import org.apache.solr.common.cloud.ImplicitDocRouter;
+import org.apache.solr.common.cloud.PlainIdRouter;
 import org.apache.solr.common.cloud.Replica;
 import org.apache.solr.common.cloud.Slice;
 import org.apache.solr.common.cloud.ZkStateReader;
@@ -45,6 +54,8 @@ import org.apache.solr.search.SolrIndexSearcher;
 import org.apache.solr.search.SyntaxError;
 import org.apache.solr.uninverting.UninvertingReader;
 import org.apache.solr.util.RefCounted;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
 
 /**
  * Create a query-time join query with scoring. It just calls {@link
@@ -80,7 +91,12 @@ import org.apache.solr.util.RefCounted;
  */
 public class ScoreJoinQParserPlugin extends QParserPlugin {
 
+  public static final String USE_CROSSCOLLECTION =
+      "SolrCloud join: To join with a collection that might not be co-located, use method=crossCollection.";
+  private static final Logger log = LoggerFactory.getLogger(MethodHandles.lookup().lookupClass());
+
   public static final String SCORE = "score";
+  public static final String CHECK_ROUTER_FIELD = "checkRouterField";
 
   static class OtherCoreJoinQuery extends SameCoreJoinQuery {
     private final String fromIndex;
@@ -112,8 +128,7 @@ public class ScoreJoinQParserPlugin extends QParserPlugin {
         throw new SolrException(
             SolrException.ErrorCode.BAD_REQUEST, "Cross-core join: no such core " + fromIndex);
       }
-      RefCounted<SolrIndexSearcher> fromHolder = null;
-      fromHolder = fromCore.getRegisteredSearcher();
+      RefCounted<SolrIndexSearcher> fromHolder = fromCore.getRegisteredSearcher();
       final Query joinQuery;
       try {
         joinQuery =
@@ -236,16 +251,13 @@ public class ScoreJoinQParserPlugin extends QParserPlugin {
 
         final String v = localParams.get(CommonParams.VALUE);
 
-        final Query q =
-            createQuery(
-                fromField,
-                v,
-                fromIndex,
-                toField,
-                scoreMode,
-                CommonParams.TRUE.equals(localParams.get("TESTenforceSameCoreAsAnotherOne")));
-
-        return q;
+        return createQuery(
+            fromField,
+            v,
+            fromIndex,
+            toField,
+            scoreMode,
+            CommonParams.TRUE.equals(localParams.get("TESTenforceSameCoreAsAnotherOne")));
       }
 
       private Query createQuery(
@@ -262,7 +274,8 @@ public class ScoreJoinQParserPlugin extends QParserPlugin {
         if (fromIndex != null && (!fromIndex.equals(myCore) || byPassShortCircutCheck)) {
           CoreContainer container = req.getCoreContainer();
 
-          final String coreName = getCoreName(fromIndex, container);
+          final String coreName =
+              getCoreName(fromIndex, container, req.getCore(), toField, fromField, localParams);
           final SolrCore fromCore = container.getCore(coreName);
           RefCounted<SolrIndexSearcher> fromHolder = null;
 
@@ -299,29 +312,40 @@ public class ScoreJoinQParserPlugin extends QParserPlugin {
   }
 
   /**
-   * Returns an String with the name of a core.
+   * Returns a String with the name of a core.
    *
    * <p>This method searches the core with fromIndex name in the core's container. If fromIndex
-   * isn't name of collection or alias it's returns fromIndex without changes. If fromIndex is name
-   * of alias but if the alias points to multiple collections it's throw
+   * isn't the name of a collection or alias it returns fromIndex without changes. If fromIndex is
+   * the name of an alias but the alias points to multiple collections it throws
    * SolrException.ErrorCode.BAD_REQUEST because multiple shards not yet supported.
    *
    * @param fromIndex name of the index
    * @param container the core container for searching the core with fromIndex name or alias
+   * @param toCore core which it joins to ie executing this request
+   * @param toField to side field
+   * @param fromField from side field
+   * @param localParams local params for this qparser invocation
    * @return the string with name of core
    */
-  public static String getCoreName(final String fromIndex, CoreContainer container) {
+  public static String getCoreName(
+      final String fromIndex,
+      CoreContainer container,
+      SolrCore toCore,
+      String toField,
+      String fromField,
+      SolrParams localParams) {
     if (container.isZooKeeperAware()) {
       ZkController zkController = container.getZkController();
-      final String resolved = resolveAlias(fromIndex, zkController);
+      final String fromCollection = resolveAlias(fromIndex, zkController);
       // TODO DWS: no need for this since later, clusterState.getCollection will throw a reasonable
       // error
-      if (!zkController.getClusterState().hasCollection(resolved)) {
+      if (!zkController.getClusterState().hasCollection(fromCollection)) {
         throw new SolrException(
             SolrException.ErrorCode.BAD_REQUEST,
             "SolrCloud join: Collection '" + fromIndex + "' not found!");
       }
-      return findLocalReplicaForFromIndex(zkController, resolved);
+      return findLocalReplicaForFromIndex(
+          zkController, fromCollection, toCore, toField, fromField, localParams);
     }
     return fromIndex;
   }
@@ -355,17 +379,126 @@ public class ScoreJoinQParserPlugin extends QParserPlugin {
     }
   }
 
-  private static String findLocalReplicaForFromIndex(ZkController zkController, String fromIndex) {
-    String fromReplica = null;
+  private static String findLocalReplicaForFromIndex(
+      ZkController zkController,
+      String fromIndex,
+      SolrCore toCore,
+      String toField,
+      String fromField,
+      SolrParams localParams) {
+    final DocCollection fromCollection = zkController.getClusterState().getCollection(fromIndex);
+    final String nodeName = zkController.getNodeName();
+    if (fromCollection.getSlices().size() == 1) {
+      return getLocalSingleShard(zkController, fromIndex);
+    } else { // sharded from
+      final CloudDescriptor toCoreDescriptor = toCore.getCoreDescriptor().getCloudDescriptor();
+      final DocCollection toCollection =
+          zkController.getClusterState().getCollection(toCoreDescriptor.getCollectionName());
+
+      final String shardId = toCoreDescriptor.getShardId();
+      boolean isFromSideCheckRequired =
+          checkToSideRouter(toCore, toField, localParams, fromCollection, toCollection);
+
+      checkSliceRanges(toCollection, fromCollection, shardId);
+      return findCollocatedFromCore(
+          toCore, fromField, fromCollection, nodeName, isFromSideCheckRequired);
+    }
+  }
 
-    String nodeName = zkController.getNodeName();
-    for (Slice slice :
-        zkController.getClusterState().getCollection(fromIndex).getActiveSlicesArr()) {
-      if (fromReplica != null)
-        throw new SolrException(
-            SolrException.ErrorCode.BAD_REQUEST,
-            "SolrCloud join: To join with a sharded collection, use method=crossCollection.");
+  private static String findCollocatedFromCore(
+      SolrCore toCore,
+      String fromField,
+      DocCollection fromCollection,
+      String nodeName,
+      boolean isFromSideCheckRequired) {
+    final CloudDescriptor toCoreDescriptor = toCore.getCoreDescriptor().getCloudDescriptor();
+    String toShardId = toCoreDescriptor.getShardId();
+    final Slice fromShardReplicas = fromCollection.getActiveSlicesMap().get(toShardId);
+    for (Replica collocatedFrom :
+        fromShardReplicas.getReplicas(r -> r.getNodeName().equals(nodeName))) {
+      final String fromCore = collocatedFrom.getCoreName();
+      if (log.isDebugEnabled()) {
+        log.debug("<-{} @ {}", fromCore, toCoreDescriptor.getCoreNodeName());
+      }
+      // which replica to pick if there are many one?
+      // if router field is not set, "from" may fall back to uniqueKey, but only we attempt to pick
+      // local shard.
+      if (isFromSideCheckRequired) {
+        SolrCore fromCoreOnDemand[] = new SolrCore[1];
+        try {
+          checkRouterField(
+              () ->
+                  fromCoreOnDemand[0] == null
+                      ? fromCoreOnDemand[0] = toCore.getCoreContainer().getCore(fromCore)
+                      : fromCoreOnDemand[0],
+              fromCollection,
+              fromField);
+        } finally {
+          if (fromCoreOnDemand[0] != null) {
+            fromCoreOnDemand[0].close();
+          }
+        }
+      }
+      return fromCore;
+    }
+    final String shards =
+        fromShardReplicas.getReplicas().stream()
+            .map(r -> r.getShard() + ":" + r.getCoreName() + "@" + r.getNodeName())
+            .collect(Collectors.joining(","));
+    throw new SolrException(
+        SolrException.ErrorCode.BAD_REQUEST,
+        "Unable to find collocated \"from\" replica: "
+            + shards
+            + " at node: "
+            + nodeName
+            + " for "
+            + toShardId
+            + ". "
+            + USE_CROSSCOLLECTION);
+  }
 
+  private static boolean checkToSideRouter(
+      SolrCore toCore,
+      String toField,
+      SolrParams localParams,
+      DocCollection fromCollection,
+      DocCollection toCollection) {
+    String routerName = checkRouters(toCollection, fromCollection);
+    boolean checkFromRouterField = false;
+    switch (routerName) {
+      case PlainIdRouter.NAME: // mandatory field check
+        checkFromRouterField = true;
+        checkRouterField(() -> toCore, toCollection, toField);
+        break;
+      case CompositeIdRouter.NAME: // let you shoot your legs
+        if (localParams.getBool(CHECK_ROUTER_FIELD, true)) {
+          checkFromRouterField = true;
+          checkRouterField(() -> toCore, toCollection, toField);
+        }
+        break;
+      case ImplicitDocRouter.NAME: // don't check field, you know what you do
+      default:
+        // if router field is not set, "to" may fallback to uniqueKey
+        if (localParams.getBool(CHECK_ROUTER_FIELD, true)) {
+          checkRouterField(() -> toCore, toCollection, toField);
+        }
+        break;
+    }
+    return checkFromRouterField;
+  }
+
+  /**
+   * Finds a core of collocated single shard collection.
+   *
+   * @return core name of the collocated single shard collection
+   */
+  private static String getLocalSingleShard(ZkController zkController, String fromIndex) {
+    final DocCollection fromCollection = zkController.getClusterState().getCollection(fromIndex);
+    final String nodeName = zkController.getNodeName();
+    String fromReplica = null;
+
+    for (Slice slice : fromCollection.getActiveSlicesArr()) {
+      assert fromReplica == null;
       for (Replica replica : slice.getReplicas()) {
         if (replica.getNodeName().equals(nodeName)) {
           fromReplica = replica.getStr(ZkStateReader.CORE_NAME_PROP);
@@ -388,10 +521,88 @@ public class ScoreJoinQParserPlugin extends QParserPlugin {
     }
 
     if (fromReplica == null)
+      throw new SolrException(SolrException.ErrorCode.BAD_REQUEST, USE_CROSSCOLLECTION);
+
+    return fromReplica;
+  }
+
+  private static void checkSliceRanges(
+      DocCollection toCollection, DocCollection fromCollection, String shardId) {
+    final DocRouter.Range toRange = toCollection.getSlice(shardId).getRange();
+    final DocRouter.Range fromRange = fromCollection.getSlice(shardId).getRange();
+    if (toRange == null && fromRange == null) {
+      // perhaps these are implicit routers
+      return;
+    }
+    if ((toRange == null) != (fromRange == null)) {
       throw new SolrException(
           SolrException.ErrorCode.BAD_REQUEST,
-          "SolrCloud join: To join with a collection that might not be co-located, use method=crossCollection.");
+          "Expecting non null slice ranges for shard:"
+              + shardId
+              + " of "
+              + toCollection
+              + " "
+              + toRange
+              + " and "
+              + fromCollection
+              + " "
+              + fromRange
+              + ". "
+              + USE_CROSSCOLLECTION);
+    }
+    if (!toRange.isSubsetOf(fromRange)) {
+      throw new SolrException(
+          SolrException.ErrorCode.BAD_REQUEST,
+          "Expecting hash range of collection:"
+              + toCollection.getName()
+              + " of shard:"
+              + shardId
+              + " "
+              + toRange
+              + " to be a subset of the same shard of collection:"
+              + fromCollection.getName()
+              + ", which is "
+              + fromRange
+              + ". "
+              + USE_CROSSCOLLECTION);
+    }
+  }
 
-    return fromReplica;
+  private static void checkRouterField(
+      Supplier<SolrCore> toCore, DocCollection fromCollection, String fromField) {
+    String routeField = fromCollection.getRouter().getRouteField(fromCollection);
+    if (routeField == null) {
+      routeField = toCore.get().getLatestSchema().getUniqueKeyField().getName();
+    }
+    if (!fromField.equals(routeField)) {
+      throw new SolrException(
+          SolrException.ErrorCode.BAD_REQUEST,
+          "collection:"
+              + fromCollection.getName()
+              + " is sharded by: '"
+              + routeField
+              + "', but attempting to join via '"
+              + fromField
+              + "' field. "
+              + USE_CROSSCOLLECTION);
+    }
+  }
+
+  private static String checkRouters(DocCollection collection, DocCollection fromCollection) {
+    final String routerName = collection.getRouter().getName();
+    if (!routerName.equals(fromCollection.getRouter().getName())) {
+      throw new SolrException(
+          SolrException.ErrorCode.BAD_REQUEST,
+          collection.getName()
+              + " and "
+              + fromCollection.getName()
+              + " should have the same routers, but they are: "
+              + collection.getRouter().getName()
+              + " and "
+              + fromCollection.getRouter().getName()
+              + ". "
+              + USE_CROSSCOLLECTION);
+    }
+    return routerName;
   }
 }
diff --git a/solr/core/src/test/org/apache/solr/cluster/placement/plugins/StubShardAffinityPlacementFactory.java b/solr/core/src/test/org/apache/solr/cluster/placement/plugins/StubShardAffinityPlacementFactory.java
new file mode 100644
index 00000000000..e7c0626c6cd
--- /dev/null
+++ b/solr/core/src/test/org/apache/solr/cluster/placement/plugins/StubShardAffinityPlacementFactory.java
@@ -0,0 +1,100 @@
+/*
+ * 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.plugins;
+
+import java.util.Collection;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import org.apache.solr.cluster.Cluster;
+import org.apache.solr.cluster.Node;
+import org.apache.solr.cluster.placement.AttributeFetcher;
+import org.apache.solr.cluster.placement.AttributeFetcherForTest;
+import org.apache.solr.cluster.placement.BalancePlanFactory;
+import org.apache.solr.cluster.placement.CollectionMetrics;
+import org.apache.solr.cluster.placement.NodeMetric;
+import org.apache.solr.cluster.placement.PlacementContext;
+import org.apache.solr.cluster.placement.PlacementException;
+import org.apache.solr.cluster.placement.PlacementPlan;
+import org.apache.solr.cluster.placement.PlacementPlanFactory;
+import org.apache.solr.cluster.placement.PlacementPlugin;
+import org.apache.solr.cluster.placement.PlacementRequest;
+import org.apache.solr.cluster.placement.impl.AttributeValuesImpl;
+import org.apache.solr.cluster.placement.impl.NodeMetricImpl;
+
+public class StubShardAffinityPlacementFactory extends AffinityPlacementFactory {
+
+  @Override
+  public PlacementPlugin createPluginInstance() {
+    return new AffinityPlacementPlugin(
+        config.minimalFreeDiskGB,
+        config.prioritizedFreeDiskGB,
+        config.withCollection,
+        config.withCollectionShards,
+        config.collectionNodeType,
+        false) {
+      @Override
+      public List<PlacementPlan> computePlacements(
+          Collection<PlacementRequest> requests, PlacementContext placementContext)
+          throws PlacementException {
+        final Map<String, Map<Node, String>> sysprops = new HashMap<>();
+        final Map<NodeMetric<?>, Map<Node, Object>> metrics = new HashMap<>();
+        final Map<String, CollectionMetrics> collectionMetrics = new HashMap<>();
+
+        for (Node node : placementContext.getCluster().getLiveNodes()) {
+          metrics.computeIfAbsent(NodeMetricImpl.NUM_CORES, n -> new HashMap<>()).put(node, 1);
+          metrics
+              .computeIfAbsent(NodeMetricImpl.FREE_DISK_GB, n -> new HashMap<>())
+              .put(node, (double) 10);
+          metrics
+              .computeIfAbsent(NodeMetricImpl.TOTAL_DISK_GB, n -> new HashMap<>())
+              .put(node, (double) 100);
+        }
+        final PlacementContext wrappingContext;
+        wrappingContext =
+            new PlacementContext() {
+
+              private AttributeFetcherForTest fetcherForTest =
+                  new AttributeFetcherForTest(
+                      new AttributeValuesImpl(sysprops, metrics, collectionMetrics));
+
+              @Override
+              public Cluster getCluster() {
+                return placementContext.getCluster();
+              }
+
+              @Override
+              public AttributeFetcher getAttributeFetcher() {
+                return fetcherForTest;
+              }
+
+              @Override
+              public PlacementPlanFactory getPlacementPlanFactory() {
+                return placementContext.getPlacementPlanFactory();
+              }
+
+              @Override
+              public BalancePlanFactory getBalancePlanFactory() {
+                return placementContext.getBalancePlanFactory();
+              }
+            };
+        return super.computePlacements(requests, wrappingContext);
+      }
+    };
+  }
+}
diff --git a/solr/core/src/test/org/apache/solr/search/join/ShardJoinCompositeTest.java b/solr/core/src/test/org/apache/solr/search/join/ShardJoinCompositeTest.java
new file mode 100644
index 00000000000..6aa0658317b
--- /dev/null
+++ b/solr/core/src/test/org/apache/solr/search/join/ShardJoinCompositeTest.java
@@ -0,0 +1,158 @@
+/*
+ * 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.search.join;
+
+import java.util.HashMap;
+import org.apache.solr.client.solrj.impl.CloudSolrClient;
+import org.apache.solr.client.solrj.request.CollectionAdminRequest;
+import org.apache.solr.client.solrj.request.QueryRequest;
+import org.apache.solr.client.solrj.response.CollectionAdminResponse;
+import org.apache.solr.common.SolrException;
+import org.apache.solr.common.SolrInputDocument;
+import org.apache.solr.common.cloud.ImplicitDocRouter;
+import org.junit.BeforeClass;
+import org.junit.Test;
+
+public class ShardJoinCompositeTest extends ShardToShardJoinAbstract {
+
+  @BeforeClass
+  public static void setupCluster() throws Exception {
+    setupCluster( // collocate child to parent
+        createChild -> {},
+        createParent -> {},
+        parent -> new SolrInputDocument(),
+        (child, parent) -> {
+          final SolrInputDocument childDoc = new SolrInputDocument();
+          childDoc.setField("id", parent + "!" + child);
+
+          return childDoc;
+        });
+    final CollectionAdminResponse process =
+        CollectionAdminRequest.collectionStatus(toColl).process(cluster.getSolrClient());
+    // System.out.println(process);
+    {
+      final CollectionAdminRequest.Create wrongRouter =
+          CollectionAdminRequest.createCollection("wrongRouter", "_default", 3, 2)
+              .setProperties(new HashMap<>());
+      wrongRouter.setRouterName(ImplicitDocRouter.NAME);
+      wrongRouter.setShards("shard1,shard2,shard3");
+      wrongRouter.process(cluster.getSolrClient());
+    }
+    {
+      final CollectionAdminRequest.Create wrongShards =
+          CollectionAdminRequest.createCollection("wrongShards", "_default", 5, 2)
+              .setProperties(new HashMap<>());
+      wrongShards.process(cluster.getSolrClient());
+    }
+  }
+
+  @Test
+  public void testScore() throws Exception {
+    // without score
+    testJoins(toColl, fromColl, "checkRouterField=false", true);
+  }
+
+  @Test(expected = SolrException.class)
+  public void testScoreFailOnFieldCheck() throws Exception {
+    try {
+      testJoins(toColl, fromColl, random().nextBoolean() ? "checkRouterField=true" : "", true);
+    } catch (Exception e) {
+      assertTrue(e.getMessage().contains("id"));
+      assertTrue(e.getMessage().contains("field"));
+      assertTrue(e.getMessage().contains("parent_id_s"));
+      throw e;
+    }
+  }
+
+  @Test(expected = SolrException.class)
+  public void testWrongRouter() throws Exception {
+    final String fromQ = "name_sI:" + 1011;
+    final String joinQ =
+        "{!join "
+            + "from=parent_id_s fromIndex="
+            + "wrongRouter" // fromColl
+            + " "
+            + " to=id}"
+            + fromQ;
+    QueryRequest qr = new QueryRequest(params("collection", toColl, "q", joinQ, "fl", "*"));
+    CloudSolrClient client = cluster.getSolrClient();
+    try {
+      client.request(qr);
+    } catch (Exception e) {
+      assertTrue(e.getMessage().contains("router"));
+      assertTrue(e.getMessage().contains(toColl));
+      assertTrue(e.getMessage().contains("wrongRouter"));
+      throw e;
+    }
+  }
+
+  @Test(expected = SolrException.class)
+  public void testWrongShards() throws Exception {
+    final String fromQ = "name_sI:" + 1011;
+    final String joinQ =
+        "{!join "
+            + "from=parent_id_s fromIndex="
+            + "wrongShards" // fromColl
+            + " "
+            + " to=id}"
+            + fromQ;
+    QueryRequest qr = new QueryRequest(params("collection", toColl, "q", joinQ, "fl", "*"));
+    CloudSolrClient client = cluster.getSolrClient();
+    try {
+      client.request(qr);
+    } catch (Exception e) {
+      assertTrue(e.getMessage().contains("hash"));
+      assertTrue(e.getMessage().contains("range"));
+      throw e;
+    }
+  }
+
+  @Test(expected = SolrException.class)
+  public void testWrongRouterBack() throws Exception {
+    final String fromQ = "name_sI:" + 1011;
+    final String joinQ =
+        "{!join " + "from=parent_id_s fromIndex=" + fromColl + " " + " to=id}" + fromQ;
+    QueryRequest qr = new QueryRequest(params("collection", "wrongRouter", "q", joinQ, "fl", "*"));
+    CloudSolrClient client = cluster.getSolrClient();
+    try {
+      client.request(qr);
+    } catch (Exception e) {
+      assertTrue(e.getMessage().contains("router"));
+      assertTrue(e.getMessage().contains(fromColl));
+      assertTrue(e.getMessage().contains("wrongRouter"));
+      throw e;
+    }
+  }
+
+  @Test
+  public void testNoScore() throws Exception {
+    testJoins(toColl, fromColl, "checkRouterField=false", false);
+  }
+
+  @Test(expected = SolrException.class)
+  public void testNoScoreFailOnFieldCheck() throws Exception {
+    try {
+      testJoins(toColl, fromColl, random().nextBoolean() ? "checkRouterField=true" : "", false);
+    } catch (Exception e) {
+      assertTrue(e.getMessage().contains("id"));
+      assertTrue(e.getMessage().contains("field"));
+      assertTrue(e.getMessage().contains("parent_id_s"));
+      throw e;
+    }
+  }
+}
diff --git a/solr/core/src/test/org/apache/solr/search/join/ShardJoinImplicitTest.java b/solr/core/src/test/org/apache/solr/search/join/ShardJoinImplicitTest.java
new file mode 100644
index 00000000000..7ec0f6cbb93
--- /dev/null
+++ b/solr/core/src/test/org/apache/solr/search/join/ShardJoinImplicitTest.java
@@ -0,0 +1,97 @@
+/*
+ * 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.search.join;
+
+import java.util.HashMap;
+import org.apache.solr.client.solrj.request.CollectionAdminRequest;
+import org.apache.solr.common.SolrException;
+import org.apache.solr.common.SolrInputDocument;
+import org.apache.solr.common.cloud.CompositeIdRouter;
+import org.apache.solr.common.cloud.ImplicitDocRouter;
+import org.junit.BeforeClass;
+import org.junit.Test;
+
+/** use router.field to collocate children to parent */
+public class ShardJoinImplicitTest extends ShardToShardJoinAbstract {
+
+  private static String[] shards = new String[] {"a", "b", "c", "d", "e"};
+
+  @BeforeClass
+  public static void setupCluster() throws Exception {
+
+    setupCluster( // collocate child to parent
+        createChild -> {
+          createChild.setRouterName(ImplicitDocRouter.NAME);
+          createChild.setShards(String.join(",", shards));
+        },
+        createParent -> {
+          createParent.setRouterName(ImplicitDocRouter.NAME);
+          createParent.setShards(String.join(",", shards));
+        },
+        parent -> {
+          final SolrInputDocument parentDoc = new SolrInputDocument();
+          parentDoc.setField("_route_", shards[(parent.hashCode()) % shards.length]);
+          return parentDoc;
+        },
+        (child, parent) -> {
+          final SolrInputDocument childDoc = new SolrInputDocument();
+          childDoc.setField("id", child);
+          childDoc.setField("_route_", shards[parent.hashCode() % shards.length]);
+          return childDoc;
+        });
+    final CollectionAdminRequest.Create wrongRouter =
+        CollectionAdminRequest.createCollection("wrongRouter", "_default", 3, 2)
+            .setProperties(new HashMap<>());
+    wrongRouter.setRouterName(CompositeIdRouter.NAME);
+    wrongRouter.process(cluster.getSolrClient());
+  }
+
+  @Test
+  public void testScore() throws Exception {
+    testJoins(toColl, fromColl, "checkRouterField=false", true);
+  }
+
+  @Test(expected = SolrException.class)
+  public void testScoreFailOnFieldCheck() throws Exception {
+    try {
+      testJoins(toColl, fromColl, random().nextBoolean() ? "checkRouterField=true" : "", true);
+    } catch (Exception e) {
+      assertTrue(e.getMessage().contains("id"));
+      assertTrue(e.getMessage().contains("field"));
+      assertTrue(e.getMessage().contains("parent_id_s"));
+      throw e;
+    }
+  }
+
+  @Test
+  public void testNoScore() throws Exception {
+    testJoins(toColl, fromColl, "checkRouterField=false", false);
+  }
+
+  @Test(expected = SolrException.class)
+  public void testNoScoreFailOnFieldCheck() throws Exception {
+    try {
+      testJoins(toColl, fromColl, random().nextBoolean() ? "checkRouterField=true" : "", false);
+    } catch (Exception e) {
+      assertTrue(e.getMessage().contains("id"));
+      assertTrue(e.getMessage().contains("field"));
+      assertTrue(e.getMessage().contains("parent_id_s"));
+      throw e;
+    }
+  }
+}
diff --git a/solr/core/src/test/org/apache/solr/search/join/ShardJoinRouterTest.java b/solr/core/src/test/org/apache/solr/search/join/ShardJoinRouterTest.java
new file mode 100644
index 00000000000..336b88c3945
--- /dev/null
+++ b/solr/core/src/test/org/apache/solr/search/join/ShardJoinRouterTest.java
@@ -0,0 +1,49 @@
+/*
+ * 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.search.join;
+
+import org.apache.solr.common.SolrInputDocument;
+import org.junit.BeforeClass;
+import org.junit.Test;
+
+/** use router.field to collocate children to parent */
+public class ShardJoinRouterTest extends ShardToShardJoinAbstract {
+
+  @BeforeClass
+  public static void setupCluster() throws Exception {
+    setupCluster( // collocate child to parent
+        createChild -> createChild.setRouterField("parent_id_s"),
+        createParent -> {},
+        parent -> new SolrInputDocument(),
+        (child, parent) -> {
+          final SolrInputDocument childDoc = new SolrInputDocument();
+          childDoc.setField("id", child);
+          return childDoc;
+        });
+  }
+
+  @Test
+  public void testScore() throws Exception {
+    testJoins(toColl, fromColl, "", true);
+  }
+
+  @Test
+  public void testNoScore() throws Exception {
+    testJoins(toColl, fromColl, "", false);
+  }
+}
diff --git a/solr/core/src/test/org/apache/solr/search/join/ShardToShardJoinAbstract.java b/solr/core/src/test/org/apache/solr/search/join/ShardToShardJoinAbstract.java
new file mode 100644
index 00000000000..ece5e5338a6
--- /dev/null
+++ b/solr/core/src/test/org/apache/solr/search/join/ShardToShardJoinAbstract.java
@@ -0,0 +1,265 @@
+/*
+ * 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.search.join;
+
+import static java.util.Collections.singletonMap;
+
+import java.io.IOException;
+import java.lang.invoke.MethodHandles;
+import java.nio.file.Path;
+import java.util.AbstractMap;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.function.BiFunction;
+import java.util.function.Consumer;
+import java.util.function.Function;
+import java.util.stream.Collectors;
+import org.apache.solr.client.solrj.SolrServerException;
+import org.apache.solr.client.solrj.impl.CloudSolrClient;
+import org.apache.solr.client.solrj.request.CollectionAdminRequest;
+import org.apache.solr.client.solrj.request.QueryRequest;
+import org.apache.solr.client.solrj.request.UpdateRequest;
+import org.apache.solr.client.solrj.request.V2Request;
+import org.apache.solr.client.solrj.request.beans.PluginMeta;
+import org.apache.solr.client.solrj.response.CollectionAdminResponse;
+import org.apache.solr.client.solrj.response.QueryResponse;
+import org.apache.solr.cloud.MiniSolrCloudCluster;
+import org.apache.solr.cloud.SolrCloudTestCase;
+import org.apache.solr.cluster.placement.PlacementPluginFactory;
+import org.apache.solr.cluster.placement.plugins.AffinityPlacementConfig;
+import org.apache.solr.cluster.placement.plugins.StubShardAffinityPlacementFactory;
+import org.apache.solr.common.SolrDocument;
+import org.apache.solr.common.SolrDocumentList;
+import org.apache.solr.common.SolrInputDocument;
+import org.junit.AfterClass;
+import org.junit.BeforeClass;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/** Tests using fromIndex that points to a collection in SolrCloud mode. */
+// @LogLevel("org.apache.solr.schema.IndexSchema=TRACE")
+public class ShardToShardJoinAbstract extends SolrCloudTestCase {
+
+  private static final Logger log = LoggerFactory.getLogger(MethodHandles.lookup().lookupClass());
+
+  private static final String[] scoreModes = {"avg", "max", "min", "total"};
+
+  //    resetExceptionIgnores();
+  protected static String toColl = "parent";
+  protected static String fromColl = "children";
+
+  @BeforeClass
+  public static void setPropos() {
+    System.setProperty("solr.test.sys.prop1", "propone");
+    System.setProperty("solr.test.sys.prop2", "proptwo");
+  }
+
+  public static void setupCluster(
+      Consumer<CollectionAdminRequest.Create> fromDecorator,
+      Consumer<CollectionAdminRequest.Create> parentDecorator,
+      Function<String, SolrInputDocument> parentDocFactory,
+      BiFunction<String, String, SolrInputDocument> childDocFactory)
+      throws Exception {
+    final Path configDir = TEST_COLL1_CONF();
+
+    String configName = "_default"; // "solrCloudCollectionConfig";
+    int nodeCount = 5;
+    final MiniSolrCloudCluster cloudCluster =
+        configureCluster(nodeCount) // .addConfig(configName, configDir)
+            .configure();
+
+    PluginMeta plugin = new PluginMeta();
+    plugin.name = PlacementPluginFactory.PLUGIN_NAME;
+    plugin.klass = StubShardAffinityPlacementFactory.class.getName();
+    plugin.config =
+        new AffinityPlacementConfig(
+            1, 2, Collections.emptyMap(), Map.of(toColl, fromColl), Map.of());
+
+    V2Request req =
+        new V2Request.Builder("/cluster/plugin")
+            .forceV2(true)
+            .POST()
+            .withPayload(singletonMap("add", plugin))
+            .build();
+    req.process(cluster.getSolrClient());
+    // TODO await completion
+    // version = phaser.awaitAdvanceInterruptibly(version, 10, TimeUnit.SECONDS);
+
+    Map<String, String> collectionProperties = new HashMap<>();
+
+    // create a collection holding data for the "to" side of the JOIN
+
+    final CollectionAdminRequest.Create createChildCollReq =
+        CollectionAdminRequest.createCollection(fromColl, configName, 3, 2)
+            .setProperties(collectionProperties);
+    fromDecorator.accept(createChildCollReq);
+    createChildCollReq.process(cluster.getSolrClient());
+
+    // collectionProperties.put("router.field", "id");
+    final CollectionAdminRequest.Create parentReq =
+        CollectionAdminRequest.createCollection(toColl, configName, 3, 2)
+            .setProperties(collectionProperties);
+    parentDecorator.accept(parentReq);
+    parentReq.process(cluster.getSolrClient());
+    // TODO await completion
+
+    List<SolrInputDocument> parents = new ArrayList<>();
+    List<SolrInputDocument> children = new ArrayList<>();
+    for (int parent = 1000; parent <= 10 * 1000; parent += 1000) {
+      int firstChild = parent + (parent / 100) + 1;
+      for (int child = firstChild; child - firstChild <= 10; child += 1) {
+        SolrInputDocument childDoc = childDocFactory.apply("" + child, "" + parent);
+        childDoc.setField("name_sI", "" + child); // search by
+        childDoc.setField("parent_id_s", "" + parent); // join by
+        children.add(childDoc);
+      }
+      SolrInputDocument parentDoc = parentDocFactory.apply("" + parent);
+      parentDoc.setField("id", "" + parent);
+      parents.add(parentDoc);
+    }
+    UpdateRequest upChild = new UpdateRequest();
+    upChild.add(children);
+    upChild.commit(cluster.getSolrClient(), fromColl);
+
+    UpdateRequest upParent = new UpdateRequest();
+    upParent.add(parents);
+    upParent.commit(cluster.getSolrClient(), toColl);
+
+    final CollectionAdminResponse process =
+        CollectionAdminRequest.getClusterStatus().process(cluster.getSolrClient());
+  }
+
+  @AfterClass
+  public static void shutdown() {
+    System.clearProperty("solr.test.sys.prop1");
+    System.clearProperty("solr.test.sys.prop2");
+    log.info("logic complete ... deleting the {} and {} collections", toColl, fromColl);
+
+    // try to clean up
+    for (String c : new String[] {toColl, fromColl}) {
+      try {
+        CollectionAdminRequest.Delete req = CollectionAdminRequest.deleteCollection(c);
+        req.process(cluster.getSolrClient());
+      } catch (Exception e) {
+        // don't fail the test
+        log.warn("Could not delete collection {} after test completed due to:", c, e);
+      }
+    }
+
+    log.info("succeeded ... shutting down now!");
+  }
+
+  protected void testJoins(
+      String toColl, String fromColl, String qLocalParams, boolean isScoresTest)
+      throws SolrServerException, IOException {
+    // verify the join with fromIndex works
+
+    CloudSolrClient client = cluster.getSolrClient();
+    List<Map.Entry<Integer, Integer>> parentToChild = new ArrayList<>();
+    {
+      for (int parent = 1000; parent <= 10 * 1000; parent += 1000) {
+        int firstChild = parent + (parent / 100) + 1;
+        for (int child = firstChild + random().nextInt(3);
+            child - firstChild <= 10;
+            child += 1 + random().nextInt(3)) {
+          {
+            final String fromQ = "name_sI:" + child;
+            final String joinQ =
+                "{!join "
+                    + anyScoreMode(isScoresTest)
+                    + "from=parent_id_s fromIndex="
+                    + fromColl
+                    + " "
+                    + qLocalParams
+                    + " to=id}"
+                    + fromQ;
+            QueryRequest qr = new QueryRequest(params("collection", toColl, "q", joinQ, "fl", "*"));
+            QueryResponse rsp = new QueryResponse(client.request(qr), client);
+            SolrDocumentList hits = rsp.getResults();
+            assertEquals("Expected 1 doc, got " + hits, 1, hits.getNumFound());
+            SolrDocument doc = hits.get(0);
+            assertEquals("" + parent, doc.getFirstValue("id"));
+          }
+          parentToChild.add(new AbstractMap.SimpleEntry<>(parent, child));
+          // let's join from child to parent
+          {
+            final String fromQ = "id:" + parent;
+            final String joinQ =
+                "{!join "
+                    + anyScoreMode(isScoresTest)
+                    + "to=parent_id_s fromIndex="
+                    + toColl
+                    + " "
+                    + qLocalParams
+                    + " from=id}"
+                    + fromQ;
+            QueryRequest qr =
+                new QueryRequest(
+                    params("collection", fromColl, "q", joinQ, "fl", "*", "rows", "20"));
+            QueryResponse rsp = new QueryResponse(client.request(qr), client);
+            SolrDocumentList hits = rsp.getResults();
+            assertEquals("Expected 11 doc, got " + hits, 10 + 1, hits.getNumFound());
+            for (SolrDocument doc : hits) {
+              assertEquals("" + parent, doc.getFirstValue("parent_id_s"));
+            }
+          }
+        }
+      }
+    }
+    for (int pass = 0; pass < 5; pass++) {
+      // pick two children, join'em to two or single parent
+      Collections.shuffle(parentToChild, random());
+
+      final String fromQ =
+          "name_sI:("
+              + parentToChild.get(0).getValue()
+              + " "
+              + parentToChild.get(1).getValue()
+              + ")";
+      final String joinQ =
+          "{!join "
+              + anyScoreMode(isScoresTest)
+              + "from=parent_id_s fromIndex="
+              + fromColl
+              + " "
+              + qLocalParams
+              + " to=id}"
+              + fromQ;
+      QueryRequest qr = new QueryRequest(params("collection", toColl, "q", joinQ, "fl", "*"));
+      QueryResponse rsp = new QueryResponse(client.request(qr), client);
+      SolrDocumentList hits = rsp.getResults();
+      final Set<String> expect =
+          new HashSet<>(
+              Arrays.asList(
+                  parentToChild.get(0).getKey().toString(),
+                  parentToChild.get(1).getKey().toString()));
+      final Set<Object> act =
+          hits.stream().map(doc -> doc.getFirstValue("id")).collect(Collectors.toSet());
+      assertEquals(expect, act);
+    }
+  }
+
+  private String anyScoreMode(boolean isScoresTest) {
+    return isScoresTest ? "score=" + (scoreModes[random().nextInt(scoreModes.length)]) + " " : "";
+  }
+}
diff --git a/solr/solr-ref-guide/modules/query-guide/pages/join-query-parser.adoc b/solr/solr-ref-guide/modules/query-guide/pages/join-query-parser.adoc
index 683d8b95a7b..8c6e876c30d 100644
--- a/solr/solr-ref-guide/modules/query-guide/pages/join-query-parser.adoc
+++ b/solr/solr-ref-guide/modules/query-guide/pages/join-query-parser.adoc
@@ -185,7 +185,44 @@ If a local replica is not available or active, then the query will fail.
 At this point, it should be clear that since you're limited to a single shard and the data must be replicated across all nodes where it is needed, this approach works better with smaller data sets where there is a one-to-many relationship between the from collection and the to collection.
 Moreover, if you add a replica to the to collection, then you also need to add a replica for the from collection.
 
-For more information, Erick Erickson has written a blog post about join performance titled https://lucidworks.com/2012/06/20/solr-and-joins/[Solr and Joins].
+For more information, Erick Erickson has written a blog post about join performance titled https://lucidworks.com/post/solr-and-joins/[Solr and Joins].
+
+== Joining Multiple Shard Collections
+It's also possible to join collections with:
+
+. the same number of shards
+. same `router.name` for both collections
+. `router.field` or `uniqueKey` should correspond to fields used in `to`,`from` parameters (see details and exceptions below)
+. collections' shards are collocated (see example below)
+
+Just for simplification we use the example of joining `from` "many" `to` "one". Though, same approach may be used in other cases as well.
+
+|===
+| |"to" collection|"from" collection
+
+|node 1 |products_shard1_replica1 |skus_shard1_replica2
+|node 2 |products_shard1_replica2 |skus_shard1_replica1
+|node 3 |products_shard2_replica1 |skus_shard2_replica1
+|node 4 |products_shard2_replica2 |skus_shard2_replica2
+|===
+
+Notice how shardN corresponds to its' counterpart from other side collection.
+Use xref:configuration-guide:replica-placement-plugins.adoc[AffinityPlacementPlugin.withCollectionShards] to align collection shards.
+
+Here are the supported options for collection routing:
+
+|===
+| `router.name`| field constraint
+
+|`implicit`|`from`, `to` match  `router.field`
+|`plain` or `compositeId`| `from`, `to` match either `uniqueKey` or `router.field`
+|`compositeId` (the default one)|constraint can be disabled by `checkRouterField=false`
+
+|===
+
+Note: the third case assumes indexer assigns `sku.id=<product.id>!<sku_id>` in this case `compositeId` logic put a product's sku into shard with the same name as their product. Also, you can turn `checkRouterField=false` and manually put every document via `\_route_` pseudo field.
+
+For example, if  `skus` collection has `router.field=product_id`, we can find products of red colored skus via `q={!join to=id from=product_id fromIndex=skus}color:red`.
 
 == Cross Collection Join
 The Cross Collection Join Filter is a method for the join parser that will execute a query against a remote Solr collection to get back a set of join keys that will be used to as a filter query against the local Solr collection.
diff --git a/solr/test-framework/src/java/org/apache/solr/cluster/placement/AttributeFetcherForTest.java b/solr/test-framework/src/java/org/apache/solr/cluster/placement/AttributeFetcherForTest.java
index 9b4551850f7..78b1f78e7d8 100644
--- a/solr/test-framework/src/java/org/apache/solr/cluster/placement/AttributeFetcherForTest.java
+++ b/solr/test-framework/src/java/org/apache/solr/cluster/placement/AttributeFetcherForTest.java
@@ -25,7 +25,7 @@ public class AttributeFetcherForTest implements AttributeFetcher {
 
   private final AttributeValues attributeValues;
 
-  AttributeFetcherForTest(AttributeValues attributeValues) {
+  public AttributeFetcherForTest(AttributeValues attributeValues) {
     this.attributeValues = attributeValues;
   }