Skip to content
Merged
Show file tree
Hide file tree
Changes from 14 commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
4910db5
Remove redundant sorts from execution plan
luigidellaquila Jan 29, 2025
a0b463a
Update docs/changelog/121156.yaml
luigidellaquila Jan 29, 2025
78c86de
More tests
luigidellaquila Jan 29, 2025
10117dc
Merge remote-tracking branch 'luigidellaquila/esql/remove_redundant_s…
luigidellaquila Jan 29, 2025
4d623d7
Merge branch 'main' into esql/remove_redundant_sort
luigidellaquila Jan 30, 2025
0ec1539
Verify OrderBy after optimization
luigidellaquila Jan 30, 2025
7ec3534
Merge branch 'main' into esql/remove_redundant_sort
luigidellaquila Jan 30, 2025
937e0dc
Merge branch 'main' into esql/remove_redundant_sort
luigidellaquila Jan 30, 2025
3569a16
Merge branch 'main' into esql/remove_redundant_sort
luigidellaquila Jan 31, 2025
df50502
Merge branch 'main' into esql/remove_redundant_sort
luigidellaquila Feb 3, 2025
3af3c11
Implement review suggestions
luigidellaquila Feb 4, 2025
16bfba0
Merge remote-tracking branch 'luigidellaquila/esql/remove_redundant_s…
luigidellaquila Feb 4, 2025
5c1ed70
Merge branch 'main' into esql/remove_redundant_sort
luigidellaquila Feb 4, 2025
c73246d
Remove AddDefaultTopN rule
luigidellaquila Feb 4, 2025
1c260e1
More tests
luigidellaquila Feb 6, 2025
a5d30ef
Better error message
luigidellaquila Feb 6, 2025
f881d2d
Delete OrderExec
luigidellaquila Feb 6, 2025
319cc69
Add SortAware interface
luigidellaquila Feb 6, 2025
7a9c8ab
Merge branch 'main' into esql/remove_redundant_sort
luigidellaquila Feb 6, 2025
2d34e50
Better description for SortAware
luigidellaquila Feb 7, 2025
f2af414
More tests
luigidellaquila Feb 7, 2025
2303d8b
Merge branch 'main' into esql/remove_redundant_sort
luigidellaquila Feb 10, 2025
9b22923
SortAware -> SortAgnostic and simplify
luigidellaquila Feb 10, 2025
9083477
Merge branch 'main' into esql/remove_redundant_sort
luigidellaquila Feb 10, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions docs/changelog/121156.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
pr: 121156
summary: Remove redundant sorts from execution plan
area: ES|QL
type: bug
issues: []
Original file line number Diff line number Diff line change
Expand Up @@ -1346,3 +1346,50 @@ language_code:integer | language_name:keyword | country:text
1 | English | United States of America
1 | English | null
;


sortBeforeAndAfterJoin
required_capability: join_lookup_v12
required_capability: remove_redundant_sort

FROM employees
| sort first_name
| EVAL language_code = languages
| LOOKUP JOIN languages_lookup ON language_code
| WHERE emp_no >= 10091 AND emp_no < 10094
| SORT emp_no
| KEEP emp_no, language_code, language_name
;

emp_no:integer | language_code:integer | language_name:keyword
10091 | 3 | Spanish
10092 | 1 | English
10093 | 3 | Spanish
;



sortBeforeAndAfterMultipleJoinAndMvExpand
required_capability: join_lookup_v12
required_capability: remove_redundant_sort

FROM employees
| sort first_name
| EVAL language_code = languages
| LOOKUP JOIN languages_lookup ON language_code
| WHERE emp_no >= 10091 AND emp_no < 10094
| SORT language_name
| MV_EXPAND first_name
| SORT first_name
| MV_EXPAND last_name
| SORT last_name
| LOOKUP JOIN languages_lookup ON language_code
| SORT emp_no
| KEEP emp_no, language_code, language_name
;

emp_no:integer | language_code:integer | language_name:keyword
10091 | 3 | Spanish
10092 | 1 | English
10093 | 3 | Spanish
;
Original file line number Diff line number Diff line change
Expand Up @@ -404,3 +404,17 @@ from employees | where emp_no == 10003 | mv_expand first_name | keep first_name
first_name:keyword
Parto
;


sortBeforeAndAfterMvExpand
from employees
| sort first_name
| mv_expand job_positions
| sort emp_no, job_positions
| keep emp_no, job_positions
| limit 2;

emp_no:integer | job_positions:keyword
10001 | Accountant
10001 | Senior Python Developer
;
Original file line number Diff line number Diff line change
Expand Up @@ -791,7 +791,14 @@ public enum Cap {
/**
* Support for aggregate_metric_double type
*/
AGGREGATE_METRIC_DOUBLE(AGGREGATE_METRIC_DOUBLE_FEATURE_FLAG.isEnabled());
AGGREGATE_METRIC_DOUBLE(AGGREGATE_METRIC_DOUBLE_FEATURE_FLAG.isEnabled()),

/**
* Fix for https://github.com/elastic/elasticsearch/issues/120817
* and https://github.com/elastic/elasticsearch/issues/120803
* Support for queries that have multiple SORTs that cannot become TopN
*/
REMOVE_REDUNDANT_SORT;

private final boolean enabled;

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,6 @@
import org.elasticsearch.xpack.esql.VerificationException;
import org.elasticsearch.xpack.esql.common.Failures;
import org.elasticsearch.xpack.esql.core.type.DataType;
import org.elasticsearch.xpack.esql.optimizer.rules.logical.AddDefaultTopN;
import org.elasticsearch.xpack.esql.optimizer.rules.logical.BooleanFunctionEqualsElimination;
import org.elasticsearch.xpack.esql.optimizer.rules.logical.BooleanSimplification;
import org.elasticsearch.xpack.esql.optimizer.rules.logical.CombineBinaryComparisons;
Expand All @@ -32,7 +31,7 @@
import org.elasticsearch.xpack.esql.optimizer.rules.logical.PruneEmptyPlans;
import org.elasticsearch.xpack.esql.optimizer.rules.logical.PruneFilters;
import org.elasticsearch.xpack.esql.optimizer.rules.logical.PruneLiteralsInOrderBy;
import org.elasticsearch.xpack.esql.optimizer.rules.logical.PruneOrderByBeforeStats;
import org.elasticsearch.xpack.esql.optimizer.rules.logical.PruneRedundantOrderBy;
import org.elasticsearch.xpack.esql.optimizer.rules.logical.PruneRedundantSortClauses;
import org.elasticsearch.xpack.esql.optimizer.rules.logical.PushDownAndCombineFilters;
import org.elasticsearch.xpack.esql.optimizer.rules.logical.PushDownAndCombineLimits;
Expand Down Expand Up @@ -116,10 +115,9 @@ protected List<Batch<LogicalPlan>> batches() {

protected static List<Batch<LogicalPlan>> rules() {
var skip = new Batch<>("Skip Compute", new SkipQueryOnLimitZero());
var defaultTopN = new Batch<>("Add default TopN", new AddDefaultTopN());
var label = new Batch<>("Set as Optimized", Limiter.ONCE, new SetAsOptimized());

return asList(substitutions(), operators(), skip, cleanup(), defaultTopN, label);
return asList(substitutions(), operators(), skip, cleanup(), label);
}

protected static Batch<LogicalPlan> substitutions() {
Expand Down Expand Up @@ -189,7 +187,7 @@ protected static Batch<LogicalPlan> operators() {
new PushDownRegexExtract(),
new PushDownEnrich(),
new PushDownAndCombineOrderBy(),
new PruneOrderByBeforeStats(),
new PruneRedundantOrderBy(),
new PruneRedundantSortClauses()
);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,9 @@ public Failures verify(LogicalPlan plan) {
PlanConsistencyChecker.checkPlan(p, dependencyFailures);

if (failures.hasFailures() == false) {
if (p instanceof PostOptimizationVerificationAware pova) {
pova.postOptimizationVerification(failures);
}
p.forEachExpression(ex -> {
if (ex instanceof PostOptimizationVerificationAware va) {
va.postOptimizationVerification(failures);
Expand Down
Copy link
Contributor

Choose a reason for hiding this comment

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

++ to the deletion, the rule was unfortunately incorrect.

This file was deleted.

This file was deleted.

Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/

package org.elasticsearch.xpack.esql.optimizer.rules.logical;

import org.elasticsearch.xpack.esql.plan.logical.Aggregate;
import org.elasticsearch.xpack.esql.plan.logical.Drop;
import org.elasticsearch.xpack.esql.plan.logical.Enrich;
import org.elasticsearch.xpack.esql.plan.logical.Eval;
import org.elasticsearch.xpack.esql.plan.logical.Filter;
import org.elasticsearch.xpack.esql.plan.logical.InlineStats;
import org.elasticsearch.xpack.esql.plan.logical.LogicalPlan;
import org.elasticsearch.xpack.esql.plan.logical.Lookup;
import org.elasticsearch.xpack.esql.plan.logical.MvExpand;
import org.elasticsearch.xpack.esql.plan.logical.OrderBy;
import org.elasticsearch.xpack.esql.plan.logical.Project;
import org.elasticsearch.xpack.esql.plan.logical.RegexExtract;
import org.elasticsearch.xpack.esql.plan.logical.Rename;
import org.elasticsearch.xpack.esql.plan.logical.TopN;
import org.elasticsearch.xpack.esql.plan.logical.UnaryPlan;
import org.elasticsearch.xpack.esql.plan.logical.join.Join;

import java.util.ArrayList;
import java.util.IdentityHashMap;
import java.util.List;

/**
* SORT cannot be executed without a LIMIT, as ES|QL doesn't support unbounded sort (yet).
* <p>
* The planner tries to push down LIMIT and transform all the unbounded sorts into a TopN.
* In some cases it's not possible though, eg.
* <p>
* from test | sort x | lookup join lookup on x | sort y
* <p>
* from test | sort x | mv_expand x | sort y
* <p>
* "sort y" will become a TopN, but "sort x" will remain unbounded, so the query could not be executed.
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
* "sort y" will become a TopN, but "sort x" will remain unbounded, so the query could not be executed.
* "sort y" will become a TopN due to the addition of the default Limit, but "sort x" will remain unbounded, so the query could not be executed.

* <p>
* In most cases though, following commands can make the previous SORTs redundant,
* because it will re-sort previously sorted results (eg. if there is another SORT)
* or because the order will be scrambled by another command (eg. a STATS)
* <p>
* This rule finds and prunes redundant SORTs, attempting to make the plan executable.
*/
public class PruneRedundantOrderBy extends OptimizerRules.OptimizerRule<LogicalPlan> {

@Override
protected LogicalPlan rule(LogicalPlan plan) {
if (plan instanceof OrderBy || plan instanceof TopN || plan instanceof Aggregate) {
IdentityHashMap<OrderBy, Void> redundant = findRedundantSort(((UnaryPlan) plan).child());
if (redundant.isEmpty()) {
return plan;
}
return plan.transformUp(p -> {
if (redundant.containsKey(p)) {
return ((OrderBy) p).child();
}
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 transformUp cannot be correct here.

In case of multiple redundant Sorts, it will remove the bottom most one, first. But that removal is implemented by a recursive change of the plan via replaceChildren, which creates a new plan instance at every level above the first change (the bottom-most Sort). Since we're using an IdentityHashMap, all intermediate Sorts will not match anymore, and will require another run of this rule to be pruned.

I was able to reproduce this with

row x = [1,2,3], y = 1 | sort x | mv_expand x | sort x | mv_expand x | sort y

Limit[1000[INTEGER],false]                                  = Limit[1000[INTEGER],false]
\_OrderBy[[Order[y{r}#61,ASC,LAST]]]                        = \_OrderBy[[Order[y{r}#61,ASC,LAST]]]
  \_MvExpand[x{r}#69,x{r}#70]                               =   \_MvExpand[x{r}#69,x{r}#70]
    \_OrderBy[[Order[x{r}#69,ASC,LAST]]]                    =     \_OrderBy[[Order[x{r}#69,ASC,LAST]]]
      \_MvExpand[x{r}#59,x{r}#69]                           =       \_MvExpand[x{r}#59,x{r}#69]
        \_OrderBy[[Order[x{r}#59,ASC,LAST]]]                !         \_Row[[[1, 2, 3][INTEGER] AS x, 1[INTEGER] AS y]]
          \_Row[[[1, 2, 3][INTEGER] AS x, 1[INTEGER] AS y]] ! 

It took two iterations to prune the two redundant OrderBys.

Either, we should update the rule to only ever remove one redundant order by (that'll just require more looping), or fix this by using transformDown. If we go the latter way (IMHO preferable to avoid too many loops), we should add a test where we manually trigger the rule only once and check that it got all the redundant OrderBys immediately.

return p;
Copy link
Member

Choose a reason for hiding this comment

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

-> collection.contains(p) ? ((UnaryPlan) p).child() ? p

});
} else {
return plan;
}
}

private IdentityHashMap<OrderBy, Void> findRedundantSort(LogicalPlan plan) {
Copy link
Member

Choose a reason for hiding this comment

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

Hide the implementation by returning the keySet as a Collection<OrderBy> on which call contains

List<LogicalPlan> toCheck = new ArrayList<>();
toCheck.add(plan);

IdentityHashMap<OrderBy, Void> result = new IdentityHashMap<>();
LogicalPlan p = null;
while (true) {
if (p == null) {
if (toCheck.isEmpty()) {
return result;
} else {
p = toCheck.remove(0);
}
} else if (p instanceof OrderBy ob) {
result.put(ob, null);
p = ob.child();
} else if (p instanceof UnaryPlan unary) {
if (unary instanceof Project
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 this long list is gonna be dangerous/brittle to correctly maintain, esp. as we add more commands (currently in the process with CHANGE_POINT!)

Could we, instead, please add an abstract method on LogicalPlan (or UnaryPlan), like boolean dependsOnSortOrder()? Or, alternatively, have a marker interface for plans that do not depend on sorts (so that missing the marker is correct by default)?

In a follow-up, I think we can tackle queries like

row x = [1,2,3], y = 1 | sort y | mv_expand x | where x > 2

by an optimizer rule that we run after the main operator optimization batch. It would look for a Limit and then try to pull up OrderBys to it whenever that's valid. To check if it's valid, I think we need something like a method boolean LogicalPlan.canBePushedDownPastOrderBy(List<OrderBy> orders) to check all the intermediate plans that would tell us if performing the OrderBy after the respective plan node would be equivalent. (Or maybe we just want to have another round of push downs past order bys, after the operator optimizations, which would be more aggressive and include push downs of MV_EXPAND and LOOKUP JOINs - which we generally don't want to push down unless it's necessary.)

Depending on sorting and being able to be pushed past sorting are both properties that are easier to check in the context of each relevant plan node, rather than in an optimizer rule that needs to exhaustively check all relevant plan nodes (while exhaustiveness cannot be enforced by the compiler).

Copy link
Member

Choose a reason for hiding this comment

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

+1 on a marker interface (say SortAware) under capabilities package so that commands can signal this behavior and the rule can pick it up without extra code modifications.
The rule would then perform a top-down transform and keep only the first sort for a SortAware (by default Limit to form topN).

P.S. LogicalPlan is a base class, utility methods (assuming they are used beyond just one rule) should be added elsewhere under PlannerUtils or LogicalPlans.

|| unary instanceof Drop
|| unary instanceof Rename
|| unary instanceof MvExpand
|| unary instanceof Enrich
|| unary instanceof RegexExtract
|| unary instanceof InlineStats
|| unary instanceof Lookup
// IMPORTANT
// If we introduce window functions or order-sensitive aggs (eg. STREAMSTATS),
// the previous sort could actually become relevant
// so we have to be careful with plans that could use them, ie. the following
|| unary instanceof Filter
|| unary instanceof Eval
|| unary instanceof Aggregate) {
Copy link
Contributor Author

@luigidellaquila luigidellaquila Feb 4, 2025

Choose a reason for hiding this comment

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

This is safe now, as long as we don't have window functions (and, in general, functions/aggs that rely on the order of the input).
We can decide to keep it like this for now and review it when we introduce such capabilities, or we can be more paranoid about future regressions and discard such cases, but in this case we won't be able to completely avoid unbounded sorts.

p = unary.child();
} else {
// stop here, other unary plans could be sensitive to SORT
p = null;
}
} else if (p instanceof Join lj) {
toCheck.add(lj.left());
toCheck.add(lj.right());
p = null;
} else {
// stop here, other unary plans could be sensitive to SORT
p = null;
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
import org.elasticsearch.common.io.stream.StreamInput;
import org.elasticsearch.common.io.stream.StreamOutput;
import org.elasticsearch.xpack.esql.capabilities.PostAnalysisVerificationAware;
import org.elasticsearch.xpack.esql.capabilities.PostOptimizationVerificationAware;
import org.elasticsearch.xpack.esql.capabilities.TelemetryAware;
import org.elasticsearch.xpack.esql.common.Failures;
import org.elasticsearch.xpack.esql.core.capabilities.Resolvables;
Expand All @@ -25,7 +26,7 @@

import static org.elasticsearch.xpack.esql.common.Failure.fail;

public class OrderBy extends UnaryPlan implements PostAnalysisVerificationAware, TelemetryAware {
public class OrderBy extends UnaryPlan implements PostAnalysisVerificationAware, PostOptimizationVerificationAware, TelemetryAware {
public static final NamedWriteableRegistry.Entry ENTRY = new NamedWriteableRegistry.Entry(LogicalPlan.class, "OrderBy", OrderBy::new);

private final List<Order> order;
Expand Down Expand Up @@ -109,4 +110,9 @@ public void postAnalysisVerification(Failures failures) {
}
});
}

@Override
public void postOptimizationVerification(Failures failures) {
failures.add(fail(this, "Unbounded sort not supported yet, please add a limit"));
Copy link
Contributor

Choose a reason for hiding this comment

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

Since we'll always fail when this node is still present after optimization, I think we should remove OrderExec and related code. It's never going to be instantiated, and we can't map it to operators, anyway.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

👍
BWC won't be a problem, right? We are never sending OrderExec to data nodes afaik (we only send a FragmentExec with a logical plan)

Copy link
Contributor

Choose a reason for hiding this comment

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

nit: maybe we could include the source in the error message to make this easier for users to understand.

}
}
Loading