Skip to content

ESQL: remove incorrect inline stats pruning#141056

Merged
elasticsearchmachine merged 14 commits intoelastic:mainfrom
astefan:139359_fix_take2
Feb 12, 2026
Merged

ESQL: remove incorrect inline stats pruning#141056
elasticsearchmachine merged 14 commits intoelastic:mainfrom
astefan:139359_fix_take2

Conversation

@astefan
Copy link
Contributor

@astefan astefan commented Jan 21, 2026

Removes incorrect pruning in inline stats that lacked taking into consideration the fact that groupings can be multi-valued.
Fixes #139359 (comment) (grouping by an mv fields can lead to inconsistencies in certain queries)
Fixes #139359
Fixes #140757

This also extracts a bunch of tests methods from the huge LogicalPlanOptimizerTests and moves them to a new test class - PruneColumnsTests which should include most of the tests that have in the spotlight the PruneColumns optimization rule.

@elasticsearchmachine
Copy link
Collaborator

Hi @astefan, I've created a changelog YAML for you.

@astefan astefan added v9.3.0 auto-backport Automatically create backport pull requests when merged labels Jan 30, 2026
@astefan astefan marked this pull request as ready for review January 30, 2026 10:34
@elasticsearchmachine elasticsearchmachine added the Team:Analytics Meta label for analytical engine team (ESQL/Aggs/Geo) label Jan 30, 2026
@elasticsearchmachine
Copy link
Collaborator

Pinging @elastic/es-analytical-engine (Team:Analytics)

@astefan astefan requested review from alex-spies and bpintea January 30, 2026 10:35
347.99
;

inlineStatsGroupByNull_PruneDifferentInlineStats
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This test covers both the bug fix here but also it's extended following #140027 merge


public class PruneColumnsTests extends AbstractLogicalPlanOptimizerTests {

public void testPruneUnusedEval() {
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The start of this file contains already existent tests from LogicalPlanOptimizerTests. There is another comment down below with the new tests pertaining to this PR fix only.

}
}

/*
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Tests relevant for this PR's fix.


// Left branch: Project with avg, decades, idecades
var leftProject = as(inlineJoin.left(), Project.class);
assertThat(Expressions.names(leftProject.projections()), is(List.of("avg", "decades")));
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Here idecades has been projected away from the original version of this test method.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The comment could be updated too.

Copy link
Contributor

@alex-spies alex-spies left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks a lot @astefan !

As discussed before, I can't currently give this a deep review, but the approach looks good to me and I'm happy about the added spec tests :)

var right = pruneColumns(ij.right(), used, true);
if (right.output().isEmpty() || isLocalEmptyRelation(right)) {

if (right.outputSet().subtract(ij.references()).isEmpty() || isLocalEmptyRelation(right)) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: I'd add a comment: "ij.references() are the join keys - the inline join doesn't add new columns. Since it preserves rows, it doesn't do anything and can be pruned."

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would this only happen when we are shadowing variables in the inline stats outside?

Example:

FROM employees
| KEEP emp_no, salary, birth_date
| EVAL orig_emp_no = emp_no
| INLINE STATS emp_no = MAX(birth_date) BY salary
| EVAL emp_no = birth_date

Or are there more cases?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Shadowing and DROP'ping. Maybe @astefan is aware of more cases.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is about all use cases where what inline stats creates (meaning the aggs themselves) are "missing" after the inline stats command. This can be drop, keep [other columns and not the ones created by inline stats], eval, another stats (maybe others).

The other scenario (|| isLocalEmptyRelation(right)) comes from scenarios where the output of the right hand side is completely empty, meaning we don't need anything from what inline stats is using. In your specific query, for example, if I would do drop emp_no, salary then the inline stats itself can be pruned entirely. And I think that PruneEmptyPlans does that by creating a LocalRelation with an EMPTY supplier and the inline stats command itself gets to be pruned here.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The only subtlety is that even a pruned inline stats affects the column order if there is a BY clause and the grouping does not get dropped:

FROM employees
| KEEP emp_no, languages, salary
| INLINE STATS s = max(salary) BY languages
| DROP s

-> output:
emp_no | salary | languages

The INLINE STATS moves the groupings to the right hand side, compared to just

FROM employees
| KEEP emp_no, languages, salary
* \_Filter[salary{f}#16 < 10000[INTEGER] AND salary{f}#16 > 10000[INTEGER]]
* \_EsRelation[employees][_meta_field{f}#17, emp_no{f}#11, first_name{f}#12, ..]
*/
public void testPushDown_ImpossibleFilter_PastInlineJoin() {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Btw, @GalLalouche 's golden tests can be used now - removing the toil required for writing/updating all the assertions below. It's a great time saver :)

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I am an adept of seeing and checking what I see in the same file :-).

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

With #141082, the query that is being checked also gets put into the dir with the expected outcome, so you can just zip through the files during review. Worked great for me.

Comment on lines 1382 to 1383
| INLINE STATS salary = AVG(salary) BY salary
| WHERE salary > 10000
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That's a sneaky one. Nice test!

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Adding here the reproducer from the duplicated PR, as it comes from a different machanism (constant false WHERE in agg, ReplaceStatsFilteredOrNullAggWithEval):

FROM employees
| INLINE STATS x = MAX(salary) WHERE false, c = COUNT(*) BY emp_no
| KEEP x, c
| SORT x, c
| LIMIT 3
;

x:integer | c:long
null      | 1
null      | 1
null      | 1
;
Copy link
Contributor

@bpintea bpintea left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What a nice simplification! Thanks Andrei.

Copy link
Contributor

@ncordon ncordon left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I left some questions, nothing major, it's mainly for my own understanding.

The change makes sense from my viewpoint because of two reasons: we are having into account variables that get used inside the right part of inline join that we didn't before and we are being less aggressive when pruning.

}

/*
* https://github.com/elastic/elasticsearch/issues/138283
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we delete this line?

var right = pruneColumns(ij.right(), used, true);
if (right.output().isEmpty() || isLocalEmptyRelation(right)) {

if (right.outputSet().subtract(ij.references()).isEmpty() || isLocalEmptyRelation(right)) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would this only happen when we are shadowing variables in the inline stats outside?

Example:

FROM employees
| KEEP emp_no, salary, birth_date
| EVAL orig_emp_no = emp_no
| INLINE STATS emp_no = MAX(birth_date) BY salary
| EVAL emp_no = birth_date

Or are there more cases?

* | \_EsRelation[union_types_index*][!first_name, id{f}#11, !languages, !last_name, !sal..]
* \_EsRelation[languages_lookup][LOOKUP][language_code{f}#13]
*/
public void testPruneColumnsInProject_WithLookupJoin() {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we add some csv specs for these as well?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The csv spec tests are not relevant for the functionality of this PR change when it comes to Project pruning of union types related fields (see more here). Those fields were already not present in the output of these types of queries. What this PR does is something "invisible" in the output, meaning fields like !first_name, !languages, !last_name are not present in the Project sitting atop of EsRelation on the right-hand side of the LookupJoin node. Before this PR that Project had wrongly projecting those fields as well.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If you look closely at these tests, they are a bit different from the rest of tests regarding LogicalPlanOptimizer: they are directly calling PruneColumns rule (vs. running the full list of optimization rules batches).

Comment on lines -975 to +974
var eval = as(extract.child(), EvalExec.class);
var source = as(eval.child(), EsQueryExec.class);
var source = as(extract.child(), EsQueryExec.class);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why has this changed?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Specifically I don't understand why the INLINE STATS prunning or the Project prunning affect this test

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Because _EvalExec[[null[INTEGER] AS languages#21]] is not needed anymore, since it is not used afterwards. languages#21 is projected with \_ProjectExec[[emp_no{f}#18, languages{r}#21 AS language_code#7, first_name{f}#19]] but afterwards, languages#21 and/or language_code#7 is not re-used anywhere else. It is overiden by language_code{r}#12 that grok creates.

@astefan astefan added the auto-merge-without-approval Automatically merge pull request when CI checks pass (NB doesn't wait for reviews!) label Feb 11, 2026
@elasticsearchmachine elasticsearchmachine merged commit 244538b into elastic:main Feb 12, 2026
35 checks passed
@astefan astefan deleted the 139359_fix_take2 branch February 12, 2026 12:38
@elasticsearchmachine
Copy link
Collaborator

💔 Backport failed

Status Branch Result
9.3 Commit could not be cherrypicked due to conflicts

You can use sqren/backport to manually backport by running backport --upstream elastic/elasticsearch --pr 141056

astefan added a commit to astefan/elasticsearch that referenced this pull request Feb 12, 2026
Removes incorrect pruning in `inline stats` that lacked taking into
consideration the fact that groupings can be multi-valued. Fixes
elastic#139359 (comment)
(grouping by an mv fields can lead to inconsistencies in certain
queries) Fixes elastic#139359
Fixes elastic#140757

This also extracts a bunch of tests methods from the huge
`LogicalPlanOptimizerTests` and moves them to a new test class -
`PruneColumnsTests` which should include most of the tests that have in
the spotlight the `PruneColumns` optimization rule.

(cherry picked from commit 244538b)
elasticsearchmachine pushed a commit that referenced this pull request Feb 13, 2026
* ESQL: remove incorrect inline stats pruning (#141056)

Removes incorrect pruning in `inline stats` that lacked taking into
consideration the fact that groupings can be multi-valued. Fixes
#139359 (comment)
(grouping by an mv fields can lead to inconsistencies in certain
queries) Fixes #139359
Fixes #140757

This also extracts a bunch of tests methods from the huge
`LogicalPlanOptimizerTests` and moves them to a new test class -
`PruneColumnsTests` which should include most of the tests that have in
the spotlight the `PruneColumns` optimization rule.

(cherry picked from commit 244538b)

* Update

* Adapt the fix to 9.3.0
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

:Analytics/ES|QL AKA ESQL auto-backport Automatically create backport pull requests when merged auto-merge-without-approval Automatically merge pull request when CI checks pass (NB doesn't wait for reviews!) >bug Team:Analytics Meta label for analytical engine team (ESQL/Aggs/Geo) v9.3.1 v9.4.0

6 participants