Skip to content

Commit 96ca13a

Browse files
[8.18] ESQL: ESQL: Fix ReplaceMissingFieldsWithNull (#125764) (#126166)
* Revert changes to Layout.java The change in 80125a4 is a quick fix and allows breaking an invariant of Layout. Revert that. * Simplify ReplaceMissingFieldWithNull When encountering projections, it tries to do the job of field extraction for missing fields by injecting an Eval that creates a literal null with the same name id as the field attribute for the missing field. This is wrong: 1. We only insert an Eval in case that a Project relies on the missing attribute. There could be other plan nodes that rely on the missing attribute. 2. Even for Projects, we only insert an Eval in case we squarely project for the field - in case of aliases (e.g. from RENAME), we do nothing. 3. In case of multiple Projects that use this attribute, we create multiple attributes with the original field attribute's id, causing a wrong Layout. This triggered #121754. * Revive logic for EsRelation instead of Project * Update LocalLogicalPlanOptimizerTests * Update test expectations * Do not prune attributes from EsRelation This can lead to empty output, which leads to the EsRelation being replaced by a LocalRelation with 0 rows. * Add tests + capability * Add comments * [CI] Auto commit changes from spotless * Update docs/changelog/125764.yaml --------- Co-authored-by: elasticsearchmachine <[email protected]>
1 parent 57adbda commit 96ca13a

File tree

9 files changed

+174
-79
lines changed

9 files changed

+174
-79
lines changed

docs/changelog/125764.yaml

+8
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
pr: 125764
2+
summary: Fix `ReplaceMissingFieldsWithNull`
3+
area: ES|QL
4+
type: bug
5+
issues:
6+
- 126036
7+
- 121754
8+
- 126030

x-pack/plugin/esql/qa/testFixtures/src/main/resources/enrich.csv-spec

+18
Original file line numberDiff line numberDiff line change
@@ -644,3 +644,21 @@ FROM airports
644644
abbrev:k | city_name:k | city_location:geo_point | country:k | location:geo_point | name:text | region:text | boundary_wkt_length:i
645645
IDR | Indore | POINT(75.8472 22.7167) | India | POINT(75.8092915005895 22.727749187571) | Devi Ahilyabai Holkar Int'l | Indore City | 231
646646
;
647+
648+
// Regression test for https://github.com/elastic/elasticsearch/issues/126030
649+
// We had wrong layouts from ReplaceMissingFieldsWithNull in case of indices that had relevant fields for the query,
650+
// but were **missing the field we enrich on**.
651+
fieldsInOtherIndicesBug
652+
required_capability: enrich_load
653+
required_capability: fix_replace_missing_field_with_null_duplicate_name_id_in_layout
654+
655+
from *
656+
| keep author.keyword, book_no, scalerank, street, bytes_in, @timestamp, abbrev, city_location, distance, description, birth_date, language_code, intersects, client_ip, event_duration, version
657+
| enrich languages_policy on author.keyword
658+
| sort book_no
659+
| limit 1
660+
;
661+
662+
author.keyword:keyword|book_no:keyword|scalerank:integer|street:keyword|bytes_in:ul|@timestamp:unsupported|abbrev:keyword|city_location:geo_point|distance:double|description:unsupported|birth_date:date|language_code:integer|intersects:boolean|client_ip:unsupported|event_duration:long|version:version|language_name:keyword
663+
Fyodor Dostoevsky |1211 |null |null |null |null |null |null |null |null |null |null |null |null |null |null |null
664+
;

x-pack/plugin/esql/qa/testFixtures/src/main/resources/lookup-join.csv-spec

+22
Original file line numberDiff line numberDiff line change
@@ -1394,6 +1394,10 @@ emp_no:integer | language_code:integer | language_name:keyword
13941394
10093 | 3 | Spanish
13951395
;
13961396

1397+
###############################################
1398+
# Bugfixes
1399+
###############################################
1400+
13971401
multipleBatchesWithSort
13981402
required_capability: join_lookup_v12
13991403
required_capability: remove_redundant_sort
@@ -1480,3 +1484,21 @@ from *
14801484
m:integer |birth_date:datetime
14811485
null |1952-02-27T00:00:00.000Z
14821486
;
1487+
1488+
// Regression test for https://github.com/elastic/elasticsearch/issues/126030
1489+
// We had wrong layouts from ReplaceMissingFieldsWithNull
1490+
1491+
enrichLookupStatsBug
1492+
required_capability: join_lookup_v12
1493+
required_capability: fix_replace_missing_field_with_null_duplicate_name_id_in_layout
1494+
1495+
from *
1496+
| enrich languages_policy on cluster
1497+
| rename languages.byte as language_code
1498+
| lookup join languages_lookup on language_code
1499+
| stats salary_change.long = max(ratings), foo = max(num)
1500+
;
1501+
1502+
salary_change.long:double|foo:long
1503+
5.0 |1698069301543123456
1504+
;

x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/action/EsqlCapabilities.java

+6-1
Original file line numberDiff line numberDiff line change
@@ -699,7 +699,12 @@ public enum Cap {
699699
/**
700700
* Make numberOfChannels consistent with layout in DefaultLayout by removing duplicated ChannelSet.
701701
*/
702-
MAKE_NUMBER_OF_CHANNELS_CONSISTENT_WITH_LAYOUT;
702+
MAKE_NUMBER_OF_CHANNELS_CONSISTENT_WITH_LAYOUT,
703+
704+
/**
705+
* Supercedes {@link Cap#MAKE_NUMBER_OF_CHANNELS_CONSISTENT_WITH_LAYOUT}.
706+
*/
707+
FIX_REPLACE_MISSING_FIELD_WITH_NULL_DUPLICATE_NAME_ID_IN_LAYOUT;
703708

704709
private final boolean enabled;
705710

x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/optimizer/rules/logical/local/ReplaceMissingFieldWithNull.java

+54-50
Original file line numberDiff line numberDiff line change
@@ -10,13 +10,13 @@
1010
import org.elasticsearch.common.util.Maps;
1111
import org.elasticsearch.index.IndexMode;
1212
import org.elasticsearch.xpack.esql.core.expression.Alias;
13+
import org.elasticsearch.xpack.esql.core.expression.Attribute;
1314
import org.elasticsearch.xpack.esql.core.expression.AttributeSet;
1415
import org.elasticsearch.xpack.esql.core.expression.FieldAttribute;
1516
import org.elasticsearch.xpack.esql.core.expression.Literal;
1617
import org.elasticsearch.xpack.esql.core.expression.NamedExpression;
1718
import org.elasticsearch.xpack.esql.core.type.DataType;
1819
import org.elasticsearch.xpack.esql.optimizer.LocalLogicalOptimizerContext;
19-
import org.elasticsearch.xpack.esql.plan.logical.Aggregate;
2020
import org.elasticsearch.xpack.esql.plan.logical.EsRelation;
2121
import org.elasticsearch.xpack.esql.plan.logical.Eval;
2222
import org.elasticsearch.xpack.esql.plan.logical.Filter;
@@ -25,14 +25,12 @@
2525
import org.elasticsearch.xpack.esql.plan.logical.Project;
2626
import org.elasticsearch.xpack.esql.plan.logical.RegexExtract;
2727
import org.elasticsearch.xpack.esql.plan.logical.TopN;
28-
import org.elasticsearch.xpack.esql.plan.logical.join.Join;
29-
import org.elasticsearch.xpack.esql.plan.logical.local.LocalRelation;
3028
import org.elasticsearch.xpack.esql.rule.ParameterizedRule;
31-
import org.elasticsearch.xpack.esql.stats.SearchStats;
3229

3330
import java.util.ArrayList;
3431
import java.util.List;
3532
import java.util.Map;
33+
import java.util.function.Predicate;
3634

3735
/**
3836
* Look for any fields used in the plan that are missing locally and replace them with null.
@@ -42,79 +40,85 @@ public class ReplaceMissingFieldWithNull extends ParameterizedRule<LogicalPlan,
4240

4341
@Override
4442
public LogicalPlan apply(LogicalPlan plan, LocalLogicalOptimizerContext localLogicalOptimizerContext) {
43+
// Fields from lookup indices don't need to be present on the node, and our search stats don't include them, anyway. Ignore them.
4544
AttributeSet lookupFields = new AttributeSet();
4645
plan.forEachUp(EsRelation.class, esRelation -> {
46+
// Looking only for indices in LOOKUP mode is correct: during parsing, we assign the expected mode and even if a lookup index
47+
// is used in the FROM command, it will not be marked with LOOKUP mode there - but STANDARD.
48+
// It seems like we could instead just look for JOINs and walk down their right hand side to find lookup fields - but this does
49+
// not work as this rule also gets called just on the right hand side of a JOIN, which means that we don't always know that
50+
// we're inside the right (or left) branch of a JOIN node. (See PlannerUtils.localPlan - this looks for FragmentExecs and
51+
// performs local logical optimization of the fragments; the right hand side of a LookupJoinExec can be a FragmentExec.)
4752
if (esRelation.indexMode() == IndexMode.LOOKUP) {
4853
lookupFields.addAll(esRelation.output());
4954
}
5055
});
5156

52-
return plan.transformUp(p -> missingToNull(p, localLogicalOptimizerContext.searchStats(), lookupFields));
53-
}
54-
55-
private LogicalPlan missingToNull(LogicalPlan plan, SearchStats stats, AttributeSet lookupFields) {
56-
if (plan instanceof EsRelation || plan instanceof LocalRelation) {
57-
return plan;
58-
}
57+
// Do not use the attribute name, this can deviate from the field name for union types; use fieldName() instead.
58+
// Also retain fields from lookup indices because we do not have stats for these.
59+
Predicate<FieldAttribute> shouldBeRetained = f -> (localLogicalOptimizerContext.searchStats().exists(f.fieldName())
60+
|| lookupFields.contains(f));
5961

60-
if (plan instanceof Aggregate a) {
61-
// don't do anything (for now)
62-
return a;
63-
}
64-
// keep the aliased name
65-
else if (plan instanceof Project project) {
66-
var projections = project.projections();
67-
List<NamedExpression> newProjections = new ArrayList<>(projections.size());
68-
Map<DataType, Alias> nullLiteral = Maps.newLinkedHashMapWithExpectedSize(DataType.types().size());
69-
AttributeSet joinAttributes = joinAttributes(project);
62+
return plan.transformUp(p -> missingToNull(p, shouldBeRetained));
63+
}
7064

71-
for (NamedExpression projection : projections) {
72-
// Do not use the attribute name, this can deviate from the field name for union types.
73-
if (projection instanceof FieldAttribute f && stats.exists(f.fieldName()) == false && joinAttributes.contains(f) == false) {
74-
// TODO: Should do a searchStats lookup for join attributes instead of just ignoring them here
75-
// See TransportSearchShardsAction
65+
private LogicalPlan missingToNull(LogicalPlan plan, Predicate<FieldAttribute> shouldBeRetained) {
66+
if (plan instanceof EsRelation relation) {
67+
// Remove missing fields from the EsRelation because this is not where we will obtain them from; replace them by an Eval right
68+
// after, instead. This allows us to safely re-use the attribute ids of the corresponding FieldAttributes.
69+
// This means that an EsRelation[field1, field2, field3] where field1 and field 3 are missing will be replaced by
70+
// Project[field1, field2, field3] <- keeps the ordering intact
71+
// \_Eval[field1 = null, field3 = null]
72+
// \_EsRelation[field2]
73+
List<Attribute> relationOutput = relation.output();
74+
Map<DataType, Alias> nullLiterals = Maps.newLinkedHashMapWithExpectedSize(DataType.types().size());
75+
List<NamedExpression> newProjections = new ArrayList<>(relationOutput.size());
76+
for (int i = 0, size = relationOutput.size(); i < size; i++) {
77+
Attribute attr = relationOutput.get(i);
78+
NamedExpression projection;
79+
if (attr instanceof FieldAttribute f && (shouldBeRetained.test(f) == false)) {
7680
DataType dt = f.dataType();
77-
Alias nullAlias = nullLiteral.get(f.dataType());
81+
Alias nullAlias = nullLiterals.get(dt);
7882
// save the first field as null (per datatype)
7983
if (nullAlias == null) {
84+
// Keep the same id so downstream query plans don't need updating
85+
// NOTE: THIS IS BRITTLE AND CAN LEAD TO BUGS.
86+
// In case some optimizer rule or so inserts a plan node that requires the field BEFORE the Eval that we're adding
87+
// on top of the EsRelation, this can trigger a field extraction in the physical optimizer phase, causing wrong
88+
// layouts due to a duplicate name id.
89+
// If someone reaches here AGAIN when debugging e.g. ClassCastExceptions NPEs from wrong layouts, we should probably
90+
// give up on this approach and instead insert EvalExecs in InsertFieldExtraction.
8091
Alias alias = new Alias(f.source(), f.name(), Literal.of(f, null), f.id());
81-
nullLiteral.put(dt, alias);
92+
nullLiterals.put(dt, alias);
8293
projection = alias.toAttribute();
8394
}
84-
// otherwise point to it
95+
// otherwise point to it since this avoids creating field copies
8596
else {
86-
// since avoids creating field copies
8797
projection = new Alias(f.source(), f.name(), nullAlias.toAttribute(), f.id());
8898
}
99+
} else {
100+
projection = attr;
89101
}
90-
91102
newProjections.add(projection);
92103
}
93-
// add the first found field as null
94-
if (nullLiteral.size() > 0) {
95-
plan = new Eval(project.source(), project.child(), new ArrayList<>(nullLiteral.values()));
96-
plan = new Project(project.source(), plan, newProjections);
104+
105+
if (nullLiterals.size() == 0) {
106+
return plan;
97107
}
98-
} else if (plan instanceof Eval
108+
109+
Eval eval = new Eval(plan.source(), relation, new ArrayList<>(nullLiterals.values()));
110+
// This projection is redundant if there's another projection downstream (and no commands depend on the order until we hit it).
111+
return new Project(plan.source(), eval, newProjections);
112+
}
113+
114+
if (plan instanceof Eval
99115
|| plan instanceof Filter
100116
|| plan instanceof OrderBy
101117
|| plan instanceof RegexExtract
102118
|| plan instanceof TopN) {
103-
plan = plan.transformExpressionsOnlyUp(
104-
FieldAttribute.class,
105-
// Do not use the attribute name, this can deviate from the field name for union types.
106-
// Also skip fields from lookup indices because we do not have stats for these.
107-
// TODO: We do have stats for lookup indices in case they are being used in the FROM clause; this can be refined.
108-
f -> stats.exists(f.fieldName()) || lookupFields.contains(f) ? f : Literal.of(f, null)
109-
);
110-
}
119+
return plan.transformExpressionsOnlyUp(FieldAttribute.class, f -> shouldBeRetained.test(f) ? f : Literal.of(f, null));
120+
}
111121

112122
return plan;
113123
}
114-
115-
private AttributeSet joinAttributes(Project project) {
116-
var attributes = new AttributeSet();
117-
project.forEachDown(Join.class, j -> j.right().forEachDown(EsRelation.class, p -> attributes.addAll(p.output())));
118-
return attributes;
119-
}
120124
}

x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/plan/logical/EsRelation.java

+4
Original file line numberDiff line numberDiff line change
@@ -226,4 +226,8 @@ public static void writeIndexMode(StreamOutput out, IndexMode indexMode) throws
226226
throw new IllegalStateException("not ready to support index mode [" + indexMode + "]");
227227
}
228228
}
229+
230+
public EsRelation withAttributes(List<Attribute> newAttributes) {
231+
return new EsRelation(source(), indexPattern, indexMode, indexNameWithModes, newAttributes);
232+
}
229233
}

x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/planner/Layout.java

+5-18
Original file line numberDiff line numberDiff line change
@@ -107,26 +107,13 @@ public Layout build() {
107107
Map<NameId, ChannelAndType> layout = new HashMap<>();
108108
int numberOfChannels = 0;
109109
for (ChannelSet set : channels) {
110-
boolean createNewChannel = true;
111-
int channel = 0;
110+
int channel = numberOfChannels++;
112111
for (NameId id : set.nameIds) {
113-
if (layout.containsKey(id)) {
114-
// If a NameId already exists in the map, do not increase the numberOfChannels, it can cause inverse() to create
115-
// a null in the list of channels, and NullPointerException when build() is called.
116-
// TODO avoid adding duplicated attributes with the same id in the plan, ReplaceMissingFieldWithNull may add nulls
117-
// with the same ids as the missing field ids.
118-
continue;
119-
}
120-
if (createNewChannel) {
121-
channel = numberOfChannels++;
122-
createNewChannel = false;
123-
}
112+
// Duplicate name ids would mean that have 2 channels that are declared under the same id. That makes no sense - which
113+
// channel should subsequent operators use, then, when they want to refer to this id?
114+
assert (layout.containsKey(id) == false) : "Duplicate name ids are not allowed in layouts";
124115
ChannelAndType next = new ChannelAndType(channel, set.type);
125-
ChannelAndType prev = layout.put(id, next);
126-
// Do allow multiple name to point to the same channel - see https://github.com/elastic/elasticsearch/pull/100238
127-
// if (prev != null) {
128-
// throw new IllegalArgumentException("Name [" + id + "] is on two channels [" + prev + "] and [" + next + "]");
129-
// }
116+
layout.put(id, next);
130117
}
131118
}
132119
return new DefaultLayout(Collections.unmodifiableMap(layout), numberOfChannels);

0 commit comments

Comments
 (0)