Skip to content

Commit 610722d

Browse files
authored
Handle search timeout in SuggestPhase (#122357)
Whenever a search timeout is set to a search request, the timeout may be triggered by the suggest phase via exitable directory reader. In that case, the exception that is thrown by the timeout check needs to be handled, instead of returned back to the user. Instead of handling the timeout in each phase, this commit handles it as part of QueryPhase for both SuggestPhase and RescorePhase. For rescore phase, one integration test that is time dependent is also rewritten to remove the time dependency and moved from QueryRescorerIT to SearchTimeoutIT. Closes #122186
1 parent b117651 commit 610722d

File tree

7 files changed

+394
-35
lines changed

7 files changed

+394
-35
lines changed

docs/changelog/122357.yaml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
pr: 122357
2+
summary: Handle search timeout in `SuggestPhase`
3+
area: Search
4+
type: bug
5+
issues:
6+
- 122186

server/src/internalClusterTest/java/org/elasticsearch/search/SearchTimeoutIT.java

Lines changed: 239 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
import org.apache.lucene.search.ConstantScoreScorer;
1515
import org.apache.lucene.search.ConstantScoreWeight;
1616
import org.apache.lucene.search.DocIdSetIterator;
17+
import org.apache.lucene.search.Explanation;
1718
import org.apache.lucene.search.IndexSearcher;
1819
import org.apache.lucene.search.LeafCollector;
1920
import org.apache.lucene.search.Query;
@@ -22,8 +23,10 @@
2223
import org.apache.lucene.search.ScoreMode;
2324
import org.apache.lucene.search.Scorer;
2425
import org.apache.lucene.search.ScorerSupplier;
26+
import org.apache.lucene.search.TopDocs;
2527
import org.apache.lucene.search.Weight;
2628
import org.apache.lucene.util.Bits;
29+
import org.apache.lucene.util.CharsRefBuilder;
2730
import org.elasticsearch.ElasticsearchException;
2831
import org.elasticsearch.TransportVersion;
2932
import org.elasticsearch.action.search.SearchRequestBuilder;
@@ -33,12 +36,23 @@
3336
import org.elasticsearch.core.TimeValue;
3437
import org.elasticsearch.index.query.AbstractQueryBuilder;
3538
import org.elasticsearch.index.query.QueryBuilder;
39+
import org.elasticsearch.index.query.QueryRewriteContext;
3640
import org.elasticsearch.index.query.SearchExecutionContext;
41+
import org.elasticsearch.index.query.TermQueryBuilder;
3742
import org.elasticsearch.plugins.Plugin;
3843
import org.elasticsearch.plugins.SearchPlugin;
3944
import org.elasticsearch.search.aggregations.bucket.terms.StringTerms;
4045
import org.elasticsearch.search.aggregations.bucket.terms.TermsAggregationBuilder;
4146
import org.elasticsearch.search.internal.ContextIndexSearcher;
47+
import org.elasticsearch.search.rescore.RescoreContext;
48+
import org.elasticsearch.search.rescore.Rescorer;
49+
import org.elasticsearch.search.rescore.RescorerBuilder;
50+
import org.elasticsearch.search.suggest.SortBy;
51+
import org.elasticsearch.search.suggest.SuggestBuilder;
52+
import org.elasticsearch.search.suggest.Suggester;
53+
import org.elasticsearch.search.suggest.SuggestionSearchContext;
54+
import org.elasticsearch.search.suggest.term.TermSuggestion;
55+
import org.elasticsearch.search.suggest.term.TermSuggestionBuilder;
4256
import org.elasticsearch.test.ESIntegTestCase;
4357
import org.elasticsearch.test.hamcrest.ElasticsearchAssertions;
4458
import org.elasticsearch.xcontent.XContentBuilder;
@@ -58,7 +72,7 @@ public class SearchTimeoutIT extends ESIntegTestCase {
5872

5973
@Override
6074
protected Collection<Class<? extends Plugin>> nodePlugins() {
61-
return Collections.singleton(BulkScorerTimeoutQueryPlugin.class);
75+
return Collections.singleton(SearchTimeoutPlugin.class);
6276
}
6377

6478
@Override
@@ -72,6 +86,9 @@ protected void setupSuiteScopeCluster() throws Exception {
7286
indexRandom(true, "test", randomIntBetween(20, 50));
7387
}
7488

89+
/**
90+
* Test the scenario where the query times out before starting to collect documents, verify that partial hits are not returned
91+
*/
7592
public void testTopHitsTimeoutBeforeCollecting() {
7693
// setting the timeout is necessary only because we check that if a TimeExceededException is thrown, a timeout was set
7794
SearchRequestBuilder searchRequestBuilder = prepareSearch("test").setTimeout(new TimeValue(10, TimeUnit.SECONDS))
@@ -88,6 +105,9 @@ public void testTopHitsTimeoutBeforeCollecting() {
88105
});
89106
}
90107

108+
/**
109+
* Test the scenario where the query times out while collecting documents, verify that partial hits results are returned
110+
*/
91111
public void testTopHitsTimeoutWhileCollecting() {
92112
// setting the timeout is necessary only because we check that if a TimeExceededException is thrown, a timeout was set
93113
SearchRequestBuilder searchRequestBuilder = prepareSearch("test").setTimeout(new TimeValue(10, TimeUnit.SECONDS))
@@ -103,6 +123,9 @@ public void testTopHitsTimeoutWhileCollecting() {
103123
});
104124
}
105125

126+
/**
127+
* Test the scenario where the query times out before starting to collect documents, verify that partial aggs results are not returned
128+
*/
106129
public void testAggsTimeoutBeforeCollecting() {
107130
SearchRequestBuilder searchRequestBuilder = prepareSearch("test").setSize(0)
108131
// setting the timeout is necessary only because we check that if a TimeExceededException is thrown, a timeout was set
@@ -123,6 +146,9 @@ public void testAggsTimeoutBeforeCollecting() {
123146
});
124147
}
125148

149+
/**
150+
* Test the scenario where the query times out while collecting documents, verify that partial aggs results are returned
151+
*/
126152
public void testAggsTimeoutWhileCollecting() {
127153
SearchRequestBuilder searchRequestBuilder = prepareSearch("test").setSize(0)
128154
// setting the timeout is necessary only because we check that if a TimeExceededException is thrown, a timeout was set
@@ -145,6 +171,56 @@ public void testAggsTimeoutWhileCollecting() {
145171
});
146172
}
147173

174+
/**
175+
* Test the scenario where the suggest phase (part of the query phase) times out, yet there are results
176+
* available coming from executing the query and aggs on each shard.
177+
*/
178+
public void testSuggestTimeoutWithPartialResults() {
179+
SuggestBuilder suggestBuilder = new SuggestBuilder();
180+
suggestBuilder.setGlobalText("text");
181+
TimeoutSuggestionBuilder timeoutSuggestionBuilder = new TimeoutSuggestionBuilder();
182+
suggestBuilder.addSuggestion("suggest", timeoutSuggestionBuilder);
183+
SearchRequestBuilder searchRequestBuilder = prepareSearch("test").suggest(suggestBuilder)
184+
.addAggregation(new TermsAggregationBuilder("terms").field("field.keyword"));
185+
ElasticsearchAssertions.assertResponse(searchRequestBuilder, searchResponse -> {
186+
assertThat(searchResponse.isTimedOut(), equalTo(true));
187+
assertEquals(0, searchResponse.getShardFailures().length);
188+
assertEquals(0, searchResponse.getFailedShards());
189+
assertThat(searchResponse.getSuccessfulShards(), greaterThan(0));
190+
assertEquals(searchResponse.getSuccessfulShards(), searchResponse.getTotalShards());
191+
assertThat(searchResponse.getHits().getTotalHits().value(), greaterThan(0L));
192+
assertThat(searchResponse.getHits().getHits().length, greaterThan(0));
193+
StringTerms terms = searchResponse.getAggregations().get("terms");
194+
assertEquals(1, terms.getBuckets().size());
195+
StringTerms.Bucket bucket = terms.getBuckets().get(0);
196+
assertEquals("value", bucket.getKeyAsString());
197+
assertThat(bucket.getDocCount(), greaterThan(0L));
198+
});
199+
}
200+
201+
/**
202+
* Test the scenario where the rescore phase (part of the query phase) times out, yet there are results
203+
* available coming from executing the query and aggs on each shard.
204+
*/
205+
public void testRescoreTimeoutWithPartialResults() {
206+
SearchRequestBuilder searchRequestBuilder = prepareSearch("test").setRescorer(new TimeoutRescorerBuilder())
207+
.addAggregation(new TermsAggregationBuilder("terms").field("field.keyword"));
208+
ElasticsearchAssertions.assertResponse(searchRequestBuilder, searchResponse -> {
209+
assertThat(searchResponse.isTimedOut(), equalTo(true));
210+
assertEquals(0, searchResponse.getShardFailures().length);
211+
assertEquals(0, searchResponse.getFailedShards());
212+
assertThat(searchResponse.getSuccessfulShards(), greaterThan(0));
213+
assertEquals(searchResponse.getSuccessfulShards(), searchResponse.getTotalShards());
214+
assertThat(searchResponse.getHits().getTotalHits().value(), greaterThan(0L));
215+
assertThat(searchResponse.getHits().getHits().length, greaterThan(0));
216+
StringTerms terms = searchResponse.getAggregations().get("terms");
217+
assertEquals(1, terms.getBuckets().size());
218+
StringTerms.Bucket bucket = terms.getBuckets().get(0);
219+
assertEquals("value", bucket.getKeyAsString());
220+
assertThat(bucket.getDocCount(), greaterThan(0L));
221+
});
222+
}
223+
148224
public void testPartialResultsIntolerantTimeoutBeforeCollecting() {
149225
ElasticsearchException ex = expectThrows(
150226
ElasticsearchException.class,
@@ -171,13 +247,67 @@ public void testPartialResultsIntolerantTimeoutWhileCollecting() {
171247
assertEquals(429, ex.status().getStatus());
172248
}
173249

174-
public static final class BulkScorerTimeoutQueryPlugin extends Plugin implements SearchPlugin {
250+
public void testPartialResultsIntolerantTimeoutWhileSuggestingOnly() {
251+
SuggestBuilder suggestBuilder = new SuggestBuilder();
252+
suggestBuilder.setGlobalText("text");
253+
TimeoutSuggestionBuilder timeoutSuggestionBuilder = new TimeoutSuggestionBuilder();
254+
suggestBuilder.addSuggestion("suggest", timeoutSuggestionBuilder);
255+
ElasticsearchException ex = expectThrows(
256+
ElasticsearchException.class,
257+
prepareSearch("test").suggest(suggestBuilder).setAllowPartialSearchResults(false) // this line causes timeouts to report
258+
// failures
259+
);
260+
assertTrue(ex.toString().contains("Time exceeded"));
261+
assertEquals(429, ex.status().getStatus());
262+
}
263+
264+
public void testPartialResultsIntolerantTimeoutWhileSuggesting() {
265+
SuggestBuilder suggestBuilder = new SuggestBuilder();
266+
suggestBuilder.setGlobalText("text");
267+
TimeoutSuggestionBuilder timeoutSuggestionBuilder = new TimeoutSuggestionBuilder();
268+
suggestBuilder.addSuggestion("suggest", timeoutSuggestionBuilder);
269+
ElasticsearchException ex = expectThrows(
270+
ElasticsearchException.class,
271+
prepareSearch("test").setQuery(new TermQueryBuilder("field", "value"))
272+
.suggest(suggestBuilder)
273+
.setAllowPartialSearchResults(false) // this line causes timeouts to report failures
274+
);
275+
assertTrue(ex.toString().contains("Time exceeded"));
276+
assertEquals(429, ex.status().getStatus());
277+
}
278+
279+
public void testPartialResultsIntolerantTimeoutWhileRescoring() {
280+
ElasticsearchException ex = expectThrows(
281+
ElasticsearchException.class,
282+
prepareSearch("test").setQuery(new TermQueryBuilder("field", "value"))
283+
.setRescorer(new TimeoutRescorerBuilder())
284+
.setAllowPartialSearchResults(false) // this line causes timeouts to report failures
285+
);
286+
assertTrue(ex.toString().contains("Time exceeded"));
287+
assertEquals(429, ex.status().getStatus());
288+
}
289+
290+
public static final class SearchTimeoutPlugin extends Plugin implements SearchPlugin {
175291
@Override
176292
public List<QuerySpec<?>> getQueries() {
177293
return Collections.singletonList(new QuerySpec<QueryBuilder>("timeout", BulkScorerTimeoutQuery::new, parser -> {
178294
throw new UnsupportedOperationException();
179295
}));
180296
}
297+
298+
@Override
299+
public List<SuggesterSpec<?>> getSuggesters() {
300+
return Collections.singletonList(new SuggesterSpec<>("timeout", TimeoutSuggestionBuilder::new, parser -> {
301+
throw new UnsupportedOperationException();
302+
}, TermSuggestion::new));
303+
}
304+
305+
@Override
306+
public List<RescorerSpec<?>> getRescorers() {
307+
return Collections.singletonList(new RescorerSpec<>("timeout", TimeoutRescorerBuilder::new, parser -> {
308+
throw new UnsupportedOperationException();
309+
}));
310+
}
181311
}
182312

183313
/**
@@ -315,4 +445,111 @@ public TransportVersion getMinimalSupportedVersion() {
315445
return null;
316446
}
317447
}
448+
449+
/**
450+
* Suggestion builder that triggers a timeout as part of its execution
451+
*/
452+
private static final class TimeoutSuggestionBuilder extends TermSuggestionBuilder {
453+
TimeoutSuggestionBuilder() {
454+
super("field");
455+
}
456+
457+
TimeoutSuggestionBuilder(StreamInput in) throws IOException {
458+
super(in);
459+
}
460+
461+
@Override
462+
public String getWriteableName() {
463+
return "timeout";
464+
}
465+
466+
@Override
467+
public SuggestionSearchContext.SuggestionContext build(SearchExecutionContext context) {
468+
return new TimeoutSuggestionContext(new TimeoutSuggester((ContextIndexSearcher) context.searcher()), context);
469+
}
470+
}
471+
472+
private static final class TimeoutSuggester extends Suggester<TimeoutSuggestionContext> {
473+
private final ContextIndexSearcher contextIndexSearcher;
474+
475+
TimeoutSuggester(ContextIndexSearcher contextIndexSearcher) {
476+
this.contextIndexSearcher = contextIndexSearcher;
477+
}
478+
479+
@Override
480+
protected TermSuggestion innerExecute(
481+
String name,
482+
TimeoutSuggestionContext suggestion,
483+
IndexSearcher searcher,
484+
CharsRefBuilder spare
485+
) {
486+
contextIndexSearcher.throwTimeExceededException();
487+
assert false;
488+
return new TermSuggestion(name, suggestion.getSize(), SortBy.SCORE);
489+
}
490+
491+
@Override
492+
protected TermSuggestion emptySuggestion(String name, TimeoutSuggestionContext suggestion, CharsRefBuilder spare) {
493+
return new TermSuggestion(name, suggestion.getSize(), SortBy.SCORE);
494+
}
495+
}
496+
497+
private static final class TimeoutSuggestionContext extends SuggestionSearchContext.SuggestionContext {
498+
TimeoutSuggestionContext(Suggester<?> suggester, SearchExecutionContext searchExecutionContext) {
499+
super(suggester, searchExecutionContext);
500+
}
501+
}
502+
503+
private static final class TimeoutRescorerBuilder extends RescorerBuilder<TimeoutRescorerBuilder> {
504+
TimeoutRescorerBuilder() {
505+
super();
506+
}
507+
508+
TimeoutRescorerBuilder(StreamInput in) throws IOException {
509+
super(in);
510+
}
511+
512+
@Override
513+
protected void doWriteTo(StreamOutput out) {}
514+
515+
@Override
516+
protected void doXContent(XContentBuilder builder, Params params) {}
517+
518+
@Override
519+
protected RescoreContext innerBuildContext(int windowSize, SearchExecutionContext context) throws IOException {
520+
return new RescoreContext(10, new Rescorer() {
521+
@Override
522+
public TopDocs rescore(TopDocs topDocs, IndexSearcher searcher, RescoreContext rescoreContext) {
523+
((ContextIndexSearcher) context.searcher()).throwTimeExceededException();
524+
assert false;
525+
return null;
526+
}
527+
528+
@Override
529+
public Explanation explain(
530+
int topLevelDocId,
531+
IndexSearcher searcher,
532+
RescoreContext rescoreContext,
533+
Explanation sourceExplanation
534+
) {
535+
throw new UnsupportedOperationException();
536+
}
537+
});
538+
}
539+
540+
@Override
541+
public String getWriteableName() {
542+
return "timeout";
543+
}
544+
545+
@Override
546+
public TransportVersion getMinimalSupportedVersion() {
547+
return null;
548+
}
549+
550+
@Override
551+
public RescorerBuilder<TimeoutRescorerBuilder> rewrite(QueryRewriteContext ctx) {
552+
return this;
553+
}
554+
}
318555
}

server/src/internalClusterTest/java/org/elasticsearch/search/functionscore/QueryRescorerIT.java

Lines changed: 0 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,6 @@
2424
import org.elasticsearch.common.lucene.search.function.ScoreFunction;
2525
import org.elasticsearch.common.settings.Settings;
2626
import org.elasticsearch.common.settings.Settings.Builder;
27-
import org.elasticsearch.core.TimeValue;
2827
import org.elasticsearch.index.query.Operator;
2928
import org.elasticsearch.index.query.QueryBuilder;
3029
import org.elasticsearch.index.query.QueryBuilders;
@@ -994,22 +993,6 @@ public void testRescoreAfterCollapseRandom() throws Exception {
994993
});
995994
}
996995

997-
public void testRescoreWithTimeout() throws Exception {
998-
// no dummy docs since merges can change scores while we run queries.
999-
int numDocs = indexRandomNumbers("whitespace", -1, false);
1000-
1001-
String intToEnglish = English.intToEnglish(between(0, numDocs - 1));
1002-
String query = intToEnglish.split(" ")[0];
1003-
assertResponse(
1004-
prepareSearch().setSearchType(SearchType.QUERY_THEN_FETCH)
1005-
.setQuery(QueryBuilders.matchQuery("field1", query).operator(Operator.OR))
1006-
.setSize(10)
1007-
.addRescorer(new QueryRescorerBuilder(functionScoreQuery(new TestTimedScoreFunctionBuilder())).windowSize(100))
1008-
.setTimeout(TimeValue.timeValueMillis(10)),
1009-
r -> assertTrue(r.isTimedOut())
1010-
);
1011-
}
1012-
1013996
@Override
1014997
protected Collection<Class<? extends Plugin>> nodePlugins() {
1015998
return List.of(TestTimedQueryPlugin.class);

0 commit comments

Comments
 (0)