Skip to content

Commit 3cd8554

Browse files
authored
ESQL: Don't mutate the BoolQueryBuilder in plan (backport of elastic#111519) (elastic#112679)
* ESQL: Don't mutate the BoolQueryBuilder in plan (elastic#111519) This modifies ESQL's `QueryBuilder` merging to stop it from mutating `BoolQueryBuilder`s in place. It's more efficient when you can do that, but only marginally so. Instead we create a shallow copy of the same builder and mutate *that*. That lines up much better with the plan being immutable objects. It should be! The resulting queries that ESQL sends to lucene are the same here - we just modify how we build them. This should stop a fun class of bugs that can come up when we mutate the query builders in multiple threads - because we *do* replan the query in multiple threads. That's fine. So long as we shallow copy, like we do in this PR. * Update docs/changelog/112679.yaml * Delete docs/changelog/112679.yaml
1 parent 781e197 commit 3cd8554

File tree

14 files changed

+377
-35
lines changed

14 files changed

+377
-35
lines changed

docs/changelog/111519.yaml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
pr: 111519
2+
summary: "ESQL: Don't mutate the `BoolQueryBuilder` in plan"
3+
area: ES|QL
4+
type: bug
5+
issues: []

server/src/main/java/org/elasticsearch/index/query/BoolQueryBuilder.java

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -410,4 +410,28 @@ private static boolean rewriteClauses(
410410
public TransportVersion getMinimalSupportedVersion() {
411411
return TransportVersions.ZERO;
412412
}
413+
414+
/**
415+
* Create a new builder with the same clauses but modification of
416+
* the builder won't modify the original. Modifications of any
417+
* of the copy's clauses will modify the original. Don't so that.
418+
*/
419+
public BoolQueryBuilder shallowCopy() {
420+
BoolQueryBuilder copy = new BoolQueryBuilder();
421+
copy.adjustPureNegative = adjustPureNegative;
422+
copy.minimumShouldMatch = minimumShouldMatch;
423+
for (QueryBuilder q : mustClauses) {
424+
copy.must(q);
425+
}
426+
for (QueryBuilder q : mustNotClauses) {
427+
copy.mustNot(q);
428+
}
429+
for (QueryBuilder q : filterClauses) {
430+
copy.filter(q);
431+
}
432+
for (QueryBuilder q : shouldClauses) {
433+
copy.should(q);
434+
}
435+
return copy;
436+
}
413437
}

server/src/test/java/org/elasticsearch/index/query/BoolQueryBuilderTests.java

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,9 @@
3232
import static org.elasticsearch.index.query.QueryBuilders.termQuery;
3333
import static org.hamcrest.CoreMatchers.containsString;
3434
import static org.hamcrest.CoreMatchers.equalTo;
35+
import static org.hamcrest.CoreMatchers.hasItem;
3536
import static org.hamcrest.CoreMatchers.instanceOf;
37+
import static org.hamcrest.Matchers.not;
3638

3739
public class BoolQueryBuilderTests extends AbstractQueryTestCase<BoolQueryBuilder> {
3840
@Override
@@ -463,4 +465,39 @@ public void testMustRewrite() throws IOException {
463465
IllegalStateException e = expectThrows(IllegalStateException.class, () -> boolQuery.toQuery(context));
464466
assertEquals("Rewrite first", e.getMessage());
465467
}
468+
469+
public void testShallowCopy() {
470+
BoolQueryBuilder orig = createTestQueryBuilder();
471+
BoolQueryBuilder shallowCopy = orig.shallowCopy();
472+
assertThat(shallowCopy.adjustPureNegative(), equalTo(orig.adjustPureNegative()));
473+
assertThat(shallowCopy.minimumShouldMatch(), equalTo(orig.minimumShouldMatch()));
474+
assertThat(shallowCopy.must(), equalTo(orig.must()));
475+
assertThat(shallowCopy.mustNot(), equalTo(orig.mustNot()));
476+
assertThat(shallowCopy.should(), equalTo(orig.should()));
477+
assertThat(shallowCopy.filter(), equalTo(orig.filter()));
478+
479+
QueryBuilder b = new MatchQueryBuilder("foo", "bar");
480+
switch (between(0, 3)) {
481+
case 0 -> {
482+
shallowCopy.must(b);
483+
assertThat(shallowCopy.must(), hasItem(b));
484+
assertThat(orig.must(), not(hasItem(b)));
485+
}
486+
case 1 -> {
487+
shallowCopy.mustNot(b);
488+
assertThat(shallowCopy.mustNot(), hasItem(b));
489+
assertThat(orig.mustNot(), not(hasItem(b)));
490+
}
491+
case 2 -> {
492+
shallowCopy.should(b);
493+
assertThat(shallowCopy.should(), hasItem(b));
494+
assertThat(orig.should(), not(hasItem(b)));
495+
}
496+
case 3 -> {
497+
shallowCopy.filter(b);
498+
assertThat(shallowCopy.filter(), hasItem(b));
499+
assertThat(orig.filter(), not(hasItem(b)));
500+
}
501+
}
502+
}
466503
}

x-pack/plugin/esql-core/src/main/java/org/elasticsearch/xpack/esql/core/util/Queries.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,7 @@ public static QueryBuilder combine(Clause clause, List<QueryBuilder> queries) {
5050
if (firstQuery == null) {
5151
firstQuery = query;
5252
if (firstQuery instanceof BoolQueryBuilder bqb) {
53-
bool = bqb;
53+
bool = bqb.shallowCopy();
5454
}
5555
}
5656
// at least two entries, start copying

x-pack/plugin/esql-core/src/test/java/org/elasticsearch/xpack/esql/core/util/QueriesTests.java

Lines changed: 25 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414

1515
import static java.util.Arrays.asList;
1616
import static org.hamcrest.Matchers.everyItem;
17+
import static org.hamcrest.Matchers.hasItem;
1718
import static org.hamcrest.Matchers.in;
1819
import static org.hamcrest.Matchers.instanceOf;
1920
import static org.hamcrest.Matchers.sameInstance;
@@ -92,13 +93,27 @@ public void testCombineBoolQueries() {
9293

9394
assertThat(combination, instanceOf(BoolQueryBuilder.class));
9495
var bool = (BoolQueryBuilder) combination;
96+
assertBoolQueryMerge(queries, bool, clause);
97+
}
9598

96-
var clauseList = clause.innerQueries.apply(bool);
99+
private void assertBoolQueryMerge(QueryBuilder[] queries, BoolQueryBuilder bool, Queries.Clause clause) {
100+
BoolQueryBuilder first = (BoolQueryBuilder) queries[0];
101+
for (QueryBuilder b : first.must()) {
102+
assertThat(bool.must(), hasItem(b));
103+
}
104+
for (QueryBuilder b : first.mustNot()) {
105+
assertThat(bool.mustNot(), hasItem(b));
106+
}
107+
for (QueryBuilder b : first.should()) {
108+
assertThat(bool.should(), hasItem(b));
109+
}
110+
for (QueryBuilder b : first.filter()) {
111+
assertThat(bool.filter(), hasItem(b));
112+
}
97113

98-
for (QueryBuilder query : queries) {
99-
if (query != bool) {
100-
assertThat(query, in(clauseList));
101-
}
114+
var clauseList = clause.innerQueries.apply(bool);
115+
for (int i = 1; i < queries.length; i++) {
116+
assertThat(queries[i], in(clauseList));
102117
}
103118
}
104119

@@ -118,10 +133,11 @@ public void testCombineMixedBoolAndNonBoolQueries() {
118133
assertThat(combination, instanceOf(BoolQueryBuilder.class));
119134
var bool = (BoolQueryBuilder) combination;
120135

121-
var clauseList = clause.innerQueries.apply(bool);
122-
123-
for (QueryBuilder query : queries) {
124-
if (query != bool) {
136+
if (queries[0] instanceof BoolQueryBuilder) {
137+
assertBoolQueryMerge(queries, bool, clause);
138+
} else {
139+
var clauseList = clause.innerQueries.apply(bool);
140+
for (QueryBuilder query : queries) {
125141
assertThat(query, in(clauseList));
126142
}
127143
}

x-pack/plugin/esql/qa/server/multi-node/build.gradle

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,9 @@ dependencies {
88
javaRestTestImplementation project(xpackModule('esql:qa:testFixtures'))
99
javaRestTestImplementation project(xpackModule('esql:qa:server'))
1010
yamlRestTestImplementation project(xpackModule('esql:qa:server'))
11+
12+
clusterPlugins project(':plugins:mapper-size')
13+
clusterPlugins project(':plugins:mapper-murmur3')
1114
}
1215

1316
GradleUtils.extendSourceSet(project, "javaRestTest", "yamlRestTest")

x-pack/plugin/esql/qa/server/multi-node/src/javaRestTest/java/org/elasticsearch/xpack/esql/qa/multi_node/Clusters.java

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,15 +8,17 @@
88
package org.elasticsearch.xpack.esql.qa.multi_node;
99

1010
import org.elasticsearch.test.cluster.ElasticsearchCluster;
11+
import org.elasticsearch.test.cluster.local.LocalClusterConfigProvider;
1112
import org.elasticsearch.test.cluster.local.distribution.DistributionType;
1213

1314
public class Clusters {
14-
public static ElasticsearchCluster testCluster() {
15+
public static ElasticsearchCluster testCluster(LocalClusterConfigProvider configProvider) {
1516
return ElasticsearchCluster.local()
1617
.distribution(DistributionType.DEFAULT)
1718
.nodes(2)
1819
.setting("xpack.security.enabled", "false")
1920
.setting("xpack.license.self_generated.type", "trial")
21+
.apply(() -> configProvider)
2022
.build();
2123
}
2224
}

x-pack/plugin/esql/qa/server/multi-node/src/javaRestTest/java/org/elasticsearch/xpack/esql/qa/multi_node/EsqlSpecIT.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@
1414

1515
public class EsqlSpecIT extends EsqlSpecTestCase {
1616
@ClassRule
17-
public static ElasticsearchCluster cluster = Clusters.testCluster();
17+
public static ElasticsearchCluster cluster = Clusters.testCluster(spec -> {});
1818

1919
@Override
2020
protected String getTestRestCluster() {

x-pack/plugin/esql/qa/server/multi-node/src/javaRestTest/java/org/elasticsearch/xpack/esql/qa/multi_node/FieldExtractorIT.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@
1717
@ThreadLeakFilters(filters = TestClustersThreadFilter.class)
1818
public class FieldExtractorIT extends FieldExtractorTestCase {
1919
@ClassRule
20-
public static ElasticsearchCluster cluster = Clusters.testCluster();
20+
public static ElasticsearchCluster cluster = Clusters.testCluster(spec -> {});
2121

2222
@Override
2323
protected String getTestRestCluster() {
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
/*
2+
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
3+
* or more contributor license agreements. Licensed under the Elastic License
4+
* 2.0; you may not use this file except in compliance with the Elastic License
5+
* 2.0.
6+
*/
7+
8+
package org.elasticsearch.xpack.esql.qa.multi_node;
9+
10+
import com.carrotsearch.randomizedtesting.annotations.ParametersFactory;
11+
12+
import org.elasticsearch.test.cluster.ElasticsearchCluster;
13+
import org.elasticsearch.xpack.esql.qa.rest.RestEsqlTestCase;
14+
import org.junit.ClassRule;
15+
16+
import java.util.Arrays;
17+
import java.util.List;
18+
19+
public class RestEsqlIT extends RestEsqlTestCase {
20+
@ClassRule
21+
public static ElasticsearchCluster cluster = Clusters.testCluster(
22+
specBuilder -> specBuilder.plugin("mapper-size").plugin("mapper-murmur3")
23+
);
24+
25+
@Override
26+
protected String getTestRestCluster() {
27+
return cluster.getHttpAddresses();
28+
}
29+
30+
@ParametersFactory(argumentFormatting = "%1s")
31+
public static List<Object[]> modes() {
32+
return Arrays.stream(Mode.values()).map(m -> new Object[] { m }).toList();
33+
}
34+
35+
public RestEsqlIT(Mode mode) {
36+
super(mode);
37+
}
38+
}

0 commit comments

Comments
 (0)