Browse Source

Add new search shards endpoint (#94534)

This pull request introduces a new search shards endpoint that returns a 
list of shards where a search request will be executed. Unlike the
existing cluster search_shards API, this API performs the can_match
phase on both the coordinator and data nodes, which excludes
non-matching shards from the results.

Relates #93730
Nhat Nguyen 2 years ago
parent
commit
23e0ad3ef3

+ 5 - 0
docs/changelog/94534.yaml

@@ -0,0 +1,5 @@
+pr: 94534
+summary: Add search shards endpoint
+area: Search
+type: enhancement
+issues: []

+ 132 - 0
server/src/internalClusterTest/java/org/elasticsearch/action/search/SearchShardsIT.java

@@ -0,0 +1,132 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0 and the Server Side Public License, v 1; you may not use this file except
+ * in compliance with, at your election, the Elastic License 2.0 or the Server
+ * Side Public License, v 1.
+ */
+
+package org.elasticsearch.action.search;
+
+import org.elasticsearch.cluster.metadata.IndexMetadata;
+import org.elasticsearch.common.settings.Settings;
+import org.elasticsearch.index.query.MatchAllQueryBuilder;
+import org.elasticsearch.index.query.RangeQueryBuilder;
+import org.elasticsearch.search.builder.SearchSourceBuilder;
+import org.elasticsearch.test.ESIntegTestCase;
+import org.elasticsearch.test.hamcrest.ElasticsearchAssertions;
+
+import static org.hamcrest.Matchers.empty;
+import static org.hamcrest.Matchers.equalTo;
+import static org.hamcrest.Matchers.hasSize;
+import static org.hamcrest.Matchers.not;
+
+public class SearchShardsIT extends ESIntegTestCase {
+
+    public void testBasic() {
+        int indicesWithData = between(1, 10);
+        for (int i = 0; i < indicesWithData; i++) {
+            String index = "index-with-data-" + i;
+            ElasticsearchAssertions.assertAcked(
+                admin().indices().prepareCreate(index).setSettings(Settings.builder().put(IndexMetadata.SETTING_NUMBER_OF_SHARDS, 1))
+            );
+            int numDocs = randomIntBetween(1, 10);
+            for (int j = 0; j < numDocs; j++) {
+                client().prepareIndex(index).setSource("value", i).setId(Integer.toString(i)).get();
+            }
+            client().admin().indices().prepareRefresh(index).get();
+        }
+        int indicesWithoutData = between(1, 10);
+        for (int i = 0; i < indicesWithoutData; i++) {
+            String index = "index-without-data-" + i;
+            ElasticsearchAssertions.assertAcked(
+                admin().indices().prepareCreate(index).setSettings(Settings.builder().put(IndexMetadata.SETTING_NUMBER_OF_SHARDS, 1))
+            );
+        }
+        // Range query
+        {
+            RangeQueryBuilder rangeQuery = new RangeQueryBuilder("value").from(0).includeLower(true);
+            var request = new SearchShardsRequest(
+                new String[] { "index-*" },
+                SearchRequest.DEFAULT_INDICES_OPTIONS,
+                rangeQuery,
+                null,
+                null,
+                randomBoolean()
+            );
+            var resp = client().execute(SearchShardsAction.INSTANCE, request).actionGet();
+            assertThat(resp.getGroups(), hasSize(indicesWithData + indicesWithoutData));
+            int skipped = 0;
+            for (SearchShardsGroup g : resp.getGroups()) {
+                String indexName = g.shardId().getIndexName();
+                assertThat(g.allocatedNodes(), not(empty()));
+                assertTrue(g.preFiltered());
+                if (indexName.contains("without")) {
+                    assertTrue(g.skipped());
+                    skipped++;
+                } else {
+                    assertFalse(g.skipped());
+                }
+            }
+            assertThat(skipped, equalTo(indicesWithoutData));
+        }
+        // Match all
+        {
+            MatchAllQueryBuilder matchAll = new MatchAllQueryBuilder();
+            var request = new SearchShardsRequest(
+                new String[] { "index-*" },
+                SearchRequest.DEFAULT_INDICES_OPTIONS,
+                matchAll,
+                null,
+                null,
+                randomBoolean()
+            );
+            SearchShardsResponse resp = client().execute(SearchShardsAction.INSTANCE, request).actionGet();
+            assertThat(resp.getGroups(), hasSize(indicesWithData + indicesWithoutData));
+            for (SearchShardsGroup g : resp.getGroups()) {
+                assertFalse(g.skipped());
+                assertTrue(g.preFiltered());
+            }
+        }
+    }
+
+    public void testRandom() {
+        int numIndices = randomIntBetween(1, 10);
+        for (int i = 0; i < numIndices; i++) {
+            String index = "index-" + i;
+            ElasticsearchAssertions.assertAcked(
+                admin().indices()
+                    .prepareCreate(index)
+                    .setSettings(Settings.builder().put(IndexMetadata.SETTING_NUMBER_OF_SHARDS, between(1, 5)))
+            );
+            int numDocs = randomIntBetween(10, 1000);
+            for (int j = 0; j < numDocs; j++) {
+                client().prepareIndex(index).setSource("value", i).setId(Integer.toString(i)).get();
+            }
+            client().admin().indices().prepareRefresh(index).get();
+        }
+        int iterations = iterations(2, 10);
+        for (int i = 0; i < iterations; i++) {
+            long from = randomLongBetween(1, 100);
+            long to = randomLongBetween(from, from + 100);
+            String preference = randomBoolean() ? null : randomAlphaOfLength(10);
+            RangeQueryBuilder rangeQuery = new RangeQueryBuilder("value").from(from).to(to).includeUpper(true).includeLower(true);
+            SearchRequest searchRequest = new SearchRequest().indices("index-*").source(new SearchSourceBuilder().query(rangeQuery));
+            searchRequest.setPreFilterShardSize(1);
+            SearchResponse searchResponse = client().search(searchRequest).actionGet();
+            var searchShardsRequest = new SearchShardsRequest(
+                new String[] { "index-*" },
+                SearchRequest.DEFAULT_INDICES_OPTIONS,
+                rangeQuery,
+                null,
+                preference,
+                randomBoolean()
+            );
+            var searchShardsResponse = client().execute(SearchShardsAction.INSTANCE, searchShardsRequest).actionGet();
+
+            assertThat(searchShardsResponse.getGroups(), hasSize(searchResponse.getTotalShards()));
+            long skippedShards = searchShardsResponse.getGroups().stream().filter(SearchShardsGroup::skipped).count();
+            assertThat(skippedShards, equalTo((long) searchResponse.getSkippedShards()));
+        }
+    }
+}

+ 3 - 0
server/src/main/java/org/elasticsearch/action/ActionModule.java

@@ -239,12 +239,14 @@ import org.elasticsearch.action.search.RestClosePointInTimeAction;
 import org.elasticsearch.action.search.RestOpenPointInTimeAction;
 import org.elasticsearch.action.search.SearchAction;
 import org.elasticsearch.action.search.SearchScrollAction;
+import org.elasticsearch.action.search.SearchShardsAction;
 import org.elasticsearch.action.search.TransportClearScrollAction;
 import org.elasticsearch.action.search.TransportClosePointInTimeAction;
 import org.elasticsearch.action.search.TransportMultiSearchAction;
 import org.elasticsearch.action.search.TransportOpenPointInTimeAction;
 import org.elasticsearch.action.search.TransportSearchAction;
 import org.elasticsearch.action.search.TransportSearchScrollAction;
+import org.elasticsearch.action.search.TransportSearchShardsAction;
 import org.elasticsearch.action.support.ActionFilters;
 import org.elasticsearch.action.support.AutoCreateIndex;
 import org.elasticsearch.action.support.DestructiveOperations;
@@ -704,6 +706,7 @@ public class ActionModule extends AbstractModule {
         actions.register(SearchScrollAction.INSTANCE, TransportSearchScrollAction.class);
         actions.register(OpenPointInTimeAction.INSTANCE, TransportOpenPointInTimeAction.class);
         actions.register(ClosePointInTimeAction.INSTANCE, TransportClosePointInTimeAction.class);
+        actions.register(SearchShardsAction.INSTANCE, TransportSearchShardsAction.class);
         actions.register(MultiSearchAction.INSTANCE, TransportMultiSearchAction.class);
         actions.register(ExplainAction.INSTANCE, TransportExplainAction.class);
         actions.register(ClearScrollAction.INSTANCE, TransportClearScrollAction.class);

+ 20 - 0
server/src/main/java/org/elasticsearch/action/search/SearchShardsAction.java

@@ -0,0 +1,20 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0 and the Server Side Public License, v 1; you may not use this file except
+ * in compliance with, at your election, the Elastic License 2.0 or the Server
+ * Side Public License, v 1.
+ */
+
+package org.elasticsearch.action.search;
+
+import org.elasticsearch.action.ActionType;
+
+public class SearchShardsAction extends ActionType<SearchShardsResponse> {
+    public static final String NAME = "indices:admin/search/search_shards";
+    public static final SearchShardsAction INSTANCE = new SearchShardsAction();
+
+    private SearchShardsAction() {
+        super(NAME, SearchShardsResponse::new);
+    }
+}

+ 94 - 0
server/src/main/java/org/elasticsearch/action/search/SearchShardsGroup.java

@@ -0,0 +1,94 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0 and the Server Side Public License, v 1; you may not use this file except
+ * in compliance with, at your election, the Elastic License 2.0 or the Server
+ * Side Public License, v 1.
+ */
+
+package org.elasticsearch.action.search;
+
+import org.elasticsearch.common.io.stream.StreamInput;
+import org.elasticsearch.common.io.stream.StreamOutput;
+import org.elasticsearch.common.io.stream.Writeable;
+import org.elasticsearch.index.shard.ShardId;
+
+import java.io.IOException;
+import java.util.List;
+import java.util.Objects;
+
+/**
+ * Represents a group of nodes that a given ShardId is allocated on, along with information about
+ * whether this group might match the query or not.
+ */
+public class SearchShardsGroup implements Writeable {
+    private final ShardId shardId;
+    private final List<String> allocatedNodes;
+    private final boolean preFiltered;
+    private final boolean skipped;
+
+    public SearchShardsGroup(ShardId shardId, List<String> allocatedNodes, boolean preFiltered, boolean skipped) {
+        this.shardId = shardId;
+        this.allocatedNodes = allocatedNodes;
+        this.skipped = skipped;
+        this.preFiltered = preFiltered;
+        assert skipped == false || preFiltered;
+    }
+
+    public SearchShardsGroup(StreamInput in) throws IOException {
+        this.shardId = new ShardId(in);
+        this.allocatedNodes = in.readStringList();
+        this.skipped = in.readBoolean();
+        this.preFiltered = in.readBoolean();
+    }
+
+    @Override
+    public void writeTo(StreamOutput out) throws IOException {
+        shardId.writeTo(out);
+        out.writeStringCollection(allocatedNodes);
+        out.writeBoolean(skipped);
+        out.writeBoolean(preFiltered);
+    }
+
+    public ShardId shardId() {
+        return shardId;
+    }
+
+    /**
+     * Returns true if the target shards in this group won't match the query given {@link SearchShardsRequest}.
+     */
+    public boolean skipped() {
+        return skipped;
+    }
+
+    /**
+     * Returns true if the can_match was performed against this group. This flag is for BWC purpose. It's always
+     * true for a response from the new search_shards API; but always false for a response from the old API.
+     */
+    public boolean preFiltered() {
+        return preFiltered;
+    }
+
+    /**
+     * The list of node ids that shard copies on this group are allocated on.
+     */
+    public List<String> allocatedNodes() {
+        return allocatedNodes;
+    }
+
+    @Override
+    public boolean equals(Object o) {
+        if (this == o) return true;
+        if (o == null || getClass() != o.getClass()) return false;
+        SearchShardsGroup group = (SearchShardsGroup) o;
+        return skipped == group.skipped
+            && preFiltered == group.preFiltered
+            && shardId.equals(group.shardId)
+            && allocatedNodes.equals(group.allocatedNodes);
+    }
+
+    @Override
+    public int hashCode() {
+        return Objects.hash(shardId, allocatedNodes, skipped, preFiltered);
+    }
+}

+ 164 - 0
server/src/main/java/org/elasticsearch/action/search/SearchShardsRequest.java

@@ -0,0 +1,164 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0 and the Server Side Public License, v 1; you may not use this file except
+ * in compliance with, at your election, the Elastic License 2.0 or the Server
+ * Side Public License, v 1.
+ */
+
+package org.elasticsearch.action.search;
+
+import org.elasticsearch.action.ActionRequest;
+import org.elasticsearch.action.ActionRequestValidationException;
+import org.elasticsearch.action.IndicesRequest;
+import org.elasticsearch.action.support.IndicesOptions;
+import org.elasticsearch.common.io.stream.StreamInput;
+import org.elasticsearch.common.io.stream.StreamOutput;
+import org.elasticsearch.core.Nullable;
+import org.elasticsearch.index.query.QueryBuilder;
+import org.elasticsearch.tasks.Task;
+import org.elasticsearch.tasks.TaskId;
+
+import java.io.IOException;
+import java.util.Arrays;
+import java.util.Map;
+import java.util.Objects;
+
+/**
+ * A request to find the list of target shards that might match the query for the given target indices.
+ */
+public final class SearchShardsRequest extends ActionRequest implements IndicesRequest.Replaceable {
+    private String[] indices;
+    private final IndicesOptions indicesOptions;
+
+    @Nullable
+    private final QueryBuilder query;
+
+    @Nullable
+    private final String routing;
+    @Nullable
+    private final String preference;
+
+    private final boolean allowPartialSearchResults;
+
+    public SearchShardsRequest(
+        String[] indices,
+        IndicesOptions indicesOptions,
+        QueryBuilder query,
+        String routing,
+        String preference,
+        boolean allowPartialSearchResults
+    ) {
+        this.indices = indices;
+        this.indicesOptions = indicesOptions;
+        this.query = query;
+        this.routing = routing;
+        this.preference = preference;
+        this.allowPartialSearchResults = allowPartialSearchResults;
+    }
+
+    public SearchShardsRequest(StreamInput in) throws IOException {
+        super(in);
+        this.indices = in.readStringArray();
+        this.indicesOptions = IndicesOptions.readIndicesOptions(in);
+        this.query = in.readOptionalNamedWriteable(QueryBuilder.class);
+        this.routing = in.readOptionalString();
+        this.preference = in.readOptionalString();
+        this.allowPartialSearchResults = in.readBoolean();
+    }
+
+    @Override
+    public void writeTo(StreamOutput out) throws IOException {
+        super.writeTo(out);
+        out.writeStringArray(indices);
+        indicesOptions.writeIndicesOptions(out);
+        out.writeOptionalNamedWriteable(query);
+        out.writeOptionalString(routing);
+        out.writeOptionalString(preference);
+        out.writeBoolean(allowPartialSearchResults);
+    }
+
+    @Override
+    public ActionRequestValidationException validate() {
+        return null;
+    }
+
+    @Override
+    public String[] indices() {
+        return indices;
+    }
+
+    @Override
+    public IndicesOptions indicesOptions() {
+        return indicesOptions;
+    }
+
+    @Override
+    public IndicesRequest indices(String... indices) {
+        this.indices = indices;
+        return this;
+    }
+
+    @Override
+    public Task createTask(long id, String type, String action, TaskId parentTaskId, Map<String, String> headers) {
+        return new SearchTask(id, type, action, this::description, parentTaskId, headers);
+    }
+
+    public QueryBuilder query() {
+        return query;
+    }
+
+    public String routing() {
+        return routing;
+    }
+
+    public String preference() {
+        return preference;
+    }
+
+    public boolean allowPartialSearchResults() {
+        return allowPartialSearchResults;
+    }
+
+    private String description() {
+        return "indices="
+            + Arrays.toString(indices)
+            + ", indicesOptions="
+            + indicesOptions
+            + ", query="
+            + query
+            + ", routing='"
+            + routing
+            + '\''
+            + ", preference='"
+            + preference
+            + '\''
+            + ", allowPartialSearchResults="
+            + allowPartialSearchResults;
+    }
+
+    @Override
+    public String toString() {
+        return "SearchShardsRequest{" + description() + "}";
+    }
+
+    @Override
+    public boolean equals(Object o) {
+        if (this == o) return true;
+        if (o == null || getClass() != o.getClass()) return false;
+        SearchShardsRequest request = (SearchShardsRequest) o;
+        return Arrays.equals(indices, request.indices)
+            && Objects.equals(indicesOptions, request.indicesOptions)
+            && Objects.equals(query, request.query)
+            && Objects.equals(routing, request.routing)
+            && Objects.equals(preference, request.preference)
+            && allowPartialSearchResults == request.allowPartialSearchResults;
+    }
+
+    @Override
+    public int hashCode() {
+        int result = Objects.hash(indicesOptions, query, routing, preference, allowPartialSearchResults);
+        result = 31 * result + Arrays.hashCode(indices);
+        return result;
+    }
+}

+ 83 - 0
server/src/main/java/org/elasticsearch/action/search/SearchShardsResponse.java

@@ -0,0 +1,83 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0 and the Server Side Public License, v 1; you may not use this file except
+ * in compliance with, at your election, the Elastic License 2.0 or the Server
+ * Side Public License, v 1.
+ */
+
+package org.elasticsearch.action.search;
+
+import org.elasticsearch.action.ActionResponse;
+import org.elasticsearch.cluster.node.DiscoveryNode;
+import org.elasticsearch.common.io.stream.StreamInput;
+import org.elasticsearch.common.io.stream.StreamOutput;
+import org.elasticsearch.search.internal.AliasFilter;
+
+import java.io.IOException;
+import java.util.Collection;
+import java.util.Map;
+import java.util.Objects;
+
+/**
+ * A response of {@link SearchShardsRequest} which contains the target shards grouped by {@link org.elasticsearch.index.shard.ShardId}
+ */
+public final class SearchShardsResponse extends ActionResponse {
+    private final Collection<SearchShardsGroup> groups;
+    private final Collection<DiscoveryNode> nodes;
+    private final Map<String, AliasFilter> aliasFilters;
+
+    SearchShardsResponse(Collection<SearchShardsGroup> groups, Collection<DiscoveryNode> nodes, Map<String, AliasFilter> aliasFilters) {
+        this.groups = groups;
+        this.nodes = nodes;
+        this.aliasFilters = aliasFilters;
+    }
+
+    public SearchShardsResponse(StreamInput in) throws IOException {
+        super(in);
+        this.groups = in.readList(SearchShardsGroup::new);
+        this.nodes = in.readList(DiscoveryNode::new);
+        this.aliasFilters = in.readMap(StreamInput::readString, AliasFilter::readFrom);
+    }
+
+    @Override
+    public void writeTo(StreamOutput out) throws IOException {
+        out.writeCollection(groups);
+        out.writeCollection(nodes);
+        out.writeMap(aliasFilters, StreamOutput::writeString, (o, v) -> v.writeTo(o));
+    }
+
+    /**
+     * List of nodes in the cluster
+     */
+    public Collection<DiscoveryNode> getNodes() {
+        return nodes;
+    }
+
+    /**
+     * List of target shards grouped by ShardId
+     */
+    public Collection<SearchShardsGroup> getGroups() {
+        return groups;
+    }
+
+    /**
+     * A map from index uuid to alias filters
+     */
+    public Map<String, AliasFilter> getAliasFilters() {
+        return aliasFilters;
+    }
+
+    @Override
+    public boolean equals(Object o) {
+        if (this == o) return true;
+        if (o == null || getClass() != o.getClass()) return false;
+        SearchShardsResponse that = (SearchShardsResponse) o;
+        return groups.equals(that.groups) && nodes.equals(that.nodes) && aliasFilters.equals(that.aliasFilters);
+    }
+
+    @Override
+    public int hashCode() {
+        return Objects.hash(groups, nodes, aliasFilters);
+    }
+}

+ 44 - 48
server/src/main/java/org/elasticsearch/action/search/TransportSearchAction.java

@@ -76,6 +76,7 @@ import java.util.HashMap;
 import java.util.HashSet;
 import java.util.List;
 import java.util.Map;
+import java.util.Objects;
 import java.util.Set;
 import java.util.concurrent.ConcurrentHashMap;
 import java.util.concurrent.Executor;
@@ -165,46 +166,41 @@ public class TransportSearchAction extends HandledTransportAction<SearchRequest,
     private Map<String, OriginalIndices> buildPerIndexOriginalIndices(
         ClusterState clusterState,
         Set<String> indicesAndAliases,
-        Index[] concreteIndices,
+        String[] indices,
         IndicesOptions indicesOptions
     ) {
         Map<String, OriginalIndices> res = new HashMap<>();
-        for (Index index : concreteIndices) {
-            clusterState.blocks().indexBlockedRaiseException(ClusterBlockLevel.READ, index.getName());
+        for (String index : indices) {
+            clusterState.blocks().indexBlockedRaiseException(ClusterBlockLevel.READ, index);
 
             String[] aliases = indexNameExpressionResolver.indexAliases(
                 clusterState,
-                index.getName(),
+                index,
                 aliasMetadata -> true,
                 dataStreamAlias -> true,
                 true,
                 indicesAndAliases
             );
             BooleanSupplier hasDataStreamRef = () -> {
-                IndexAbstraction ret = clusterState.getMetadata().getIndicesLookup().get(index.getName());
+                IndexAbstraction ret = clusterState.getMetadata().getIndicesLookup().get(index);
                 if (ret == null || ret.getParentDataStream() == null) {
                     return false;
                 }
                 return indicesAndAliases.contains(ret.getParentDataStream().getName());
             };
             List<String> finalIndices = new ArrayList<>();
-            if (aliases == null || aliases.length == 0 || indicesAndAliases.contains(index.getName()) || hasDataStreamRef.getAsBoolean()) {
-                finalIndices.add(index.getName());
+            if (aliases == null || aliases.length == 0 || indicesAndAliases.contains(index) || hasDataStreamRef.getAsBoolean()) {
+                finalIndices.add(index);
             }
             if (aliases != null) {
                 finalIndices.addAll(Arrays.asList(aliases));
             }
-            res.put(index.getUUID(), new OriginalIndices(finalIndices.toArray(String[]::new), indicesOptions));
+            res.put(index, new OriginalIndices(finalIndices.toArray(String[]::new), indicesOptions));
         }
         return Collections.unmodifiableMap(res);
     }
 
-    private Map<String, AliasFilter> buildPerIndexAliasFilter(
-        ClusterState clusterState,
-        Set<String> indicesAndAliases,
-        Index[] concreteIndices,
-        Map<String, AliasFilter> remoteAliasMap
-    ) {
+    Map<String, AliasFilter> buildIndexAliasFilters(ClusterState clusterState, Set<String> indicesAndAliases, Index[] concreteIndices) {
         final Map<String, AliasFilter> aliasFilterMap = new HashMap<>();
         for (Index index : concreteIndices) {
             clusterState.blocks().indexBlockedRaiseException(ClusterBlockLevel.READ, index.getName());
@@ -212,7 +208,6 @@ public class TransportSearchAction extends HandledTransportAction<SearchRequest,
             assert aliasFilter != null;
             aliasFilterMap.put(index.getUUID(), aliasFilter);
         }
-        aliasFilterMap.putAll(remoteAliasMap);
         return aliasFilterMap;
     }
 
@@ -801,7 +796,7 @@ public class TransportSearchAction extends HandledTransportAction<SearchRequest,
         return remoteShardIterators;
     }
 
-    private Index[] resolveLocalIndices(OriginalIndices localIndices, ClusterState clusterState, SearchTimeProvider timeProvider) {
+    Index[] resolveLocalIndices(OriginalIndices localIndices, ClusterState clusterState, SearchTimeProvider timeProvider) {
         if (localIndices == null) {
             return Index.EMPTY_ARRAY; // don't search on any local index (happens when only remote indices were specified)
         }
@@ -869,39 +864,11 @@ public class TransportSearchAction extends HandledTransportAction<SearchRequest,
             );
         } else {
             final Index[] indices = resolveLocalIndices(localIndices, clusterState, timeProvider);
-            Map<String, Set<String>> routingMap = indexNameExpressionResolver.resolveSearchRouting(
-                clusterState,
-                searchRequest.routing(),
-                searchRequest.indices()
-            );
-            routingMap = routingMap == null ? Collections.emptyMap() : Collections.unmodifiableMap(routingMap);
-            concreteLocalIndices = new String[indices.length];
-            for (int i = 0; i < indices.length; i++) {
-                concreteLocalIndices[i] = indices[i].getName();
-            }
-            Map<String, Long> nodeSearchCounts = searchTransportService.getPendingSearchRequests();
-            GroupShardsIterator<ShardIterator> localShardRoutings = clusterService.operationRouting()
-                .searchShards(
-                    clusterState,
-                    concreteLocalIndices,
-                    routingMap,
-                    searchRequest.preference(),
-                    searchService.getResponseCollectorService(),
-                    nodeSearchCounts
-                );
+            concreteLocalIndices = Arrays.stream(indices).map(Index::getName).toArray(String[]::new);
             final Set<String> indicesAndAliases = indexNameExpressionResolver.resolveExpressions(clusterState, searchRequest.indices());
-            aliasFilter = buildPerIndexAliasFilter(clusterState, indicesAndAliases, indices, remoteAliasMap);
-            final Map<String, OriginalIndices> finalIndicesMap = buildPerIndexOriginalIndices(
-                clusterState,
-                indicesAndAliases,
-                indices,
-                searchRequest.indicesOptions()
-            );
-            localShardIterators = StreamSupport.stream(localShardRoutings.spliterator(), false).map(it -> {
-                OriginalIndices finalIndices = finalIndicesMap.get(it.shardId().getIndex().getUUID());
-                assert finalIndices != null;
-                return new SearchShardIterator(searchRequest.getLocalClusterAlias(), it.shardId(), it.getShardRoutings(), finalIndices);
-            }).toList();
+            aliasFilter = buildIndexAliasFilters(clusterState, indicesAndAliases, indices);
+            aliasFilter.putAll(remoteAliasMap);
+            localShardIterators = getLocalShardsIterator(clusterState, searchRequest, indicesAndAliases, concreteLocalIndices);
         }
         final GroupShardsIterator<SearchShardIterator> shardIterators = mergeShardsIterators(localShardIterators, remoteShardIterators);
 
@@ -1363,4 +1330,33 @@ public class TransportSearchAction extends HandledTransportAction<SearchRequest,
         }
         return iterators;
     }
+
+    List<SearchShardIterator> getLocalShardsIterator(
+        ClusterState clusterState,
+        SearchRequest searchRequest,
+        Set<String> indicesAndAliases,
+        String[] concreteIndices
+    ) {
+        var routingMap = indexNameExpressionResolver.resolveSearchRouting(clusterState, searchRequest.routing(), searchRequest.indices());
+        GroupShardsIterator<ShardIterator> shardRoutings = clusterService.operationRouting()
+            .searchShards(
+                clusterState,
+                concreteIndices,
+                Objects.requireNonNullElseGet(routingMap, Map::of),
+                searchRequest.preference(),
+                searchService.getResponseCollectorService(),
+                searchTransportService.getPendingSearchRequests()
+            );
+        final Map<String, OriginalIndices> originalIndices = buildPerIndexOriginalIndices(
+            clusterState,
+            indicesAndAliases,
+            concreteIndices,
+            searchRequest.indicesOptions()
+        );
+        return StreamSupport.stream(shardRoutings.spliterator(), false).map(it -> {
+            OriginalIndices finalIndices = originalIndices.get(it.shardId().getIndex().getName());
+            assert finalIndices != null;
+            return new SearchShardIterator(searchRequest.getLocalClusterAlias(), it.shardId(), it.getShardRoutings(), finalIndices);
+        }).toList();
+    }
 }

+ 146 - 0
server/src/main/java/org/elasticsearch/action/search/TransportSearchShardsAction.java

@@ -0,0 +1,146 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0 and the Server Side Public License, v 1; you may not use this file except
+ * in compliance with, at your election, the Elastic License 2.0 or the Server
+ * Side Public License, v 1.
+ */
+
+package org.elasticsearch.action.search;
+
+import org.elasticsearch.action.ActionListener;
+import org.elasticsearch.action.OriginalIndices;
+import org.elasticsearch.action.support.ActionFilters;
+import org.elasticsearch.action.support.HandledTransportAction;
+import org.elasticsearch.cluster.ClusterState;
+import org.elasticsearch.cluster.metadata.IndexNameExpressionResolver;
+import org.elasticsearch.cluster.routing.GroupShardsIterator;
+import org.elasticsearch.cluster.service.ClusterService;
+import org.elasticsearch.common.inject.Inject;
+import org.elasticsearch.common.util.concurrent.EsExecutors;
+import org.elasticsearch.index.Index;
+import org.elasticsearch.index.query.Rewriteable;
+import org.elasticsearch.index.shard.ShardId;
+import org.elasticsearch.search.SearchService;
+import org.elasticsearch.search.SearchShardTarget;
+import org.elasticsearch.search.builder.SearchSourceBuilder;
+import org.elasticsearch.search.internal.AliasFilter;
+import org.elasticsearch.tasks.Task;
+import org.elasticsearch.transport.RemoteClusterAware;
+import org.elasticsearch.transport.RemoteClusterService;
+import org.elasticsearch.transport.TransportService;
+
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+
+/**
+ * An internal search shards API performs the can_match phase and returns target shards of indices that might match a query.
+ */
+public class TransportSearchShardsAction extends HandledTransportAction<SearchShardsRequest, SearchShardsResponse> {
+    private final TransportSearchAction transportSearchAction;
+    private final SearchService searchService;
+    private final RemoteClusterService remoteClusterService;
+    private final ClusterService clusterService;
+    private final SearchTransportService searchTransportService;
+    private final IndexNameExpressionResolver indexNameExpressionResolver;
+
+    @Inject
+    public TransportSearchShardsAction(
+        TransportService transportService,
+        SearchService searchService,
+        ActionFilters actionFilters,
+        ClusterService clusterService,
+        TransportSearchAction transportSearchAction,
+        SearchTransportService searchTransportService,
+        IndexNameExpressionResolver indexNameExpressionResolver
+    ) {
+        super(SearchShardsAction.NAME, transportService, actionFilters, SearchShardsRequest::new);
+        this.transportSearchAction = transportSearchAction;
+        this.searchService = searchService;
+        this.remoteClusterService = transportService.getRemoteClusterService();
+        this.clusterService = clusterService;
+        this.searchTransportService = searchTransportService;
+        this.indexNameExpressionResolver = indexNameExpressionResolver;
+    }
+
+    @Override
+    protected void doExecute(Task task, SearchShardsRequest searchShardsRequest, ActionListener<SearchShardsResponse> listener) {
+        final long relativeStartNanos = System.nanoTime();
+        SearchRequest original = new SearchRequest(searchShardsRequest.indices()).indicesOptions(searchShardsRequest.indicesOptions())
+            .routing(searchShardsRequest.routing())
+            .preference(searchShardsRequest.preference())
+            .allowPartialSearchResults(searchShardsRequest.allowPartialSearchResults());
+        if (searchShardsRequest.query() != null) {
+            original.source(new SearchSourceBuilder().query(searchShardsRequest.query()));
+        }
+        final TransportSearchAction.SearchTimeProvider timeProvider = new TransportSearchAction.SearchTimeProvider(
+            original.getOrCreateAbsoluteStartMillis(),
+            relativeStartNanos,
+            System::nanoTime
+        );
+        ClusterState clusterState = clusterService.state();
+        Rewriteable.rewriteAndFetch(
+            original,
+            searchService.getRewriteContext(timeProvider::absoluteStartMillis),
+            ActionListener.wrap(searchRequest -> {
+                Map<String, OriginalIndices> groupedIndices = remoteClusterService.groupIndices(
+                    searchRequest.indicesOptions(),
+                    searchRequest.indices()
+                );
+                OriginalIndices originalIndices = groupedIndices.remove(RemoteClusterAware.LOCAL_CLUSTER_GROUP_KEY);
+                if (groupedIndices.isEmpty() == false) {
+                    throw new UnsupportedOperationException("search_shards API doesn't support remote indices " + searchRequest);
+                }
+                // TODO: Move a share stuff out of the TransportSearchAction.
+                Index[] concreteIndices = transportSearchAction.resolveLocalIndices(originalIndices, clusterState, timeProvider);
+                final Set<String> indicesAndAliases = indexNameExpressionResolver.resolveExpressions(clusterState, searchRequest.indices());
+                final Map<String, AliasFilter> aliasFilters = transportSearchAction.buildIndexAliasFilters(
+                    clusterState,
+                    indicesAndAliases,
+                    concreteIndices
+                );
+                String[] concreteIndexNames = Arrays.stream(concreteIndices).map(Index::getName).toArray(String[]::new);
+                var shardIterators = transportSearchAction.getLocalShardsIterator(
+                    clusterState,
+                    searchRequest,
+                    indicesAndAliases,
+                    concreteIndexNames
+                );
+                var canMatchPhase = new CanMatchPreFilterSearchPhase(
+                    logger,
+                    searchTransportService,
+                    (clusterAlias, node) -> searchTransportService.getConnection(clusterAlias, clusterState.nodes().get(node)),
+                    aliasFilters,
+                    Map.of(),
+                    EsExecutors.DIRECT_EXECUTOR_SERVICE,
+                    searchRequest,
+                    GroupShardsIterator.sortAndCreate(shardIterators),
+                    timeProvider,
+                    (SearchTask) task,
+                    searchService.getCoordinatorRewriteContextProvider(timeProvider::absoluteStartMillis),
+                    listener.map(shardIts -> new SearchShardsResponse(toGroups(shardIts), clusterState.nodes(), aliasFilters))
+                );
+                canMatchPhase.start();
+            }, listener::onFailure)
+        );
+    }
+
+    private static List<SearchShardsGroup> toGroups(GroupShardsIterator<SearchShardIterator> shardIts) {
+        List<SearchShardsGroup> groups = new ArrayList<>(shardIts.size());
+        for (SearchShardIterator shardIt : shardIts) {
+            boolean skip = shardIt.skip();
+            shardIt.reset();
+            List<String> targetNodes = new ArrayList<>();
+            SearchShardTarget target;
+            while ((target = shardIt.nextOrNull()) != null) {
+                targetNodes.add(target.getNodeId());
+            }
+            ShardId shardId = shardIt.shardId();
+            groups.add(new SearchShardsGroup(shardId, targetNodes, true, skip));
+        }
+        return groups;
+    }
+}

+ 83 - 0
server/src/test/java/org/elasticsearch/action/search/SearchShardsRequestTests.java

@@ -0,0 +1,83 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0 and the Server Side Public License, v 1; you may not use this file except
+ * in compliance with, at your election, the Elastic License 2.0 or the Server
+ * Side Public License, v 1.
+ */
+
+package org.elasticsearch.action.search;
+
+import org.elasticsearch.action.support.IndicesOptions;
+import org.elasticsearch.common.io.stream.NamedWriteableRegistry;
+import org.elasticsearch.common.io.stream.Writeable;
+import org.elasticsearch.common.settings.Settings;
+import org.elasticsearch.common.util.ArrayUtils;
+import org.elasticsearch.index.query.QueryBuilder;
+import org.elasticsearch.index.query.QueryBuilders;
+import org.elasticsearch.search.SearchModule;
+import org.elasticsearch.test.AbstractWireSerializingTestCase;
+
+import java.io.IOException;
+import java.util.List;
+
+public class SearchShardsRequestTests extends AbstractWireSerializingTestCase<SearchShardsRequest> {
+    @Override
+    protected NamedWriteableRegistry getNamedWriteableRegistry() {
+        return new NamedWriteableRegistry(new SearchModule(Settings.EMPTY, List.of()).getNamedWriteables());
+    }
+
+    @Override
+    protected Writeable.Reader<SearchShardsRequest> instanceReader() {
+        return SearchShardsRequest::new;
+    }
+
+    @Override
+    protected SearchShardsRequest createTestInstance() {
+        String[] indices = generateRandomStringArray(10, 10, false);
+        IndicesOptions indicesOptions = IndicesOptions.fromOptions(randomBoolean(), randomBoolean(), randomBoolean(), randomBoolean());
+        QueryBuilder query = QueryBuilders.termQuery(randomAlphaOfLengthBetween(5, 20), randomAlphaOfLengthBetween(5, 20));
+        String routing = randomBoolean() ? null : randomAlphaOfLength(10);
+        String preference = randomBoolean() ? null : randomAlphaOfLength(10);
+        return new SearchShardsRequest(indices, indicesOptions, query, routing, preference, randomBoolean());
+    }
+
+    @Override
+    protected SearchShardsRequest mutateInstance(SearchShardsRequest r) throws IOException {
+        return switch (between(0, 5)) {
+            case 0 -> {
+                String[] extraIndices = randomArray(1, 10, String[]::new, () -> randomAlphaOfLength(10));
+                String[] indices = ArrayUtils.concat(r.indices(), extraIndices);
+                yield new SearchShardsRequest(indices, r.indicesOptions(), r.query(), r.routing(), r.preference(), randomBoolean());
+            }
+            case 1 -> {
+                IndicesOptions indicesOptions = randomValueOtherThan(
+                    r.indicesOptions(),
+                    () -> IndicesOptions.fromOptions(randomBoolean(), randomBoolean(), randomBoolean(), randomBoolean())
+                );
+                yield new SearchShardsRequest(r.indices(), indicesOptions, r.query(), r.routing(), r.preference(), randomBoolean());
+            }
+            case 2 -> {
+                QueryBuilder query = QueryBuilders.rangeQuery(randomAlphaOfLengthBetween(5, 20)).from(randomNonNegativeLong());
+                yield new SearchShardsRequest(r.indices(), r.indicesOptions(), query, r.routing(), r.preference(), randomBoolean());
+            }
+            case 3 -> {
+                String routing = randomValueOtherThan(r.routing(), () -> randomBoolean() ? null : randomAlphaOfLength(10));
+                yield new SearchShardsRequest(r.indices(), r.indicesOptions(), r.query(), routing, r.preference(), randomBoolean());
+            }
+            case 4 -> {
+                String preference = randomValueOtherThan(r.preference(), () -> randomBoolean() ? null : randomAlphaOfLength(10));
+                yield new SearchShardsRequest(r.indices(), r.indicesOptions(), r.query(), r.routing(), preference, randomBoolean());
+            }
+            case 5 -> new SearchShardsRequest(
+                r.indices(),
+                r.indicesOptions(),
+                r.query(),
+                r.routing(),
+                r.preference(),
+                r.allowPartialSearchResults() == false
+            );
+            default -> throw new AssertionError("unexpected value");
+        };
+    }
+}

+ 95 - 0
server/src/test/java/org/elasticsearch/action/search/SearchShardsResponseTests.java

@@ -0,0 +1,95 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0 and the Server Side Public License, v 1; you may not use this file except
+ * in compliance with, at your election, the Elastic License 2.0 or the Server
+ * Side Public License, v 1.
+ */
+
+package org.elasticsearch.action.search;
+
+import org.elasticsearch.cluster.node.DiscoveryNode;
+import org.elasticsearch.cluster.node.TestDiscoveryNode;
+import org.elasticsearch.common.UUIDs;
+import org.elasticsearch.common.io.stream.NamedWriteableRegistry;
+import org.elasticsearch.common.io.stream.Writeable;
+import org.elasticsearch.common.settings.Settings;
+import org.elasticsearch.index.query.RandomQueryBuilder;
+import org.elasticsearch.index.shard.ShardId;
+import org.elasticsearch.search.SearchModule;
+import org.elasticsearch.search.internal.AliasFilter;
+import org.elasticsearch.test.AbstractWireSerializingTestCase;
+
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+public class SearchShardsResponseTests extends AbstractWireSerializingTestCase<SearchShardsResponse> {
+
+    @Override
+    protected NamedWriteableRegistry getNamedWriteableRegistry() {
+        SearchModule searchModule = new SearchModule(Settings.EMPTY, Collections.emptyList());
+        return new NamedWriteableRegistry(searchModule.getNamedWriteables());
+    }
+
+    @Override
+    protected Writeable.Reader<SearchShardsResponse> instanceReader() {
+        return SearchShardsResponse::new;
+    }
+
+    @Override
+    protected SearchShardsResponse createTestInstance() {
+        List<DiscoveryNode> nodes = randomList(1, 10, () -> TestDiscoveryNode.create(UUIDs.randomBase64UUID()));
+        int numGroups = randomIntBetween(0, 10);
+        List<SearchShardsGroup> groups = new ArrayList<>();
+        for (int i = 0; i < numGroups; i++) {
+            String index = randomAlphaOfLengthBetween(5, 10);
+            ShardId shardId = new ShardId(index, UUIDs.randomBase64UUID(), i);
+            int numOfAllocatedNodes = randomIntBetween(0, 5);
+            List<String> allocatedNodes = new ArrayList<>();
+            for (int j = 0; j < numOfAllocatedNodes; j++) {
+                allocatedNodes.add(UUIDs.randomBase64UUID());
+            }
+            groups.add(new SearchShardsGroup(shardId, allocatedNodes, true, randomBoolean()));
+        }
+        Map<String, AliasFilter> aliasFilters = new HashMap<>();
+        for (SearchShardsGroup g : groups) {
+            AliasFilter aliasFilter;
+            if (randomBoolean()) {
+                aliasFilter = AliasFilter.of(RandomQueryBuilder.createQuery(random()), "alias-" + g.shardId().getIndexName());
+            } else {
+                aliasFilter = AliasFilter.EMPTY;
+            }
+            aliasFilters.put(g.shardId().getIndex().getUUID(), aliasFilter);
+        }
+        return new SearchShardsResponse(groups, nodes, aliasFilters);
+    }
+
+    @Override
+    protected SearchShardsResponse mutateInstance(SearchShardsResponse r) throws IOException {
+        switch (randomIntBetween(0, 2)) {
+            case 0 -> {
+                List<SearchShardsGroup> groups = new ArrayList<>(r.getGroups());
+                ShardId shardId = new ShardId(randomAlphaOfLengthBetween(5, 10), UUIDs.randomBase64UUID(), randomInt(2));
+                groups.add(new SearchShardsGroup(shardId, List.of(), true, randomBoolean()));
+                return new SearchShardsResponse(groups, r.getNodes(), r.getAliasFilters());
+            }
+            case 1 -> {
+                List<DiscoveryNode> nodes = new ArrayList<>(r.getNodes());
+                nodes.add(TestDiscoveryNode.create(UUIDs.randomBase64UUID()));
+                return new SearchShardsResponse(r.getGroups(), nodes, r.getAliasFilters());
+            }
+            case 2 -> {
+                Map<String, AliasFilter> aliasFilters = new HashMap<>(r.getAliasFilters());
+                aliasFilters.put(UUIDs.randomBase64UUID(), AliasFilter.of(RandomQueryBuilder.createQuery(random()), "alias-index"));
+                return new SearchShardsResponse(new ArrayList<>(r.getGroups()), r.getNodes(), aliasFilters);
+            }
+            default -> {
+                throw new AssertionError("invalid option");
+            }
+        }
+    }
+}

+ 4 - 1
x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/authz/privilege/IndexPrivilege.java

@@ -29,6 +29,7 @@ import org.elasticsearch.action.datastreams.DeleteDataStreamAction;
 import org.elasticsearch.action.datastreams.GetDataStreamAction;
 import org.elasticsearch.action.datastreams.PromoteDataStreamAction;
 import org.elasticsearch.action.fieldcaps.FieldCapabilitiesAction;
+import org.elasticsearch.action.search.SearchShardsAction;
 import org.elasticsearch.common.Strings;
 import org.elasticsearch.index.seqno.RetentionLeaseActions;
 import org.elasticsearch.transport.TcpTransport;
@@ -77,7 +78,8 @@ public final class IndexPrivilege extends Privilege {
     private static final Automaton READ_AUTOMATON = patterns("indices:data/read/*", ResolveIndexAction.NAME);
     private static final Automaton READ_CROSS_CLUSTER_AUTOMATON = patterns(
         "internal:transport/proxy/indices:data/read/*",
-        ClusterSearchShardsAction.NAME
+        ClusterSearchShardsAction.NAME,
+        SearchShardsAction.NAME
     );
     private static final Automaton CREATE_AUTOMATON = patterns("indices:data/write/index*", "indices:data/write/bulk*");
     private static final Automaton CREATE_DOC_AUTOMATON = patterns(
@@ -112,6 +114,7 @@ public final class IndexPrivilege extends Privilege {
         GetFieldMappingsAction.NAME + "*",
         GetMappingsAction.NAME,
         ClusterSearchShardsAction.NAME,
+        SearchShardsAction.NAME,
         ValidateQueryAction.NAME + "*",
         GetSettingsAction.NAME,
         ExplainLifecycleAction.NAME,

+ 1 - 0
x-pack/plugin/security/qa/operator-privileges-tests/src/javaRestTest/java/org/elasticsearch/xpack/security/operator/Constants.java

@@ -440,6 +440,7 @@ public class Constants {
         "indices:admin/seq_no/renew_retention_lease",
         "indices:admin/settings/update",
         "indices:admin/shards/search_shards",
+        "indices:admin/search/search_shards",
         "indices:admin/template/delete",
         "indices:admin/template/get",
         "indices:admin/template/put",