Skip to content

Commit 9d02114

Browse files
committed
Tie-break completion suggestions with same score and surface form (#39564)
In case multiple completion suggestion entries have the same score and surface form, the order in which such options will be returned is currently not deterministic. With this commmit we introduce tie-breaking for such situations, based on shard id, index name, index uuid and doc id like we already do for ordinary search hits. With this change we also make shardIndex mandatory when sorting and comparing completion suggestion options, which was previously only needed later when fetching hits). Also, we need to make sure shardIndex is properly set when merging completion suggestions coming from multiple clusters in `SearchResponseMerger`
1 parent ca83408 commit 9d02114

File tree

4 files changed

+175
-21
lines changed

4 files changed

+175
-21
lines changed

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

Lines changed: 45 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@
3939
import org.elasticsearch.search.profile.ProfileShardResult;
4040
import org.elasticsearch.search.profile.SearchProfileShardResults;
4141
import org.elasticsearch.search.suggest.Suggest;
42+
import org.elasticsearch.search.suggest.completion.CompletionSuggestion;
4243

4344
import java.util.ArrayList;
4445
import java.util.Arrays;
@@ -152,6 +153,16 @@ SearchResponse getMergedResponse(Clusters clusters) {
152153
List<Suggest.Suggestion> suggestionList = groupedSuggestions.computeIfAbsent(entries.getName(), s -> new ArrayList<>());
153154
suggestionList.add(entries);
154155
}
156+
List<CompletionSuggestion> completionSuggestions = suggest.filter(CompletionSuggestion.class);
157+
for (CompletionSuggestion completionSuggestion : completionSuggestions) {
158+
for (CompletionSuggestion.Entry options : completionSuggestion) {
159+
for (CompletionSuggestion.Entry.Option option : options) {
160+
SearchShardTarget shard = option.getHit().getShard();
161+
ShardIdAndClusterAlias shardId = new ShardIdAndClusterAlias(shard.getShardId(), shard.getClusterAlias());
162+
shards.putIfAbsent(shardId, null);
163+
}
164+
}
165+
}
155166
}
156167

157168
SearchHits searchHits = searchResponse.getHits();
@@ -174,14 +185,15 @@ SearchResponse getMergedResponse(Clusters clusters) {
174185
}
175186

176187
//after going through all the hits and collecting all their distinct shards, we can assign shardIndex and set it to the ScoreDocs
177-
setShardIndex(shards, topDocsList);
188+
setTopDocsShardIndex(shards, topDocsList);
189+
setSuggestShardIndex(shards, groupedSuggestions);
178190
TopDocs topDocs = mergeTopDocs(topDocsList, size, from);
179191
SearchHits mergedSearchHits = topDocsToSearchHits(topDocs, topDocsStats);
180192
Suggest suggest = groupedSuggestions.isEmpty() ? null : new Suggest(Suggest.reduce(groupedSuggestions));
181193
InternalAggregations reducedAggs = InternalAggregations.reduce(aggs, reduceContextFunction.apply(true));
182194
ShardSearchFailure[] shardFailures = failures.toArray(ShardSearchFailure.EMPTY_ARRAY);
183195
SearchProfileShardResults profileShardResults = profileResults.isEmpty() ? null : new SearchProfileShardResults(profileResults);
184-
//make failures ordering consistent with ordinary search and CCS
196+
//make failures ordering consistent between ordinary search and CCS by looking at the shard they come from
185197
Arrays.sort(shardFailures, FAILURES_COMPARATOR);
186198
InternalSearchResponse response = new InternalSearchResponse(mergedSearchHits, reducedAggs, suggest, profileShardResults,
187199
topDocsStats.timedOut, topDocsStats.terminatedEarly, numReducePhases);
@@ -275,14 +287,8 @@ private static TopDocs searchHitsToTopDocs(SearchHits searchHits, TotalHits tota
275287
return topDocs;
276288
}
277289

278-
private static void setShardIndex(Map<ShardIdAndClusterAlias, Integer> shards, List<TopDocs> topDocsList) {
279-
{
280-
//assign a different shardIndex to each shard, based on their shardId natural ordering and their cluster alias
281-
int shardIndex = 0;
282-
for (Map.Entry<ShardIdAndClusterAlias, Integer> shard : shards.entrySet()) {
283-
shard.setValue(shardIndex++);
284-
}
285-
}
290+
private static void setTopDocsShardIndex(Map<ShardIdAndClusterAlias, Integer> shards, List<TopDocs> topDocsList) {
291+
assignShardIndex(shards);
286292
//go through all the scoreDocs from each cluster and set their corresponding shardIndex
287293
for (TopDocs topDocs : topDocsList) {
288294
for (ScoreDoc scoreDoc : topDocs.scoreDocs) {
@@ -295,6 +301,34 @@ private static void setShardIndex(Map<ShardIdAndClusterAlias, Integer> shards, L
295301
}
296302
}
297303

304+
private static void setSuggestShardIndex(Map<ShardIdAndClusterAlias, Integer> shards,
305+
Map<String, List<Suggest.Suggestion>> groupedSuggestions) {
306+
assignShardIndex(shards);
307+
for (List<Suggest.Suggestion> suggestions : groupedSuggestions.values()) {
308+
for (Suggest.Suggestion suggestion : suggestions) {
309+
if (suggestion instanceof CompletionSuggestion) {
310+
CompletionSuggestion completionSuggestion = (CompletionSuggestion) suggestion;
311+
for (CompletionSuggestion.Entry options : completionSuggestion) {
312+
for (CompletionSuggestion.Entry.Option option : options) {
313+
SearchShardTarget shard = option.getHit().getShard();
314+
ShardIdAndClusterAlias shardId = new ShardIdAndClusterAlias(shard.getShardId(), shard.getClusterAlias());
315+
assert shards.containsKey(shardId);
316+
option.setShardIndex(shards.get(shardId));
317+
}
318+
}
319+
}
320+
}
321+
}
322+
}
323+
324+
private static void assignShardIndex(Map<ShardIdAndClusterAlias, Integer> shards) {
325+
//assign a different shardIndex to each shard, based on their shardId natural ordering and their cluster alias
326+
int shardIndex = 0;
327+
for (Map.Entry<ShardIdAndClusterAlias, Integer> shard : shards.entrySet()) {
328+
shard.setValue(shardIndex++);
329+
}
330+
}
331+
298332
private static SearchHits topDocsToSearchHits(TopDocs topDocs, TopDocsStats topDocsStats) {
299333
SearchHit[] searchHits = new SearchHit[topDocs.scoreDocs.length];
300334
for (int i = 0; i < topDocs.scoreDocs.length; i++) {
@@ -340,6 +374,7 @@ private static final class ShardIdAndClusterAlias implements Comparable<ShardIdA
340374

341375
ShardIdAndClusterAlias(ShardId shardId, String clusterAlias) {
342376
this.shardId = shardId;
377+
assert clusterAlias != null : "clusterAlias is null";
343378
this.clusterAlias = clusterAlias;
344379
}
345380

server/src/main/java/org/elasticsearch/search/suggest/completion/CompletionSuggestion.java

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -145,7 +145,16 @@ private static final class OptionPriorityQueue extends PriorityQueue<ShardOption
145145

146146
@Override
147147
protected boolean lessThan(ShardOptions a, ShardOptions b) {
148-
return COMPARATOR.compare(a.current, b.current) < 0;
148+
int compare = COMPARATOR.compare(a.current, b.current);
149+
if (compare != 0) {
150+
return compare < 0;
151+
}
152+
ScoreDoc aDoc = a.current.getDoc();
153+
ScoreDoc bDoc = b.current.getDoc();
154+
if (aDoc.shardIndex == bDoc.shardIndex) {
155+
return aDoc.doc < bDoc.doc;
156+
}
157+
return aDoc.shardIndex < bDoc.shardIndex;
149158
}
150159
}
151160

@@ -157,6 +166,7 @@ private ShardOptions(Iterator<Entry.Option> optionsIterator) {
157166
assert optionsIterator.hasNext();
158167
this.optionsIterator = optionsIterator;
159168
this.current = optionsIterator.next();
169+
assert this.current.getDoc().shardIndex != -1 : "shardIndex is not set";
160170
}
161171

162172
boolean advanceToNextOption() {

server/src/test/java/org/elasticsearch/action/search/SearchResponseMergerTests.java

Lines changed: 72 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@
4747
import org.elasticsearch.search.suggest.completion.CompletionSuggestion;
4848
import org.elasticsearch.test.ESTestCase;
4949
import org.elasticsearch.transport.RemoteClusterAware;
50+
import org.elasticsearch.transport.RemoteClusterService;
5051
import org.junit.Before;
5152

5253
import java.util.ArrayList;
@@ -64,6 +65,8 @@
6465
import java.util.concurrent.TimeUnit;
6566

6667
import static org.hamcrest.Matchers.containsInAnyOrder;
68+
import static org.hamcrest.Matchers.greaterThan;
69+
import static org.hamcrest.Matchers.greaterThanOrEqualTo;
6770
import static org.hamcrest.Matchers.lessThanOrEqualTo;
6871

6972
public class SearchResponseMergerTests extends ESTestCase {
@@ -241,17 +244,25 @@ public void testMergeProfileResults() throws InterruptedException {
241244
assertEquals(expectedProfile, mergedResponse.getProfileResults());
242245
}
243246

244-
public void testMergeSuggestions() throws InterruptedException {
247+
public void testMergeCompletionSuggestions() throws InterruptedException {
245248
String suggestionName = randomAlphaOfLengthBetween(4, 8);
246-
boolean skipDuplicates = randomBoolean();
247249
int size = randomIntBetween(1, 100);
248250
SearchResponseMerger searchResponseMerger = new SearchResponseMerger(0, 0, 0, new SearchTimeProvider(0, 0, () -> 0), flag -> null);
249251
for (int i = 0; i < numResponses; i++) {
250252
List<Suggest.Suggestion<? extends Suggest.Suggestion.Entry<? extends Suggest.Suggestion.Entry.Option>>> suggestions =
251253
new ArrayList<>();
252-
CompletionSuggestion completionSuggestion = new CompletionSuggestion(suggestionName, size, skipDuplicates);
254+
CompletionSuggestion completionSuggestion = new CompletionSuggestion(suggestionName, size, false);
253255
CompletionSuggestion.Entry options = new CompletionSuggestion.Entry(new Text("suggest"), 0, 10);
254-
options.addOption(new CompletionSuggestion.Entry.Option(randomInt(), new Text("suggestion"), i, Collections.emptyMap()));
256+
int docId = randomIntBetween(0, Integer.MAX_VALUE);
257+
CompletionSuggestion.Entry.Option option = new CompletionSuggestion.Entry.Option(docId,
258+
new Text(randomAlphaOfLengthBetween(5, 10)), i, Collections.emptyMap());
259+
SearchHit hit = new SearchHit(docId);
260+
ShardId shardId = new ShardId(randomAlphaOfLengthBetween(5, 10), randomAlphaOfLength(10),
261+
randomIntBetween(0, Integer.MAX_VALUE));
262+
String clusterAlias = randomBoolean() ? "" : randomAlphaOfLengthBetween(5, 10);
263+
hit.shard(new SearchShardTarget("node", shardId, clusterAlias, OriginalIndices.NONE));
264+
option.setHit(hit);
265+
options.addOption(option);
255266
completionSuggestion.addTerm(options);
256267
suggestions.add(completionSuggestion);
257268
Suggest suggest = new Suggest(suggestions);
@@ -275,14 +286,69 @@ public void testMergeSuggestions() throws InterruptedException {
275286
mergedResponse.getSuggest().getSuggestion(suggestionName);
276287
assertEquals(1, suggestion.getEntries().size());
277288
Suggest.Suggestion.Entry<? extends Suggest.Suggestion.Entry.Option> options = suggestion.getEntries().get(0);
278-
assertEquals(skipDuplicates ? 1 : Math.min(numResponses, size), options.getOptions().size());
289+
assertEquals(Math.min(numResponses, size), options.getOptions().size());
279290
int i = numResponses;
280291
for (Suggest.Suggestion.Entry.Option option : options) {
281-
assertEquals("suggestion", option.getText().string());
282292
assertEquals(--i, option.getScore(), 0f);
283293
}
284294
}
285295

296+
public void testMergeCompletionSuggestionsTieBreak() throws InterruptedException {
297+
String suggestionName = randomAlphaOfLengthBetween(4, 8);
298+
int size = randomIntBetween(1, 100);
299+
SearchResponseMerger searchResponseMerger = new SearchResponseMerger(0, 0, 0, new SearchTimeProvider(0, 0, () -> 0), flag -> null);
300+
for (int i = 0; i < numResponses; i++) {
301+
List<Suggest.Suggestion<? extends Suggest.Suggestion.Entry<? extends Suggest.Suggestion.Entry.Option>>> suggestions =
302+
new ArrayList<>();
303+
CompletionSuggestion completionSuggestion = new CompletionSuggestion(suggestionName, size, false);
304+
CompletionSuggestion.Entry options = new CompletionSuggestion.Entry(new Text("suggest"), 0, 10);
305+
int docId = randomIntBetween(0, Integer.MAX_VALUE);
306+
CompletionSuggestion.Entry.Option option = new CompletionSuggestion.Entry.Option(docId, new Text("suggestion"), 1F,
307+
Collections.emptyMap());
308+
SearchHit searchHit = new SearchHit(docId);
309+
searchHit.shard(new SearchShardTarget("node", new ShardId("index", "uuid", randomIntBetween(0, Integer.MAX_VALUE)),
310+
randomBoolean() ? RemoteClusterService.LOCAL_CLUSTER_GROUP_KEY : randomAlphaOfLengthBetween(5, 10), OriginalIndices.NONE));
311+
option.setHit(searchHit);
312+
options.addOption(option);
313+
completionSuggestion.addTerm(options);
314+
suggestions.add(completionSuggestion);
315+
Suggest suggest = new Suggest(suggestions);
316+
SearchHits searchHits = new SearchHits(new SearchHit[0], null, Float.NaN);
317+
InternalSearchResponse internalSearchResponse = new InternalSearchResponse(searchHits, null, suggest, null, false, null, 1);
318+
SearchResponse searchResponse = new SearchResponse(internalSearchResponse, null, 1, 1, 0, randomLong(),
319+
ShardSearchFailure.EMPTY_ARRAY, SearchResponse.Clusters.EMPTY);
320+
addResponse(searchResponseMerger, searchResponse);
321+
}
322+
awaitResponsesAdded();
323+
assertEquals(numResponses, searchResponseMerger.numResponses());
324+
SearchResponse.Clusters clusters = SearchResponseTests.randomClusters();
325+
SearchResponse mergedResponse = searchResponseMerger.getMergedResponse(clusters);
326+
assertSame(clusters, mergedResponse.getClusters());
327+
assertEquals(numResponses, mergedResponse.getTotalShards());
328+
assertEquals(numResponses, mergedResponse.getSuccessfulShards());
329+
assertEquals(0, mergedResponse.getSkippedShards());
330+
assertEquals(0, mergedResponse.getFailedShards());
331+
assertEquals(0, mergedResponse.getShardFailures().length);
332+
CompletionSuggestion suggestion = mergedResponse.getSuggest().getSuggestion(suggestionName);
333+
assertEquals(1, suggestion.getEntries().size());
334+
CompletionSuggestion.Entry options = suggestion.getEntries().get(0);
335+
assertEquals(Math.min(numResponses, size), options.getOptions().size());
336+
int lastShardId = 0;
337+
String lastClusterAlias = null;
338+
for (CompletionSuggestion.Entry.Option option : options) {
339+
assertEquals("suggestion", option.getText().string());
340+
SearchShardTarget shard = option.getHit().getShard();
341+
int currentShardId = shard.getShardId().id();
342+
assertThat(currentShardId, greaterThanOrEqualTo(lastShardId));
343+
if (currentShardId == lastShardId) {
344+
assertThat(shard.getClusterAlias(), greaterThan(lastClusterAlias));
345+
} else {
346+
lastShardId = currentShardId;
347+
}
348+
lastClusterAlias = shard.getClusterAlias();
349+
}
350+
}
351+
286352
public void testMergeAggs() throws InterruptedException {
287353
SearchResponseMerger searchResponseMerger = new SearchResponseMerger(0, 0, 0, new SearchTimeProvider(0, 0, () -> 0),
288354
flag -> new InternalAggregation.ReduceContext(null, null, flag));

server/src/test/java/org/elasticsearch/search/suggest/completion/CompletionSuggestionTests.java

Lines changed: 47 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -30,11 +30,13 @@
3030

3131
import static org.elasticsearch.search.suggest.Suggest.COMPARATOR;
3232
import static org.hamcrest.Matchers.equalTo;
33+
import static org.hamcrest.Matchers.greaterThan;
34+
import static org.hamcrest.Matchers.greaterThanOrEqualTo;
3335
import static org.hamcrest.Matchers.lessThanOrEqualTo;
3436

3537
public class CompletionSuggestionTests extends ESTestCase {
3638

37-
public void testToReduce() {
39+
public void testReduce() {
3840
List<Suggest.Suggestion<CompletionSuggestion.Entry>> shardSuggestions = new ArrayList<>();
3941
int nShards = randomIntBetween(1, 10);
4042
String name = randomAlphaOfLength(10);
@@ -50,8 +52,10 @@ public void testToReduce() {
5052
Suggest.Suggestion<CompletionSuggestion.Entry> suggestion = randomFrom(shardSuggestions);
5153
CompletionSuggestion.Entry entry = suggestion.getEntries().get(0);
5254
if (entry.getOptions().size() < size) {
53-
entry.addOption(new CompletionSuggestion.Entry.Option(i, new Text(""),
54-
maxScore - i, Collections.emptyMap()));
55+
CompletionSuggestion.Entry.Option option = new CompletionSuggestion.Entry.Option(i, new Text(""),
56+
maxScore - i, Collections.emptyMap());
57+
option.setShardIndex(randomIntBetween(0, Integer.MAX_VALUE));
58+
entry.addOption(option);
5559
}
5660
}
5761
CompletionSuggestion reducedSuggestion = (CompletionSuggestion) shardSuggestions.get(0).reduce(shardSuggestions);
@@ -64,7 +68,7 @@ public void testToReduce() {
6468
}
6569
}
6670

67-
public void testToReduceWithDuplicates() {
71+
public void testReduceWithDuplicates() {
6872
List<Suggest.Suggestion<CompletionSuggestion.Entry>> shardSuggestions = new ArrayList<>();
6973
int nShards = randomIntBetween(2, 10);
7074
String name = randomAlphaOfLength(10);
@@ -85,6 +89,7 @@ public void testToReduceWithDuplicates() {
8589
String surfaceForm = randomFrom(surfaceForms);
8690
CompletionSuggestion.Entry.Option newOption =
8791
new CompletionSuggestion.Entry.Option(j, new Text(surfaceForm), maxScore - j, Collections.emptyMap());
92+
newOption.setShardIndex(0);
8893
entry.addOption(newOption);
8994
options.add(newOption);
9095
}
@@ -100,4 +105,42 @@ public void testToReduceWithDuplicates() {
100105
assertThat(reducedSuggestion.getOptions().size(), lessThanOrEqualTo(size));
101106
assertEquals(expected, reducedSuggestion.getOptions());
102107
}
108+
109+
public void testReduceTiebreak() {
110+
List<Suggest.Suggestion<CompletionSuggestion.Entry>> shardSuggestions = new ArrayList<>();
111+
Text surfaceForm = new Text(randomAlphaOfLengthBetween(5, 10));
112+
float score = randomFloat();
113+
int numResponses = randomIntBetween(2, 10);
114+
String name = randomAlphaOfLength(10);
115+
int size = randomIntBetween(10, 100);
116+
for (int i = 0; i < numResponses; i++) {
117+
CompletionSuggestion suggestion = new CompletionSuggestion(name, size, false);
118+
CompletionSuggestion.Entry entry = new CompletionSuggestion.Entry(new Text(""), 0, 0);
119+
suggestion.addTerm(entry);
120+
int shardIndex = 0;
121+
for (int j = 0; j < size; j++) {
122+
CompletionSuggestion.Entry.Option newOption =
123+
new CompletionSuggestion.Entry.Option((j + 1) * (i + 1), surfaceForm, score, Collections.emptyMap());
124+
newOption.setShardIndex(shardIndex++);
125+
entry.addOption(newOption);
126+
}
127+
shardSuggestions.add(suggestion);
128+
}
129+
CompletionSuggestion reducedSuggestion = (CompletionSuggestion) shardSuggestions.get(0).reduce(shardSuggestions);
130+
assertNotNull(reducedSuggestion);
131+
List<CompletionSuggestion.Entry.Option> options = reducedSuggestion.getOptions();
132+
assertThat(options.size(), lessThanOrEqualTo(size));
133+
int shardIndex = 0;
134+
int docId = -1;
135+
for (CompletionSuggestion.Entry.Option option : options) {
136+
assertThat(option.getDoc().shardIndex, greaterThanOrEqualTo(shardIndex));
137+
if (option.getDoc().shardIndex == shardIndex) {
138+
assertThat(option.getDoc().doc, greaterThan(docId));
139+
} else {
140+
assertThat(option.getDoc().shardIndex, equalTo(shardIndex + 1));
141+
shardIndex = option.getDoc().shardIndex;
142+
}
143+
docId = option.getDoc().doc;
144+
}
145+
}
103146
}

0 commit comments

Comments
 (0)