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;
}