Skip to content

Commit 7da55d0

Browse files
authored
Integrate CCS with new search_shards API (#95894)
This PR integrates CCS with the new search_shards API. With this change, we will be able to skip shards on the coordinator on remote clusters using the timestamps stored in the cluster state. Relates #94534 Closes #93730
1 parent 9502d39 commit 7da55d0

File tree

19 files changed

+738
-305
lines changed

19 files changed

+738
-305
lines changed

docs/changelog/95894.yaml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
pr: 95894
2+
summary: Integrate CCS with new `search_shards` API
3+
area: Search
4+
type: enhancement
5+
issues:
6+
- 93730

qa/ccs-unavailable-clusters/src/javaRestTest/java/org/elasticsearch/search/CrossClusterSearchUnavailableClusterIT.java

Lines changed: 6 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -15,10 +15,6 @@
1515
import org.elasticsearch.ElasticsearchException;
1616
import org.elasticsearch.TransportVersion;
1717
import org.elasticsearch.Version;
18-
import org.elasticsearch.action.admin.cluster.shards.ClusterSearchShardsAction;
19-
import org.elasticsearch.action.admin.cluster.shards.ClusterSearchShardsGroup;
20-
import org.elasticsearch.action.admin.cluster.shards.ClusterSearchShardsRequest;
21-
import org.elasticsearch.action.admin.cluster.shards.ClusterSearchShardsResponse;
2218
import org.elasticsearch.action.admin.cluster.state.ClusterStateAction;
2319
import org.elasticsearch.action.admin.cluster.state.ClusterStateRequest;
2420
import org.elasticsearch.action.admin.cluster.state.ClusterStateResponse;
@@ -27,6 +23,9 @@
2723
import org.elasticsearch.action.search.SearchRequest;
2824
import org.elasticsearch.action.search.SearchResponse;
2925
import org.elasticsearch.action.search.SearchScrollRequest;
26+
import org.elasticsearch.action.search.SearchShardsAction;
27+
import org.elasticsearch.action.search.SearchShardsRequest;
28+
import org.elasticsearch.action.search.SearchShardsResponse;
3029
import org.elasticsearch.action.search.ShardSearchFailure;
3130
import org.elasticsearch.client.Request;
3231
import org.elasticsearch.client.RequestOptions;
@@ -103,17 +102,11 @@ private static MockTransportService startTransport(
103102
MockTransportService newService = MockTransportService.createNewService(s, version, transportVersion, threadPool, null);
104103
try {
105104
newService.registerRequestHandler(
106-
ClusterSearchShardsAction.NAME,
105+
SearchShardsAction.NAME,
107106
ThreadPool.Names.SAME,
108-
ClusterSearchShardsRequest::new,
107+
SearchShardsRequest::new,
109108
(request, channel, task) -> {
110-
channel.sendResponse(
111-
new ClusterSearchShardsResponse(
112-
new ClusterSearchShardsGroup[0],
113-
knownNodes.toArray(new DiscoveryNode[0]),
114-
Collections.emptyMap()
115-
)
116-
);
109+
channel.sendResponse(new SearchShardsResponse(List.of(), List.of(), Collections.emptyMap()));
117110
}
118111
);
119112
newService.registerRequestHandler(SearchAction.NAME, ThreadPool.Names.SAME, SearchRequest::new, (request, channel, task) -> {
Lines changed: 193 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,193 @@
1+
/*
2+
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
3+
* or more contributor license agreements. Licensed under the Elastic License
4+
* 2.0 and the Server Side Public License, v 1; you may not use this file except
5+
* in compliance with, at your election, the Elastic License 2.0 or the Server
6+
* Side Public License, v 1.
7+
*/
8+
9+
package org.elasticsearch.search.ccs;
10+
11+
import org.apache.lucene.document.LongPoint;
12+
import org.apache.lucene.index.DirectoryReader;
13+
import org.apache.lucene.index.PointValues;
14+
import org.elasticsearch.action.search.CanMatchNodeRequest;
15+
import org.elasticsearch.action.search.SearchRequest;
16+
import org.elasticsearch.action.search.SearchResponse;
17+
import org.elasticsearch.action.search.SearchTransportService;
18+
import org.elasticsearch.client.internal.Client;
19+
import org.elasticsearch.cluster.metadata.IndexMetadata;
20+
import org.elasticsearch.cluster.node.DiscoveryNode;
21+
import org.elasticsearch.cluster.node.DiscoveryNodes;
22+
import org.elasticsearch.common.Strings;
23+
import org.elasticsearch.common.settings.Settings;
24+
import org.elasticsearch.common.util.CollectionUtils;
25+
import org.elasticsearch.index.IndexSettings;
26+
import org.elasticsearch.index.engine.EngineConfig;
27+
import org.elasticsearch.index.engine.EngineFactory;
28+
import org.elasticsearch.index.engine.InternalEngine;
29+
import org.elasticsearch.index.engine.InternalEngineFactory;
30+
import org.elasticsearch.index.query.RangeQueryBuilder;
31+
import org.elasticsearch.index.shard.IndexLongFieldRange;
32+
import org.elasticsearch.index.shard.ShardLongFieldRange;
33+
import org.elasticsearch.plugins.EnginePlugin;
34+
import org.elasticsearch.plugins.Plugin;
35+
import org.elasticsearch.search.builder.SearchSourceBuilder;
36+
import org.elasticsearch.test.AbstractMultiClustersTestCase;
37+
import org.elasticsearch.test.hamcrest.ElasticsearchAssertions;
38+
import org.elasticsearch.test.transport.MockTransportService;
39+
import org.elasticsearch.transport.TransportService;
40+
import org.hamcrest.Matchers;
41+
42+
import java.io.IOException;
43+
import java.io.UncheckedIOException;
44+
import java.util.Arrays;
45+
import java.util.Collection;
46+
import java.util.List;
47+
import java.util.Optional;
48+
49+
import static org.hamcrest.Matchers.equalTo;
50+
import static org.hamcrest.Matchers.in;
51+
52+
public class CCSCanMatchIT extends AbstractMultiClustersTestCase {
53+
static final String REMOTE_CLUSTER = "cluster_a";
54+
55+
@Override
56+
protected Collection<String> remoteClusterAlias() {
57+
return List.of("cluster_a");
58+
}
59+
60+
private static class EngineWithExposingTimestamp extends InternalEngine {
61+
EngineWithExposingTimestamp(EngineConfig engineConfig) {
62+
super(engineConfig);
63+
assert IndexMetadata.INDEX_BLOCKS_WRITE_SETTING.get(config().getIndexSettings().getSettings()) : "require read-only index";
64+
}
65+
66+
@Override
67+
public ShardLongFieldRange getRawFieldRange(String field) {
68+
try (Searcher searcher = acquireSearcher("test")) {
69+
final DirectoryReader directoryReader = searcher.getDirectoryReader();
70+
71+
final byte[] minPackedValue = PointValues.getMinPackedValue(directoryReader, field);
72+
final byte[] maxPackedValue = PointValues.getMaxPackedValue(directoryReader, field);
73+
if (minPackedValue == null || maxPackedValue == null) {
74+
assert minPackedValue == null && maxPackedValue == null
75+
: Arrays.toString(minPackedValue) + "-" + Arrays.toString(maxPackedValue);
76+
return ShardLongFieldRange.EMPTY;
77+
}
78+
79+
return ShardLongFieldRange.of(LongPoint.decodeDimension(minPackedValue, 0), LongPoint.decodeDimension(maxPackedValue, 0));
80+
} catch (IOException e) {
81+
throw new UncheckedIOException(e);
82+
}
83+
}
84+
}
85+
86+
public static class ExposingTimestampEnginePlugin extends Plugin implements EnginePlugin {
87+
88+
@Override
89+
public Optional<EngineFactory> getEngineFactory(IndexSettings indexSettings) {
90+
if (IndexMetadata.INDEX_BLOCKS_WRITE_SETTING.get(indexSettings.getSettings())) {
91+
return Optional.of(EngineWithExposingTimestamp::new);
92+
} else {
93+
return Optional.of(new InternalEngineFactory());
94+
}
95+
}
96+
}
97+
98+
@Override
99+
protected Collection<Class<? extends Plugin>> nodePlugins(String clusterAlias) {
100+
return CollectionUtils.appendToCopy(super.nodePlugins(clusterAlias), ExposingTimestampEnginePlugin.class);
101+
}
102+
103+
int createIndexAndIndexDocs(String cluster, String index, int numberOfShards, long timestamp, boolean exposeTimestamp)
104+
throws Exception {
105+
Client client = client(cluster);
106+
ElasticsearchAssertions.assertAcked(
107+
client.admin()
108+
.indices()
109+
.prepareCreate(index)
110+
.setSettings(
111+
Settings.builder()
112+
.put(IndexMetadata.SETTING_NUMBER_OF_SHARDS, numberOfShards)
113+
.put(IndexMetadata.SETTING_NUMBER_OF_REPLICAS, 0)
114+
)
115+
.setMapping("@timestamp", "type=date", "position", "type=long")
116+
);
117+
int numDocs = between(100, 500);
118+
for (int i = 0; i < numDocs; i++) {
119+
client.prepareIndex(index).setSource("position", i, "@timestamp", timestamp + i).get();
120+
}
121+
if (exposeTimestamp) {
122+
client.admin().indices().prepareClose(index).get();
123+
client.admin()
124+
.indices()
125+
.prepareUpdateSettings(index)
126+
.setSettings(Settings.builder().put(IndexMetadata.INDEX_BLOCKS_WRITE_SETTING.getKey(), true).build())
127+
.get();
128+
client.admin().indices().prepareOpen(index).get();
129+
assertBusy(() -> {
130+
IndexLongFieldRange timestampRange = cluster(cluster).clusterService().state().metadata().index(index).getTimestampRange();
131+
assertTrue(Strings.toString(timestampRange), timestampRange.containsAllShardRanges());
132+
});
133+
} else {
134+
client.admin().indices().prepareRefresh(index).get();
135+
}
136+
return numDocs;
137+
}
138+
139+
public void testCanMatchOnTimeRange() throws Exception {
140+
long timestamp = randomLongBetween(10_000_000, 50_000_000);
141+
int oldLocalNumShards = randomIntBetween(1, 5);
142+
createIndexAndIndexDocs(LOCAL_CLUSTER, "local_old_index", oldLocalNumShards, timestamp - 10_000, true);
143+
int oldRemoteNumShards = randomIntBetween(1, 5);
144+
createIndexAndIndexDocs(REMOTE_CLUSTER, "remote_old_index", oldRemoteNumShards, timestamp - 10_000, true);
145+
146+
int newLocalNumShards = randomIntBetween(1, 5);
147+
int localDocs = createIndexAndIndexDocs(LOCAL_CLUSTER, "local_new_index", newLocalNumShards, timestamp, randomBoolean());
148+
int newRemoteNumShards = randomIntBetween(1, 5);
149+
int remoteDocs = createIndexAndIndexDocs(REMOTE_CLUSTER, "remote_new_index", newRemoteNumShards, timestamp, randomBoolean());
150+
151+
for (String cluster : List.of(LOCAL_CLUSTER, REMOTE_CLUSTER)) {
152+
for (TransportService ts : cluster(cluster).getInstances(TransportService.class)) {
153+
MockTransportService mockTransportService = (MockTransportService) ts;
154+
mockTransportService.addSendBehavior((connection, requestId, action, request, options) -> {
155+
if (action.equals(SearchTransportService.QUERY_CAN_MATCH_NODE_NAME)) {
156+
CanMatchNodeRequest canMatchNodeRequest = (CanMatchNodeRequest) request;
157+
List<String> indices = canMatchNodeRequest.getShardLevelRequests()
158+
.stream()
159+
.map(r -> r.shardId().getIndexName())
160+
.toList();
161+
assertThat("old indices should be prefiltered on coordinator node", "local_old_index", Matchers.not(in(indices)));
162+
assertThat("old indices should be prefiltered on coordinator node", "remote_old_index", Matchers.not(in(indices)));
163+
if (cluster.equals(LOCAL_CLUSTER)) {
164+
DiscoveryNode targetNode = connection.getNode();
165+
DiscoveryNodes remoteNodes = cluster(REMOTE_CLUSTER).clusterService().state().nodes();
166+
assertNull("No can_match requests sent across clusters", remoteNodes.get(targetNode.getId()));
167+
}
168+
}
169+
connection.sendRequest(requestId, action, request, options);
170+
});
171+
}
172+
}
173+
try {
174+
for (boolean minimizeRoundTrips : List.of(true, false)) {
175+
SearchSourceBuilder source = new SearchSourceBuilder().query(new RangeQueryBuilder("@timestamp").from(timestamp));
176+
SearchRequest request = new SearchRequest("local_*", "*:remote_*");
177+
request.source(source).setCcsMinimizeRoundtrips(minimizeRoundTrips);
178+
SearchResponse searchResp = client().search(request).actionGet();
179+
ElasticsearchAssertions.assertHitCount(searchResp, localDocs + remoteDocs);
180+
int totalShards = oldLocalNumShards + newLocalNumShards + oldRemoteNumShards + newRemoteNumShards;
181+
assertThat(searchResp.getTotalShards(), equalTo(totalShards));
182+
assertThat(searchResp.getSkippedShards(), equalTo(oldLocalNumShards + oldRemoteNumShards));
183+
}
184+
} finally {
185+
for (String cluster : List.of(LOCAL_CLUSTER, REMOTE_CLUSTER)) {
186+
for (TransportService ts : cluster(cluster).getInstances(TransportService.class)) {
187+
MockTransportService mockTransportService = (MockTransportService) ts;
188+
mockTransportService.clearAllRules();
189+
}
190+
}
191+
}
192+
}
193+
}

server/src/main/java/org/elasticsearch/action/search/CanMatchPreFilterSearchPhase.java

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -158,6 +158,12 @@ private void runCoordinatorRewritePhase() {
158158
searchShardIterator.getClusterAlias()
159159
);
160160
final ShardSearchRequest request = canMatchNodeRequest.createShardSearchRequest(buildShardLevelRequest(searchShardIterator));
161+
if (searchShardIterator.prefiltered()) {
162+
CanMatchShardResponse result = new CanMatchShardResponse(searchShardIterator.skip() == false, null);
163+
result.setShardIndex(request.shardRequestIndex());
164+
results.consumeResult(result, () -> {});
165+
continue;
166+
}
161167
boolean canMatch = true;
162168
CoordinatorRewriteContext coordinatorRewriteContext = coordinatorRewriteContextProvider.getCoordinatorRewriteContext(
163169
request.shardId().getIndex()
@@ -510,8 +516,10 @@ private GroupShardsIterator<SearchShardIterator> getIterator(
510516
// shards available in order to produce a valid search result.
511517
int shardIndexToQuery = 0;
512518
for (int i = 0; i < shardsIts.size(); i++) {
513-
if (shardsIts.get(i).size() > 0) {
519+
SearchShardIterator it = shardsIts.get(i);
520+
if (it.size() > 0) {
514521
shardIndexToQuery = i;
522+
it.skip(false); // un-skip which is needed when all the remote shards were skipped by the remote can_match
515523
break;
516524
}
517525
}
@@ -520,10 +528,12 @@ private GroupShardsIterator<SearchShardIterator> getIterator(
520528
SearchSourceBuilder source = request.source();
521529
int i = 0;
522530
for (SearchShardIterator iter : shardsIts) {
523-
if (possibleMatches.get(i++)) {
524-
iter.reset();
531+
iter.reset();
532+
boolean match = possibleMatches.get(i++);
533+
if (match) {
534+
assert iter.skip() == false;
525535
} else {
526-
iter.resetAndSkip();
536+
iter.skip(true);
527537
}
528538
}
529539
if (shouldSortShards(results.minAndMaxes) == false) {

server/src/main/java/org/elasticsearch/action/search/SearchShardIterator.java

Lines changed: 35 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,8 @@ public final class SearchShardIterator implements Comparable<SearchShardIterator
3434
private final OriginalIndices originalIndices;
3535
private final String clusterAlias;
3636
private final ShardId shardId;
37-
private boolean skip = false;
37+
private boolean skip;
38+
private final boolean prefiltered;
3839

3940
private final ShardSearchContextId searchContextId;
4041
private final TimeValue searchContextKeepAlive;
@@ -50,16 +51,30 @@ public final class SearchShardIterator implements Comparable<SearchShardIterator
5051
* @param originalIndices the indices that the search request originally related to (before any rewriting happened)
5152
*/
5253
public SearchShardIterator(@Nullable String clusterAlias, ShardId shardId, List<ShardRouting> shards, OriginalIndices originalIndices) {
53-
this(clusterAlias, shardId, shards.stream().map(ShardRouting::currentNodeId).toList(), originalIndices, null, null);
54+
this(clusterAlias, shardId, shards.stream().map(ShardRouting::currentNodeId).toList(), originalIndices, null, null, false, false);
5455
}
5556

57+
/**
58+
* Creates a {@link PlainShardIterator} instance that iterates over a subset of the given shards
59+
*
60+
* @param clusterAlias the alias of the cluster where the shard is located
61+
* @param shardId shard id of the group
62+
* @param targetNodeIds the list of nodes hosting shard copies
63+
* @param originalIndices the indices that the search request originally related to (before any rewriting happened)
64+
* @param searchContextId the point-in-time specified for this group if exists
65+
* @param searchContextKeepAlive the time interval that data nodes should extend the keep alive of the point-in-time
66+
* @param prefiltered if true, then this group already executed the can_match phase
67+
* @param skip if true, then this group won't have matches, and it can be safely skipped from the search
68+
*/
5669
public SearchShardIterator(
5770
@Nullable String clusterAlias,
5871
ShardId shardId,
5972
List<String> targetNodeIds,
6073
OriginalIndices originalIndices,
6174
ShardSearchContextId searchContextId,
62-
TimeValue searchContextKeepAlive
75+
TimeValue searchContextKeepAlive,
76+
boolean prefiltered,
77+
boolean skip
6378
) {
6479
this.shardId = shardId;
6580
this.targetNodesIterator = new PlainIterator<>(targetNodeIds);
@@ -68,6 +83,9 @@ public SearchShardIterator(
6883
this.searchContextId = searchContextId;
6984
this.searchContextKeepAlive = searchContextKeepAlive;
7085
assert searchContextKeepAlive == null || searchContextId != null;
86+
this.prefiltered = prefiltered;
87+
this.skip = skip;
88+
assert skip == false || prefiltered : "only prefiltered shards are skip-able";
7189
}
7290

7391
/**
@@ -112,15 +130,6 @@ List<String> getTargetNodeIds() {
112130
return targetNodesIterator.asList();
113131
}
114132

115-
/**
116-
* Reset the iterator and mark it as skippable
117-
* @see #skip()
118-
*/
119-
void resetAndSkip() {
120-
reset();
121-
skip = true;
122-
}
123-
124133
void reset() {
125134
targetNodesIterator.reset();
126135
}
@@ -132,6 +141,20 @@ boolean skip() {
132141
return skip;
133142
}
134143

144+
/**
145+
* Specifies if the search execution should skip this shard copies
146+
*/
147+
void skip(boolean skip) {
148+
this.skip = skip;
149+
}
150+
151+
/**
152+
* Returns {@code true} if this iterator was applied pre-filtered
153+
*/
154+
boolean prefiltered() {
155+
return prefiltered;
156+
}
157+
135158
@Override
136159
public int size() {
137160
return targetNodesIterator.size();

0 commit comments

Comments
 (0)