diff --git a/test/external-modules/error-query/build.gradle b/test/external-modules/error-query/build.gradle new file mode 100644 index 0000000000000..77ac6090fa4e0 --- /dev/null +++ b/test/external-modules/error-query/build.gradle @@ -0,0 +1,18 @@ +/* + * 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. + */ + +esplugin { + description 'A test module that exposes a way to simulate search shard failures and warnings' + classname 'org.elasticsearch.search.query.ErrorQueryPlugin' +} + +restResources { + restApi { + include '_common', 'indices', 'index', 'cluster', 'search' + } +} diff --git a/test/external-modules/error-query/src/main/java/org/elasticsearch/search/query/ErrorQueryBuilder.java b/test/external-modules/error-query/src/main/java/org/elasticsearch/search/query/ErrorQueryBuilder.java new file mode 100644 index 0000000000000..c745ae1a0bb1e --- /dev/null +++ b/test/external-modules/error-query/src/main/java/org/elasticsearch/search/query/ErrorQueryBuilder.java @@ -0,0 +1,130 @@ +/* + * 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.search.query; + +import org.apache.lucene.search.MatchAllDocsQuery; +import org.apache.lucene.search.Query; +import org.elasticsearch.common.ParseField; +import org.elasticsearch.common.io.stream.StreamInput; +import org.elasticsearch.common.io.stream.StreamOutput; +import org.elasticsearch.common.logging.HeaderWarning; +import org.elasticsearch.common.xcontent.ConstructingObjectParser; +import org.elasticsearch.common.xcontent.XContentBuilder; +import org.elasticsearch.index.query.AbstractQueryBuilder; +import org.elasticsearch.index.query.SearchExecutionContext; + +import java.io.IOException; +import java.util.List; +import java.util.Objects; + +import static org.elasticsearch.common.xcontent.ConstructingObjectParser.constructorArg; + +/** + * A test query that can simulate errors and warnings when executing a shard request. + */ +public class ErrorQueryBuilder extends AbstractQueryBuilder { + public static final String NAME = "error_query"; + + private List indices; + + public ErrorQueryBuilder(List indices) { + this.indices = Objects.requireNonNull(indices); + } + + public ErrorQueryBuilder(StreamInput in) throws IOException { + super(in); + this.indices = in.readList(IndexError::new); + } + + @Override + protected void doWriteTo(StreamOutput out) throws IOException { + out.writeList(indices); + } + + @Override + public String getWriteableName() { + return NAME; + } + + @Override + protected Query doToQuery(SearchExecutionContext context) throws IOException { + // Disable the request cache + context.nowInMillis(); + + IndexError error = null; + for (IndexError index : indices) { + if (context.indexMatches(index.getIndexName())) { + error = index; + break; + } + } + if (error == null) { + return new MatchAllDocsQuery(); + } + if (error.getShardIds() != null) { + boolean match = false; + for (int shardId : error.getShardIds()) { + if (context.getShardId() == shardId) { + match = true; + break; + } + } + if (match == false) { + return new MatchAllDocsQuery(); + } + } + final String header = "[" + context.index().getName() + "][" + context.getShardId() + "]"; + if (error.getErrorType() == IndexError.ERROR_TYPE.WARNING) { + HeaderWarning.addWarning(header + " " + error.getMessage()); + return new MatchAllDocsQuery(); + } else { + throw new RuntimeException(header + " " + error.getMessage()); + } + } + + @SuppressWarnings("unchecked") + static final ConstructingObjectParser PARSER = new ConstructingObjectParser<>(NAME, false, (args, name) -> { + ErrorQueryBuilder q = new ErrorQueryBuilder((List) args[0]); + final float boost = args[1] == null ? DEFAULT_BOOST : (Float) args[1]; + final String queryName = (String) args[2]; + q.boost = boost; + q.queryName = queryName; + return q; + }); + + static { + PARSER.declareObjectArray(constructorArg(), (p, c) -> IndexError.PARSER.parse(p, c), new ParseField("indices")); + PARSER.declareFloat(ConstructingObjectParser.optionalConstructorArg(), BOOST_FIELD); + PARSER.declareString(ConstructingObjectParser.optionalConstructorArg(), NAME_FIELD); + } + + @Override + protected void doXContent(XContentBuilder builder, Params params) throws IOException { + builder.startObject(NAME); + builder.startArray("indices"); + for (IndexError indexError : indices) { + builder.startObject(); + indexError.toXContent(builder, params); + builder.endObject(); + } + builder.endArray(); + printBoostAndQueryName(builder); + builder.endObject(); + } + + @Override + protected boolean doEquals(ErrorQueryBuilder other) { + return Objects.equals(indices, other.indices); + } + + @Override + protected int doHashCode() { + return Objects.hash(indices); + } +} diff --git a/test/external-modules/error-query/src/main/java/org/elasticsearch/search/query/ErrorQueryPlugin.java b/test/external-modules/error-query/src/main/java/org/elasticsearch/search/query/ErrorQueryPlugin.java new file mode 100644 index 0000000000000..fa294c5ea2487 --- /dev/null +++ b/test/external-modules/error-query/src/main/java/org/elasticsearch/search/query/ErrorQueryPlugin.java @@ -0,0 +1,28 @@ +/* + * 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.search.query; + +import java.util.List; + +import org.elasticsearch.plugins.Plugin; +import org.elasticsearch.plugins.SearchPlugin; + +import static java.util.Collections.singletonList; + +/** + * Test plugin that exposes a way to simulate search shard failures and warnings. + */ +public class ErrorQueryPlugin extends Plugin implements SearchPlugin { + public ErrorQueryPlugin() {} + + @Override + public List> getQueries() { + return singletonList(new QuerySpec<>(ErrorQueryBuilder.NAME, ErrorQueryBuilder::new, p -> ErrorQueryBuilder.PARSER.parse(p, null))); + } +} diff --git a/test/external-modules/error-query/src/main/java/org/elasticsearch/search/query/IndexError.java b/test/external-modules/error-query/src/main/java/org/elasticsearch/search/query/IndexError.java new file mode 100644 index 0000000000000..05a1b8e5e8749 --- /dev/null +++ b/test/external-modules/error-query/src/main/java/org/elasticsearch/search/query/IndexError.java @@ -0,0 +1,133 @@ +/* + * 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.search.query; + +import org.elasticsearch.common.Nullable; +import org.elasticsearch.common.ParseField; +import org.elasticsearch.common.io.stream.StreamInput; +import org.elasticsearch.common.io.stream.StreamOutput; +import org.elasticsearch.common.io.stream.Writeable; +import org.elasticsearch.common.xcontent.ConstructingObjectParser; +import org.elasticsearch.common.xcontent.ToXContentFragment; +import org.elasticsearch.common.xcontent.XContentBuilder; + +import java.io.IOException; +import java.util.Arrays; +import java.util.List; +import java.util.Locale; +import java.util.Objects; + +import static org.elasticsearch.common.xcontent.ConstructingObjectParser.constructorArg; +import static org.elasticsearch.common.xcontent.ConstructingObjectParser.optionalConstructorArg; + +public class IndexError implements Writeable, ToXContentFragment { + enum ERROR_TYPE { + WARNING, + EXCEPTION + } + + private final String indexName; + private final int[] shardIds; + private final ERROR_TYPE errorType; + private final String message; + + public IndexError(String indexName, int[] shardIds, ERROR_TYPE errorType, String message) { + this.indexName = indexName; + this.shardIds = shardIds; + this.errorType = errorType; + this.message = message; + } + + public IndexError(StreamInput in) throws IOException { + this.indexName = in.readString(); + this.shardIds = in.readBoolean() ? in.readIntArray() : null; + this.errorType = in.readEnum(ERROR_TYPE.class); + this.message = in.readString(); + } + + @Override + public void writeTo(StreamOutput out) throws IOException { + out.writeString(indexName); + out.writeBoolean(shardIds != null); + if (shardIds != null) { + out.writeIntArray(shardIds); + } + out.writeEnum(errorType); + out.writeString(message); + } + + public String getIndexName() { + return indexName; + } + + @Nullable + public int[] getShardIds() { + return shardIds; + } + + public ERROR_TYPE getErrorType() { + return errorType; + } + + public String getMessage() { + return message; + } + + @SuppressWarnings("unchecked") + static final ConstructingObjectParser PARSER = new ConstructingObjectParser<>( + "index_error", + false, + (args, name) -> { + List lst = (List) args[1]; + int[] shardIds = lst == null ? null : lst.stream().mapToInt(i -> i).toArray(); + return new IndexError( + (String) args[0], + shardIds, + ERROR_TYPE.valueOf(((String) args[2]).toUpperCase(Locale.ROOT)), + (String) args[3] + ); + } + ); + + static { + PARSER.declareString(constructorArg(), new ParseField("name")); + PARSER.declareIntArray(optionalConstructorArg(), new ParseField("shard_ids")); + PARSER.declareString(constructorArg(), new ParseField("error_type")); + PARSER.declareString(constructorArg(), new ParseField("message")); + } + + @Override + public XContentBuilder toXContent(XContentBuilder builder, Params params) throws IOException { + builder.field("name", indexName); + if (shardIds != null) { + builder.field("shard_ids", shardIds); + } + builder.field("error_type", errorType.toString()); + builder.field("message", message); + return builder; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + IndexError that = (IndexError) o; + return indexName.equals(that.indexName) + && Arrays.equals(shardIds, that.shardIds) + && errorType == that.errorType + && message.equals(that.message); + } + + @Override + public int hashCode() { + int result = Objects.hash(indexName, errorType, message); + result = 31 * result + Arrays.hashCode(shardIds); + return result; + } +} diff --git a/test/external-modules/error-query/src/test/java/org/elasticsearch/search/query/ErrorQueryBuilderTests.java b/test/external-modules/error-query/src/test/java/org/elasticsearch/search/query/ErrorQueryBuilderTests.java new file mode 100644 index 0000000000000..f2b3e355e6a39 --- /dev/null +++ b/test/external-modules/error-query/src/test/java/org/elasticsearch/search/query/ErrorQueryBuilderTests.java @@ -0,0 +1,62 @@ +/* + * 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.search.query; + +import org.apache.lucene.search.MatchAllDocsQuery; +import org.apache.lucene.search.Query; +import org.elasticsearch.index.query.QueryBuilder; +import org.elasticsearch.index.query.SearchExecutionContext; +import org.elasticsearch.plugins.Plugin; +import org.elasticsearch.test.AbstractQueryTestCase; +import org.elasticsearch.test.TestGeoShapeFieldMapperPlugin; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collection; +import java.util.List; + +public class ErrorQueryBuilderTests extends AbstractQueryTestCase { + @Override + protected Collection> getPlugins() { + return Arrays.asList(ErrorQueryPlugin.class, TestGeoShapeFieldMapperPlugin.class); + } + + @Override + protected ErrorQueryBuilder doCreateTestQueryBuilder() { + int numIndex = randomIntBetween(0, 5); + List indices = new ArrayList<>(); + for (int i = 0; i < numIndex; i++) { + String indexName = randomAlphaOfLengthBetween(5, 30); + int numShards = randomIntBetween(0, 3); + int[] shardIds = numShards > 0 ? new int[numShards] : null; + for (int j = 0; j < numShards; j++) { + shardIds[j] = j; + } + indices.add( + new IndexError(indexName, shardIds, randomFrom(IndexError.ERROR_TYPE.values()), randomAlphaOfLengthBetween(5, 100)) + ); + } + + return new ErrorQueryBuilder(indices); + } + + @Override + protected void doAssertLuceneQuery(ErrorQueryBuilder queryBuilder, Query query, SearchExecutionContext context) throws IOException { + assertEquals(new MatchAllDocsQuery(), query); + } + + @Override + public void testCacheability() throws IOException { + ErrorQueryBuilder queryBuilder = createTestQueryBuilder(); + SearchExecutionContext context = createSearchExecutionContext(); + QueryBuilder rewriteQuery = rewriteQuery(queryBuilder, new SearchExecutionContext(context)); + assertNotNull(rewriteQuery.toQuery(context)); + assertFalse("query should not be cacheable: " + queryBuilder.toString(), context.isCacheable()); + } +} diff --git a/test/external-modules/error-query/src/yamlRestTest/java/org/elasticsearch/search/query/ErrorQueryClientYamlTestSuiteIT.java b/test/external-modules/error-query/src/yamlRestTest/java/org/elasticsearch/search/query/ErrorQueryClientYamlTestSuiteIT.java new file mode 100644 index 0000000000000..eb2dc56847a79 --- /dev/null +++ b/test/external-modules/error-query/src/yamlRestTest/java/org/elasticsearch/search/query/ErrorQueryClientYamlTestSuiteIT.java @@ -0,0 +1,26 @@ +/* + * 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.search.query; + +import com.carrotsearch.randomizedtesting.annotations.Name; +import com.carrotsearch.randomizedtesting.annotations.ParametersFactory; + +import org.elasticsearch.test.rest.yaml.ClientYamlTestCandidate; +import org.elasticsearch.test.rest.yaml.ESClientYamlSuiteTestCase; + +public class ErrorQueryClientYamlTestSuiteIT extends ESClientYamlSuiteTestCase { + public ErrorQueryClientYamlTestSuiteIT(@Name("yaml") ClientYamlTestCandidate testCandidate) { + super(testCandidate); + } + + @ParametersFactory + public static Iterable parameters() throws Exception { + return ESClientYamlSuiteTestCase.createParameters(); + } +} diff --git a/test/external-modules/error-query/src/yamlRestTest/resources/rest-api-spec/test/error_query/10_basic.yml b/test/external-modules/error-query/src/yamlRestTest/resources/rest-api-spec/test/error_query/10_basic.yml new file mode 100644 index 0000000000000..6b602d45e8fc6 --- /dev/null +++ b/test/external-modules/error-query/src/yamlRestTest/resources/rest-api-spec/test/error_query/10_basic.yml @@ -0,0 +1,61 @@ +# Integration tests for error_query +# + +--- +"Error query": + - skip: + features: [ "allowed_warnings" ] + + - do: + indices.create: + index: test_exception + body: + settings: + index.number_of_shards: 2 + + - do: + indices.create: + index: test_warning + body: + settings: + index.number_of_shards: 2 + + - do: + search: + index: test* + body: + query: + "error_query": { "indices": [ { name: "test_exception", shard_ids: [ 1 ], error_type: "exception", message: "boom" }, { name: "test_warning", error_type: "warning", message: "Watch out!" } ] } + allowed_warnings: + - "[test_warning][0] Watch out!" + - "[test_warning][1] Watch out!" + + - match: { hits.total.value: 0 } + - match: { _shards.total: 4 } + - match: { _shards.successful: 3 } + - match: { _shards.failed: 1 } + - length: { _shards.failures: 1 } + - match: { _shards.failures.0.index: "test_exception" } + - match: { _shards.failures.0.shard: 1 } + - match: { _shards.failures.0.reason.caused_by.reason: "[test_exception][1] boom" } + + - do: + search: + index: test* + body: + query: + "error_query": { "indices": [ { name: "test_exception", error_type: "exception", message: "boom" }, { name: "test_warning", shard_ids: [ 1 ], error_type: "warning", message: "Watch out!" } ] } + allowed_warnings: + - "[test_warning][1] Watch out!" + + - match: { hits.total.value: 0 } + - match: { _shards.total: 4 } + - match: { _shards.successful: 2 } + - match: { _shards.failed: 2 } + - length: { _shards.failures: 2 } + - match: { _shards.failures.0.index: "test_exception" } + - match: { _shards.failures.0.shard: 0 } + - match: { _shards.failures.0.reason.caused_by.reason: "[test_exception][0] boom" } + - match: { _shards.failures.1.index: "test_exception" } + - match: { _shards.failures.1.shard: 1 } + - match: { _shards.failures.1.reason.caused_by.reason: "[test_exception][1] boom" }