Skip to content

Conversation

@ncordon
Copy link
Contributor

@ncordon ncordon commented Nov 3, 2025

Addresses #133992 and #136598, partially.

Missing from this pr that we still need to do: at the moment the runtime part tries to avoid double computations, resulting in exceptions if the plan is correct but not optimal. In other words, queries like:

from airports 
rename scalerank AS x 
stats  a = count(x), b = count(x) + count(x), c = count_distinct(x)

should had never failed at runtime even if the plan was not optimal for repeated aggregations.

@elasticsearchmachine elasticsearchmachine added needs:triage Requires assignment of a team area label v9.3.0 labels Nov 3, 2025
@ncordon ncordon added :Analytics/ES|QL AKA ESQL Team:ES|QL and removed needs:triage Requires assignment of a team area label labels Nov 3, 2025
@elasticsearchmachine
Copy link
Collaborator

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

@elasticsearchmachine elasticsearchmachine added the Team:Analytics Meta label for analytical engine team (ESQL/Aggs/Geo) label Nov 3, 2025
@ncordon ncordon added the >bug label Nov 3, 2025
@elasticsearchmachine
Copy link
Collaborator

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

@astefan astefan requested a review from alex-spies November 3, 2025 12:18
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.

Heya, no review yet, except for a very quick first glance.

This also fixes #136598, nice! Let's note that down in the PR description so the other issue gets auto-closed on merge, as well.

That said, I don't think this addresses problems like

| stats median(foo), percentile(foo, 50), count_distinct(foo)

because the substitution median(foo) -> percentile(foo, 50) happens after ReplaceAggregateAggExpressionWithEval, right?

The PR description says this partially addresses #133992; what else is not yet addressed?

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.

Heya, could you please add some tests to the logical plan optimizer tests that demonstrate what the plans for some relevant STATS queries will look like? Actually, we should create a test class similar to ReplaceStatsFilteredAggWithEvalTests; there are probably some tests in LogicalPlanOptimizerTests that could be moved there, too, but that's optional.

I'm interested in seeing a bunch of cases, esp. ones with a BY clause and with per-agg-function WHERE clauses. We seem to have little coverage of per-agg-function WHERE clauses that are different from their canonicalization (otherwise I'd have expected some test failures).

Other than that, I think the approach in the fix is good! Clearly, when deduplicating aggs in expressions, we need to be consistent between a single agg function and an expression with agg functions within it.

if (alias == null) {
// create synthetic alias ove the found agg function
alias = new Alias(af.source(), syntheticName(canonical, child, counter[0]++), canonical, null, true);
alias = new Alias(af.source(), syntheticName(canonical, child, counter[0]++), af, null, true);
Copy link
Contributor

Choose a reason for hiding this comment

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

Why do we not want to use the canonicalized agg function here anymore?

Copy link
Contributor

Choose a reason for hiding this comment

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

I think we should keep af.canonicalize(). The canonicalization still affects the per-agg filter, as in STATS c = count(field) WHERE other_field*1 > 10

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 don't understand the explanation on why it is important to keep the af.cannonical() here. I'd swear at some point I had to change this because tests were breaking otherwise. But if all tests are passing with it that means that either it is not that important or that we are missing specific tests that would break because of this?

Copy link
Contributor

Choose a reason for hiding this comment

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

The canonicalization may not be important, but I have a hunch that this is under tested + it's just a smaller change if we keep emitting the same agg functions as before (with canonicalization).

Expression aggExpression = child.transformUp(AggregateFunction.class, af -> {
AggregateFunction canonical = (AggregateFunction) af.canonical();
// canonical representation, with resolved aliases
AggregateFunction canonical = (AggregateFunction) af.canonical().transformUp(e -> aliases.resolve(e, e));
Copy link
Contributor

Choose a reason for hiding this comment

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

Maybe we should use a helper function for this line to prevent this from being different from how we canonicalize agg functions above (line 91)?

891 | 1782 | 8
;

fixClassCastBug2
Copy link
Contributor

Choose a reason for hiding this comment

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

Nit: It would be nice to avoid numbers in test cases.
Additional description could also hint what aspects were broken before:

  • combining results of two aggregate functions
  • nesting functions
  • multiplying result by constant
  • etc

PUSHING_DOWN_EVAL_WITH_SCORE,

/**
* Fix for ClassCastException in STATS
Copy link
Contributor

Choose a reason for hiding this comment

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

Suggested change
* Fix for ClassCastException in STATS
* Fix for ClassCastException in STATS
* https://github.com/elastic/elasticsearch/issues/133992

I realize it is a bit tricky to describe the change with java doc.
It might be worth linking an issue as it has a bit detailed description.
There are some prior examples with such links.

@ncordon
Copy link
Contributor Author

ncordon commented Nov 3, 2025

That said, I don't think this addresses problems like

| stats median(foo), percentile(foo, 50), count_distinct(foo)

@alex-spies, I've now included another planning phase after the constant folding that should take care of cases like these that @astefan suggested.

The PR description says this partially addresses #133992; what else is not yet addressed?

Philosophically I don't think the design of the compute engine part is correct at the moment. We try to make optimizations at runtime to avoid computing duplicated things and that breaks in case the plan is not optimal because we end up accessing wrong positions in our buffers.

For many (if not all) of the tests I added the plans were correct (but not optimal), and we are throwing at runtime. I've been in touch with @dnhatn about this part and he's helping me solve it.

* becomes
* stats a = min(x), c = count(*) by g | eval b = a, d = c | keep a, b, c, d, g
*/
public final class ReplaceDuplicatedAggs extends OptimizerRules.OptimizerRule<Aggregate> implements OptimizerRules.CoordinatorOnly {
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Ignore the duplicate code in this file with respect to ReplaceAggregateAggExpressionWithEval.java, I'll try to share as much code as possible once I've checked this passes all tests

phananh1010 added a commit to phananh1010/elasticsearch that referenced this pull request Nov 4, 2025
BASE=3017e334274a7292997b0fea77f90d2c73b58eba
HEAD=f1fd40ef2107a7fcf162a3baa2a9484c03cd5546
Branch=main
phananh1010 added a commit to phananh1010/elasticsearch that referenced this pull request Nov 5, 2025
BASE=3017e334274a7292997b0fea77f90d2c73b58eba
HEAD=f1fd40ef2107a7fcf162a3baa2a9484c03cd5546
Branch=main
phananh1010 added a commit to phananh1010/elasticsearch that referenced this pull request Nov 7, 2025
BASE=3017e334274a7292997b0fea77f90d2c73b58eba
HEAD=71c1691e34614a43230da1905611f4fa9dfeec01
Branch=main
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 for iterating on this @ncordon ! This has some subtilities.

new PropagateEvalFoldables(),
new ConstantFolding(),
// then extract nested aggs top-level
new DeduplicateAggs(),
Copy link
Contributor

Choose a reason for hiding this comment

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

Interesting!

Is the constant folding required to run before? Will this take care of cases that have percentile( foo, 25+25) and percentile(foo,2*25)?

I'd have thought that maybe the aggs substitution needs to happen before we replace agg expressions with evals. But this is placing deduplication pretty late into the optimization.

It's not wrong, though! But maybe a bit unfortunate that we'll have 2 rules that dedupe; and we can't assume that aggs come out of the substitutions batch already deduped. But maybe that was never correct to assume.

Copy link
Contributor

Choose a reason for hiding this comment

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

Constant folding in aggregation is an endemic problem and tech debt. Some insights here #112392 (comment).

I don't think we could realistically do the right thing now with constant folding in aggs (due to priorities), and there are already optimization stuff we duplicate because of this combination of surrogate expressions in aggs + constant folding issues - see

new SubstituteSurrogateAggregations(),
new ReplaceAggregateNestedExpressionWithEval()

being called twice in Substitutions batch. Having deduplication called twice for aggs only because agg constant folding doesn't happen correctly and at the right time is, in my mind, the right compromise at this point given how mysterious the errors are, how likely users are to run into them and, also, is not a completely wrong thing to do.

@ncordon ncordon force-pushed the stats-fix-classCastException-planning branch from b541eb7 to 15216e2 Compare November 11, 2025 09:53
import java.util.List;
import java.util.Map;

abstract class AbstractAggregateDeduplicator extends OptimizerRules.OptimizerRule<Aggregate> {
Copy link
Contributor Author

@ncordon ncordon Nov 11, 2025

Choose a reason for hiding this comment

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

I've extracted this to avoid duplicating code between DeduplicateAggs and ReplaceAggregateAggExpressionWithEval

);
}

/**
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've moved the relevant tests here to DeduplicateAggTests

* \_EsRelation[test][_meta_field{f}#17, emp_no{f}#11, first_name{f}#12, ..]
* }</pre>
*/
@AwaitsFix(bugUrl = "https://github.com/elastic/elasticsearch/issues/100634")
Copy link
Contributor Author

@ncordon ncordon Nov 11, 2025

Choose a reason for hiding this comment

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

I tried fixing the tests that have this annotation but we need extra work. It looks doable to me.

Deduplcating counts when we have a count(1) and a count(*)`, for example, is not exactly trivial and requires good care because the argument for the count could be a null, or the count could have a filter, etc.

@astefan astefan self-requested a review November 13, 2025 08:13
Copy link
Contributor

@astefan astefan left a comment

Choose a reason for hiding this comment

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

I went through the whole code and the only thing that is bothering to me is the forced refactoring for reusing the deduplication code. One of the aims for the rules, in general, is to be as much as possible easily readable and understandable by non-experts when looking at them.

For example, for the sake of code reuse, the method processAlias gets a long list of 9 parameters that are hard to grasp. Whoever is looking at this code trying to understand either DeduplicateAggs or AbstractAggregateDeduplicator will have a harder time understanding the code than that code being duplicated in the first place, imo.

I don't think this refactoring achieved this goal and it could go for another try.
I would try, as a second attempt, to have DeduplicateAggs expose a static (if possible) method that does the deduplication and that method to be re-used by ReplaceAggregateAggExpressionWithEval. Why I think this is logically more easily understandable is because:

  • DeduplicateAggs has as its main purpose deduplication of aggregations (even the name says it so)
  • ReplaceAggregateAggExpressionWithEval does something else as a main task, but also (secondary) the deduplication. It makes sense to reuse the deduplication (as an added optimization) from somewhere else.

Holder<Boolean> changed = new Holder<>(false);
int[] counter = new int[] { 0 };

for (NamedExpression agg : aggregate.aggregates()) {
Copy link
Contributor

Choose a reason for hiding this comment

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

Suggested change
for (NamedExpression agg : aggregate.aggregates()) {
for (NamedExpression agg : aggs) {

changed.set(true);
Expression aggExpression = child.transformUp(AggregateFunction.class, af -> {
// canonical representation, with resolved aliases
AggregateFunction canonical = (AggregateFunction) af.canonical().transformUp(e -> aliases.resolve(e, e));
Copy link
Contributor

Choose a reason for hiding this comment

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

The original code in this rule was different: AggregateFunction canonical = (AggregateFunction) af.canonical();. What is the purpose of this change in the PR? What exactly does it fix/change?

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 the original fix for the problem. When we are deduplicating aggs, the cannonical representative was already calculated with this method. The same cannonical representative should be used when fixing nested expressions. For example for a query:

from airports 
| rename scalerank AS x 
| stats  a = count(x), b = count(x) + count(x), c = count_distinct(x)

The first count(x) would be stored as already seen with the representative count(scalerank). For b, which is a nested expression, we should already know we've seen each one of the count(x).

assumeTrue("requires FIX FOR CLASSCAST exception to be enabled", EsqlCapabilities.Cap.FIX_STATS_CLASSCAST_EXCEPTION.isEnabled());
String query = """
FROM airports
| rename scalerank AS x
Copy link
Contributor

Choose a reason for hiding this comment

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

Suggested change
| rename scalerank AS x
| RENAME scalerank AS x

Nitty nit: please, try to keep the same style of code as the rest of the class. It shows a consistency and uniformity in approach. I know it's not functionally different, but imho it's a consistency level that shows a high level of attention.

Comment on lines 548 to 550
| STATS a = 2*COUNT_DISTINCT(scalerank, 100),
b = 2*COUNT_DISTINCT(scalerank, 220 - 150 + 30),
c = 2*COUNT_DISTINCT(scalerank, 1 + 200 - 80 - 20 - 1)
Copy link
Contributor

Choose a reason for hiding this comment

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

Suggested change
| STATS a = 2*COUNT_DISTINCT(scalerank, 100),
b = 2*COUNT_DISTINCT(scalerank, 220 - 150 + 30),
c = 2*COUNT_DISTINCT(scalerank, 1 + 200 - 80 - 20 - 1)
| STATS a = 2*COUNT_DISTINCT(scalerank, 100),
b = 2*COUNT_DISTINCT(scalerank, 220 - 150 + 30),
c = 2*COUNT_DISTINCT(scalerank, 1 + 200 - 80 - 20 - 1)

* \_EsRelation[airports][abbrev{f}#12, city{f}#18, city_location{f}#19, coun..]
*/
public void testDuplicatedAggWithFoldableIdenticalExpressions() {
assumeTrue("requires FIX FOR CLASSCAST exception to be enabled", EsqlCapabilities.Cap.FIX_STATS_CLASSCAST_EXCEPTION.isEnabled());
Copy link
Contributor

Choose a reason for hiding this comment

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

Do you really need this?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Not really 🙈


/**
* Project[[a{r}#5, b{r}#8, c{r}#11]]
* \_Eval[[$$COUNTDISTINCT$2*COUNT_DISTINC>$0{r$}#20 * 2[INTEGER] AS a#5, $$COUNTDISTINCT$2*COUNT_DISTINC>$0{r$}#20 * 2[
Copy link
Contributor

Choose a reason for hiding this comment

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

Why is this $$COUNTDISTINCT$2*COUNT_DISTINC (missing a T at the end)?

Copy link
Contributor Author

@ncordon ncordon Nov 14, 2025

Choose a reason for hiding this comment

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

In TemporaryNameUtils, we limit the length of this strings to 16 characters:

    static String limitToString(String string) {
        return string.length() > TO_STRING_LIMIT ? string.substring(0, TO_STRING_LIMIT - 1) + ">" : string;
    }

Comment on lines +169 to +172
private static AggregateFunction getCannonical(AggregateFunction af, AttributeMap<Expression> aliases) {
return (AggregateFunction) af.canonical().transformUp(e -> aliases.resolve(e, e));
}

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've extracted this as @alex-spies asked me to do

Comment on lines +143 to +145
} else {
newAggs.add(agg);
newProjections.add(agg.toAttribute());
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 block is doing nothing really because it only gets triggered if replaceNestedExpressions is false and it does not seem to be reached in that case ever, at least for the tests in our codebase at the moment.

But I'd rather have this safeguard (just leaving that aggregation as it is) than risking something important to disappear in the plan.

@ncordon
Copy link
Contributor Author

ncordon commented Nov 14, 2025

I went through the whole code and the only thing that is bothering to me is the forced refactoring for reusing the deduplication code. One of the aims for the rules, in general, is to be as much as possible easily readable and understandable by non-experts when looking at them.

Now it should be better. I've added a boolean to the original class instead to gate the part of the code that treats nested aggs.

Copy link
Contributor

@astefan astefan left a comment

Choose a reason for hiding this comment

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

LGTM with a small comment. Thank you!

*/
public final class ReplaceAggregateAggExpressionWithEval extends OptimizerRules.OptimizerRule<Aggregate> {
public class ReplaceAggregateAggExpressionWithEval extends OptimizerRules.OptimizerRule<Aggregate> {
private boolean replaceNestedExpressions = true;
Copy link
Contributor

Choose a reason for hiding this comment

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

final and set it always in the constructor.

@ncordon ncordon force-pushed the stats-fix-classCastException-planning branch from b78574e to fdde58c Compare November 17, 2025 21:50
@ncordon ncordon force-pushed the stats-fix-classCastException-planning branch from fdde58c to c483a52 Compare November 17, 2025 22:01
@ncordon ncordon merged commit 0a81875 into elastic:main Nov 18, 2025
35 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

:Analytics/ES|QL AKA ESQL >bug Team:Analytics Meta label for analytical engine team (ESQL/Aggs/Geo) Team:ES|QL v9.3.0

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants