Skip to content

Commit cfb06e0

Browse files
Prevent field caps from failing due to can match failure (#134134) (#134551)
`FieldCapabilitiesFetcher` performs a can-match in order to quickly return an empty response if no shard can match. However, if can-match fails for some reason, it can cause the field capabilities request to fail. An example of that is when a semantic query is used as filter. can-match will fail as it won't be able to expand the inference results of the query. In cases like that, it makes no sense to fail the field capabilities request. Instead, we should treat can-match as returning `true` to proceed. This change does that by following suit with other callers of can-match. Fixes #116106 (cherry picked from commit 7295189)
1 parent e505aa8 commit cfb06e0

File tree

8 files changed

+119
-83
lines changed

8 files changed

+119
-83
lines changed

docs/changelog/134134.yaml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
pr: 134134
2+
summary: Prevent field caps from failing due to can match failure
3+
area: Search
4+
type: bug
5+
issues:
6+
- 116106

server/src/internalClusterTest/java/org/elasticsearch/search/fieldcaps/CCSFieldCapabilitiesIT.java

Lines changed: 17 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -14,15 +14,12 @@
1414
import org.elasticsearch.action.fieldcaps.FieldCapabilitiesResponse;
1515
import org.elasticsearch.client.internal.Client;
1616
import org.elasticsearch.common.settings.Settings;
17-
import org.elasticsearch.plugins.Plugin;
18-
import org.elasticsearch.search.fieldcaps.FieldCapabilitiesIT.ExceptionOnRewriteQueryBuilder;
19-
import org.elasticsearch.search.fieldcaps.FieldCapabilitiesIT.ExceptionOnRewriteQueryPlugin;
17+
import org.elasticsearch.index.shard.IllegalIndexShardStateException;
2018
import org.elasticsearch.test.AbstractMultiClustersTestCase;
2119
import org.elasticsearch.transport.RemoteTransportException;
2220

23-
import java.util.ArrayList;
21+
import java.io.IOException;
2422
import java.util.Arrays;
25-
import java.util.Collection;
2623
import java.util.List;
2724

2825
import static org.elasticsearch.test.hamcrest.ElasticsearchAssertions.assertAcked;
@@ -43,14 +40,7 @@ protected boolean reuseClusters() {
4340
return false;
4441
}
4542

46-
@Override
47-
protected Collection<Class<? extends Plugin>> nodePlugins(String clusterAlias) {
48-
final List<Class<? extends Plugin>> plugins = new ArrayList<>(super.nodePlugins(clusterAlias));
49-
plugins.add(ExceptionOnRewriteQueryPlugin.class);
50-
return plugins;
51-
}
52-
53-
public void testFailuresFromRemote() {
43+
public void testFailuresFromRemote() throws IOException {
5444
Settings indexSettings = Settings.builder().put("index.number_of_replicas", 0).build();
5545
final Client localClient = client(LOCAL_CLUSTER);
5646
final Client remoteClient = client("remote_cluster");
@@ -68,11 +58,10 @@ public void testFailuresFromRemote() {
6858
FieldCapabilitiesResponse response = client().prepareFieldCaps("*", "remote_cluster:*").setFields("*").get();
6959
assertThat(Arrays.asList(response.getIndices()), containsInAnyOrder(localIndex, "remote_cluster:" + remoteErrorIndex));
7060

71-
// adding an index filter so remote call should fail
72-
response = client().prepareFieldCaps("*", "remote_cluster:*")
73-
.setFields("*")
74-
.setIndexFilter(new ExceptionOnRewriteQueryBuilder())
75-
.get();
61+
// Closed shards will result to index error because shards must be in readable state
62+
FieldCapabilitiesIT.closeShards(cluster("remote_cluster"), remoteErrorIndex);
63+
64+
response = client().prepareFieldCaps("*", "remote_cluster:*").setFields("*").get();
7665
assertThat(response.getIndices()[0], equalTo(localIndex));
7766
assertThat(response.getFailedIndicesCount(), equalTo(1));
7867
FieldCapabilitiesFailure failure = response.getFailures()
@@ -83,25 +72,22 @@ public void testFailuresFromRemote() {
8372
Exception ex = failure.getException();
8473
assertEquals(RemoteTransportException.class, ex.getClass());
8574
Throwable cause = ExceptionsHelper.unwrapCause(ex);
86-
assertEquals(IllegalArgumentException.class, cause.getClass());
87-
assertEquals("I throw because I choose to.", cause.getMessage());
75+
assertEquals(IllegalIndexShardStateException.class, cause.getClass());
76+
assertEquals(
77+
"CurrentState[CLOSED] operations only allowed when shard state is one of [POST_RECOVERY, STARTED]",
78+
cause.getMessage()
79+
);
8880

8981
// if we only query the remote we should get back an exception only
90-
ex = expectThrows(
91-
IllegalArgumentException.class,
92-
client().prepareFieldCaps("remote_cluster:*").setFields("*").setIndexFilter(new ExceptionOnRewriteQueryBuilder())
93-
);
94-
assertEquals("I throw because I choose to.", ex.getMessage());
82+
ex = expectThrows(IllegalIndexShardStateException.class, client().prepareFieldCaps("remote_cluster:*").setFields("*"));
83+
assertEquals("CurrentState[CLOSED] operations only allowed when shard state is one of [POST_RECOVERY, STARTED]", ex.getMessage());
9584

9685
// add an index that doesn't fail to the remote
9786
assertAcked(remoteClient.admin().indices().prepareCreate("okay_remote_index"));
9887
remoteClient.prepareIndex("okay_remote_index").setId("2").setSource("foo", "bar").get();
9988
remoteClient.admin().indices().prepareRefresh("okay_remote_index").get();
10089

101-
response = client().prepareFieldCaps("*", "remote_cluster:*")
102-
.setFields("*")
103-
.setIndexFilter(new ExceptionOnRewriteQueryBuilder())
104-
.get();
90+
response = client().prepareFieldCaps("*", "remote_cluster:*").setFields("*").get();
10591
assertThat(Arrays.asList(response.getIndices()), containsInAnyOrder(localIndex, "remote_cluster:okay_remote_index"));
10692
assertThat(response.getFailedIndicesCount(), equalTo(1));
10793
failure = response.getFailures()
@@ -110,8 +96,8 @@ public void testFailuresFromRemote() {
11096
.findFirst()
11197
.get();
11298
ex = failure.getException();
113-
assertEquals(IllegalArgumentException.class, ex.getClass());
114-
assertEquals("I throw because I choose to.", ex.getMessage());
99+
assertEquals(IllegalIndexShardStateException.class, ex.getClass());
100+
assertEquals("CurrentState[CLOSED] operations only allowed when shard state is one of [POST_RECOVERY, STARTED]", ex.getMessage());
115101
}
116102

117103
public void testFailedToConnectToRemoteCluster() throws Exception {

server/src/internalClusterTest/java/org/elasticsearch/search/fieldcaps/FieldCapabilitiesIT.java

Lines changed: 28 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,7 @@
4747
import org.elasticsearch.index.query.QueryBuilder;
4848
import org.elasticsearch.index.query.QueryBuilders;
4949
import org.elasticsearch.index.query.QueryRewriteContext;
50-
import org.elasticsearch.index.query.SearchExecutionContext;
50+
import org.elasticsearch.index.shard.IllegalIndexShardStateException;
5151
import org.elasticsearch.index.shard.IndexShard;
5252
import org.elasticsearch.index.shard.ShardId;
5353
import org.elasticsearch.indices.IndexClosedException;
@@ -59,6 +59,7 @@
5959
import org.elasticsearch.search.DummyQueryBuilder;
6060
import org.elasticsearch.tasks.TaskInfo;
6161
import org.elasticsearch.test.ESIntegTestCase;
62+
import org.elasticsearch.test.InternalTestCluster;
6263
import org.elasticsearch.test.MockLog;
6364
import org.elasticsearch.test.junit.annotations.TestLogging;
6465
import org.elasticsearch.test.transport.MockTransportService;
@@ -78,6 +79,7 @@
7879
import java.util.HashSet;
7980
import java.util.List;
8081
import java.util.Map;
82+
import java.util.Set;
8183
import java.util.concurrent.CancellationException;
8284
import java.util.concurrent.CountDownLatch;
8385
import java.util.concurrent.TimeUnit;
@@ -87,7 +89,6 @@
8789
import java.util.function.Predicate;
8890
import java.util.stream.IntStream;
8991

90-
import static java.util.Collections.singletonList;
9192
import static org.elasticsearch.action.support.ActionTestUtils.wrapAsRestResponseListener;
9293
import static org.elasticsearch.index.shard.IndexShardTestCase.closeShardNoCheck;
9394
import static org.elasticsearch.test.hamcrest.ElasticsearchAssertions.assertAcked;
@@ -192,7 +193,7 @@ public void setUp() throws Exception {
192193

193194
@Override
194195
protected Collection<Class<? extends Plugin>> nodePlugins() {
195-
return List.of(TestMapperPlugin.class, ExceptionOnRewriteQueryPlugin.class, BlockingOnRewriteQueryPlugin.class);
196+
return List.of(TestMapperPlugin.class, BlockingOnRewriteQueryPlugin.class);
196197
}
197198

198199
@Override
@@ -444,30 +445,36 @@ public void testFieldMetricsAndDimensions() {
444445
assertThat(response.get().get("some_dimension").get("keyword").nonDimensionIndices(), array(equalTo("new_index")));
445446
}
446447

447-
public void testFailures() throws InterruptedException {
448+
public void testFailures() throws IOException {
448449
// in addition to the existing "old_index" and "new_index", create two where the test query throws an error on rewrite
449450
assertAcked(prepareCreate("index1-error"), prepareCreate("index2-error"));
450451
ensureGreen("index1-error", "index2-error");
451-
FieldCapabilitiesResponse response = client().prepareFieldCaps()
452+
453+
// Closed shards will result to index error because shards must be in readable state
454+
closeShards(internalCluster(), "index1-error", "index2-error");
455+
456+
FieldCapabilitiesResponse response = client().prepareFieldCaps("old_index", "new_index", "index1-error", "index2-error")
452457
.setFields("*")
453-
.setIndexFilter(new ExceptionOnRewriteQueryBuilder())
454458
.get();
455459
assertEquals(1, response.getFailures().size());
456460
assertEquals(2, response.getFailedIndicesCount());
457461
assertThat(response.getFailures().get(0).getIndices(), arrayContainingInAnyOrder("index1-error", "index2-error"));
458462
Exception failure = response.getFailures().get(0).getException();
459-
assertEquals(IllegalArgumentException.class, failure.getClass());
460-
assertEquals("I throw because I choose to.", failure.getMessage());
463+
assertEquals(IllegalIndexShardStateException.class, failure.getClass());
464+
assertEquals(
465+
"CurrentState[CLOSED] operations only allowed when shard state is one of [POST_RECOVERY, STARTED]",
466+
failure.getMessage()
467+
);
461468

462469
// the "indices" section should not include failed ones
463470
assertThat(Arrays.asList(response.getIndices()), containsInAnyOrder("old_index", "new_index"));
464471

465472
// if all requested indices failed, we fail the request by throwing the exception
466-
IllegalArgumentException ex = expectThrows(
467-
IllegalArgumentException.class,
468-
client().prepareFieldCaps("index1-error", "index2-error").setFields("*").setIndexFilter(new ExceptionOnRewriteQueryBuilder())
473+
IllegalIndexShardStateException ex = expectThrows(
474+
IllegalIndexShardStateException.class,
475+
client().prepareFieldCaps("index1-error", "index2-error").setFields("*")
469476
);
470-
assertEquals("I throw because I choose to.", ex.getMessage());
477+
assertEquals("CurrentState[CLOSED] operations only allowed when shard state is one of [POST_RECOVERY, STARTED]", ex.getMessage());
471478
}
472479

473480
private void populateTimeRangeIndices() throws Exception {
@@ -830,45 +837,17 @@ private void assertIndices(FieldCapabilitiesResponse response, String... indices
830837
assertArrayEquals(indices, response.getIndices());
831838
}
832839

833-
/**
834-
* Adds an "exception" query that throws on rewrite if the index name contains the string "error"
835-
*/
836-
public static class ExceptionOnRewriteQueryPlugin extends Plugin implements SearchPlugin {
837-
838-
public ExceptionOnRewriteQueryPlugin() {}
839-
840-
@Override
841-
public List<QuerySpec<?>> getQueries() {
842-
return singletonList(
843-
new QuerySpec<>("exception", ExceptionOnRewriteQueryBuilder::new, p -> new ExceptionOnRewriteQueryBuilder())
844-
);
845-
}
846-
}
847-
848-
static class ExceptionOnRewriteQueryBuilder extends DummyQueryBuilder {
849-
850-
public static final String NAME = "exception";
851-
852-
ExceptionOnRewriteQueryBuilder() {}
853-
854-
ExceptionOnRewriteQueryBuilder(StreamInput in) throws IOException {
855-
super(in);
856-
}
857-
858-
@Override
859-
protected QueryBuilder doRewrite(QueryRewriteContext queryRewriteContext) throws IOException {
860-
SearchExecutionContext searchExecutionContext = queryRewriteContext.convertToSearchExecutionContext();
861-
if (searchExecutionContext != null) {
862-
if (searchExecutionContext.indexMatches("*error*")) {
863-
throw new IllegalArgumentException("I throw because I choose to.");
840+
static void closeShards(InternalTestCluster cluster, String... indices) throws IOException {
841+
final Set<String> indicesToClose = Set.of(indices);
842+
for (String node : cluster.getNodeNames()) {
843+
final IndicesService indicesService = cluster.getInstance(IndicesService.class, node);
844+
for (IndexService indexService : indicesService) {
845+
if (indicesToClose.contains(indexService.getMetadata().getIndex().getName())) {
846+
for (IndexShard indexShard : indexService) {
847+
closeShardNoCheck(indexShard);
848+
}
864849
}
865850
}
866-
return this;
867-
}
868-
869-
@Override
870-
public String getWriteableName() {
871-
return NAME;
872851
}
873852
}
874853

server/src/main/java/org/elasticsearch/action/fieldcaps/FieldCapabilitiesFetcher.java

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -239,12 +239,17 @@ private static boolean canMatchShard(
239239
QueryBuilder indexFilter,
240240
long nowInMillis,
241241
SearchExecutionContext searchExecutionContext
242-
) throws IOException {
242+
) {
243243
assert alwaysMatches(indexFilter) == false : "should not be called for always matching [" + indexFilter + "]";
244244
assert nowInMillis != 0L;
245245
ShardSearchRequest searchRequest = new ShardSearchRequest(shardId, nowInMillis, AliasFilter.EMPTY);
246246
searchRequest.source(new SearchSourceBuilder().query(indexFilter));
247-
return SearchService.queryStillMatchesAfterRewrite(searchRequest, searchExecutionContext);
247+
try {
248+
return SearchService.queryStillMatchesAfterRewrite(searchRequest, searchExecutionContext);
249+
} catch (Exception e) {
250+
// treat as if shard is still a potential match
251+
return true;
252+
}
248253
}
249254

250255
private static boolean alwaysMatches(QueryBuilder indexFilter) {

x-pack/plugin/inference/src/main/java/org/elasticsearch/xpack/inference/InferenceFeatures.java

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
import org.elasticsearch.features.NodeFeature;
1212
import org.elasticsearch.xpack.inference.mapper.SemanticInferenceMetadataFieldsMapper;
1313
import org.elasticsearch.xpack.inference.mapper.SemanticTextFieldMapper;
14+
import org.elasticsearch.xpack.inference.queries.SemanticQueryBuilder;
1415
import org.elasticsearch.xpack.inference.rank.textsimilarity.TextSimilarityRankRetrieverBuilder;
1516

1617
import java.util.Set;
@@ -72,7 +73,8 @@ public Set<NodeFeature> getTestFeatures() {
7273
SEMANTIC_TEXT_INDEX_OPTIONS,
7374
COHERE_V2_API,
7475
SEMANTIC_QUERY_REWRITE_INTERCEPTORS_PROPAGATE_BOOST_AND_QUERY_NAME_FIX,
75-
SEMANTIC_TEXT_HIGHLIGHTING_FLAT
76+
SEMANTIC_TEXT_HIGHLIGHTING_FLAT,
77+
SemanticQueryBuilder.SEMANTIC_QUERY_FILTER_FIELD_CAPS_FIX
7678
);
7779
}
7880
}

x-pack/plugin/inference/src/main/java/org/elasticsearch/xpack/inference/queries/SemanticQueryBuilder.java

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
import org.elasticsearch.cluster.metadata.InferenceFieldMetadata;
1717
import org.elasticsearch.common.io.stream.StreamInput;
1818
import org.elasticsearch.common.io.stream.StreamOutput;
19+
import org.elasticsearch.features.NodeFeature;
1920
import org.elasticsearch.index.mapper.MappedFieldType;
2021
import org.elasticsearch.index.query.AbstractQueryBuilder;
2122
import org.elasticsearch.index.query.MatchNoneQueryBuilder;
@@ -52,6 +53,8 @@
5253
public class SemanticQueryBuilder extends AbstractQueryBuilder<SemanticQueryBuilder> {
5354
public static final String NAME = "semantic";
5455

56+
public static final NodeFeature SEMANTIC_QUERY_FILTER_FIELD_CAPS_FIX = new NodeFeature("semantic_query.filter_field_caps_fix");
57+
5558
private static final ParseField FIELD_FIELD = new ParseField("field");
5659
private static final ParseField QUERY_FIELD = new ParseField("query");
5760
private static final ParseField LENIENT_FIELD = new ParseField("lenient");

x-pack/plugin/inference/src/yamlRestTest/resources/rest-api-spec/test/inference/10_semantic_text_field_mapping.yml

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -548,6 +548,33 @@ setup:
548548
- not_exists: fields.dense_field.inference.chunks
549549
- not_exists: fields.dense_field.inference
550550

551+
---
552+
"Field caps with semantic query does not fail":
553+
- requires:
554+
cluster_features: "semantic_query.filter_field_caps_fix"
555+
reason: "fixed bug with semantic query filtering in field_caps (#116106)"
556+
# We need at least one document present to exercise can-match phase
557+
- do:
558+
index:
559+
index: test-index
560+
id: doc_1
561+
body:
562+
sparse_field: "This is a story about a cat and a dog."
563+
refresh: true
564+
565+
- do:
566+
field_caps:
567+
index: test-index
568+
fields: "*"
569+
body:
570+
index_filter:
571+
semantic:
572+
field: "sparse_field"
573+
query: "test"
574+
575+
- match: { indices: [ "test-index" ] }
576+
- match: { fields.sparse_field.text.searchable: true }
577+
551578
---
552579
"Users can set dense vector index options and index documents using those options":
553580
- requires:

x-pack/plugin/inference/src/yamlRestTest/resources/rest-api-spec/test/inference/10_semantic_text_field_mapping_bwc.yml

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -447,6 +447,34 @@ setup:
447447
- not_exists: fields.dense_field.inference.chunks.text
448448
- not_exists: fields.dense_field.inference.chunks
449449
- not_exists: fields.dense_field.inference
450+
451+
---
452+
"Field caps with semantic query does not fail":
453+
- requires:
454+
cluster_features: "semantic_query.filter_field_caps_fix"
455+
reason: "fixed bug with semantic query filtering in field_caps (#116106)"
456+
# We need at least one document present to exercise can-match phase
457+
- do:
458+
index:
459+
index: test-index
460+
id: doc_1
461+
body:
462+
sparse_field: "This is a story about a cat and a dog."
463+
refresh: true
464+
465+
- do:
466+
field_caps:
467+
index: test-index
468+
fields: "*"
469+
body:
470+
index_filter:
471+
semantic:
472+
field: "sparse_field"
473+
query: "test"
474+
475+
- match: { indices: [ "test-index" ] }
476+
- match: { fields.sparse_field.text.searchable: true }
477+
450478
---
451479
"Users can set dense vector index options and index documents using those options":
452480
- requires:

0 commit comments

Comments
 (0)