Skip to content

Commit 0364f3d

Browse files
committed
Handle search timeout in SuggestPhase (elastic#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 elastic#122186
1 parent cc78f18 commit 0364f3d

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,15 +14,18 @@
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;
2021
import org.apache.lucene.search.QueryVisitor;
2122
import org.apache.lucene.search.Scorable;
2223
import org.apache.lucene.search.ScoreMode;
2324
import org.apache.lucene.search.Scorer;
25+
import org.apache.lucene.search.TopDocs;
2426
import org.apache.lucene.search.Weight;
2527
import org.apache.lucene.util.Bits;
28+
import org.apache.lucene.util.CharsRefBuilder;
2629
import org.elasticsearch.ElasticsearchException;
2730
import org.elasticsearch.TransportVersion;
2831
import org.elasticsearch.action.search.SearchRequestBuilder;
@@ -32,12 +35,23 @@
3235
import org.elasticsearch.core.TimeValue;
3336
import org.elasticsearch.index.query.AbstractQueryBuilder;
3437
import org.elasticsearch.index.query.QueryBuilder;
38+
import org.elasticsearch.index.query.QueryRewriteContext;
3539
import org.elasticsearch.index.query.SearchExecutionContext;
40+
import org.elasticsearch.index.query.TermQueryBuilder;
3641
import org.elasticsearch.plugins.Plugin;
3742
import org.elasticsearch.plugins.SearchPlugin;
3843
import org.elasticsearch.search.aggregations.bucket.terms.StringTerms;
3944
import org.elasticsearch.search.aggregations.bucket.terms.TermsAggregationBuilder;
4045
import org.elasticsearch.search.internal.ContextIndexSearcher;
46+
import org.elasticsearch.search.rescore.RescoreContext;
47+
import org.elasticsearch.search.rescore.Rescorer;
48+
import org.elasticsearch.search.rescore.RescorerBuilder;
49+
import org.elasticsearch.search.suggest.SortBy;
50+
import org.elasticsearch.search.suggest.SuggestBuilder;
51+
import org.elasticsearch.search.suggest.Suggester;
52+
import org.elasticsearch.search.suggest.SuggestionSearchContext;
53+
import org.elasticsearch.search.suggest.term.TermSuggestion;
54+
import org.elasticsearch.search.suggest.term.TermSuggestionBuilder;
4155
import org.elasticsearch.test.ESIntegTestCase;
4256
import org.elasticsearch.test.hamcrest.ElasticsearchAssertions;
4357
import org.elasticsearch.xcontent.XContentBuilder;
@@ -57,7 +71,7 @@ public class SearchTimeoutIT extends ESIntegTestCase {
5771

5872
@Override
5973
protected Collection<Class<? extends Plugin>> nodePlugins() {
60-
return Collections.singleton(BulkScorerTimeoutQueryPlugin.class);
74+
return Collections.singleton(SearchTimeoutPlugin.class);
6175
}
6276

6377
@Override
@@ -71,6 +85,9 @@ protected void setupSuiteScopeCluster() throws Exception {
7185
indexRandom(true, "test", randomIntBetween(20, 50));
7286
}
7387

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

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

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

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

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

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

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

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;
@@ -1016,22 +1015,6 @@ public void testRescoreAfterCollapseRandom() throws Exception {
10161015
});
10171016
}
10181017

1019-
public void testRescoreWithTimeout() throws Exception {
1020-
// no dummy docs since merges can change scores while we run queries.
1021-
int numDocs = indexRandomNumbers("whitespace", -1, false);
1022-
1023-
String intToEnglish = English.intToEnglish(between(0, numDocs - 1));
1024-
String query = intToEnglish.split(" ")[0];
1025-
assertResponse(
1026-
prepareSearch().setSearchType(SearchType.QUERY_THEN_FETCH)
1027-
.setQuery(QueryBuilders.matchQuery("field1", query).operator(Operator.OR))
1028-
.setSize(10)
1029-
.addRescorer(new QueryRescorerBuilder(functionScoreQuery(new TestTimedScoreFunctionBuilder())).windowSize(100))
1030-
.setTimeout(TimeValue.timeValueMillis(10)),
1031-
r -> assertTrue(r.isTimedOut())
1032-
);
1033-
}
1034-
10351018
@Override
10361019
protected Collection<Class<? extends Plugin>> nodePlugins() {
10371020
return List.of(TestTimedQueryPlugin.class);

0 commit comments

Comments
 (0)