Skip to content

Conversation

@selsong
Copy link
Contributor

@selsong selsong commented Aug 18, 2025

Description

This PR updates the Calcite planner to handle reverse correctly by flipping sort directions in pushdown.
It also documents and tests edge cases such as double or consecutive reverse usage, even though these are not expected in normal user queries.

1. Flip sort direction

Query:

source = accounts | sort + balance, - firstname | reverse

Logical Plan:

LogicalSystemLimit(sort0=[$3], sort1=[$1], dir0=[DESC-nulls-last], dir1=[ASC-nulls-first], fetch=[10000], type=[QUERY_SIZE_LIMIT])
  LogicalProject(account_number=[$0], firstname=[$1], address=[$2], balance=[$3], gender=[$4], city=[$5], employer=[$6], state=[$7], age=[$8], email=[$9], lastname=[$10])
  LogicalSort(sort0=[$3], sort1=[$1], dir0=[DESC-nulls-last], dir1=[ASC-nulls-first])
    LogicalSort(sort0=[$3], sort1=[$1], dir0=[ASC-nulls-first], dir1=[DESC-nulls-last])
      CalciteLogicalIndexScan(table=[[OpenSearch, accounts]])

Physical Plan:

CalciteEnumerableIndexScan(table=[[OpenSearch, accounts]], PushDownContext=[[PROJECT->[account_number, firstname, address, balance, gender, city, employer, state, age, email, lastname], SORT->[
  {
    \"balance\" : {
      \"order\" : \"desc\",
      \"missing\" : \"_last\"
    }
  },
  {
    \"firstname.keyword\" : {
      \"order\" : \"asc\",
      \"missing\" : \"_first\"
    }
  }
], LIMIT->10000], OpenSearchRequestBuilder(sourceBuilder={
  \"from\":0,
  \"size\":10000,
  \"timeout\":\"1m\",
  \"_source\":{
    \"includes\":[\"account_number\",\"firstname\",\"address\",\"balance\",\"gender\",\"city\",\"employer\",\"state\",\"age\",\"email\",\"lastname\"],
    \"excludes\":[]
  },
  \"sort\":[
    {\"balance\":{\"order\":\"desc\",\"missing\":\"_last\"}},
    {\"firstname.keyword\":{\"order\":\"asc\",\"missing\":\"_first\"}}
  ]
}, requestedTotalSize=10000, pageSize=null, startFrom=0)])

Explanation:
The single reverse flips the original sort order (balance ASC, firstname DESC) to the reversed order (balance DESC, firstname ASC).

2. Double reverse

Query:

source = accounts | sort + balance, - firstname | reverse | reverse

Logical Plan:

LogicalSystemLimit(sort0=[$3], sort1=[$1], dir0=[ASC-nulls-first], dir1=[DESC-nulls-last], fetch=[10000], type=[QUERY_SIZE_LIMIT])
  LogicalProject(account_number=[$0], firstname=[$1], address=[$2], balance=[$3], gender=[$4], city=[$5], employer=[$6], state=[$7], age=[$8], email=[$9], lastname=[$10])
  LogicalSort(sort0=[$3], sort1=[$1], dir0=[ASC-nulls-first], dir1=[DESC-nulls-last])   <-- final sort
    LogicalSort(sort0=[$3], sort1=[$1], dir0=[DESC-nulls-last], dir1=[ASC-nulls-first]) <-- after 1st reverse
      LogicalSort(sort0=[$3], sort1=[$1], dir0=[ASC-nulls-first], dir1=[DESC-nulls-last]) <-- original sort
        CalciteLogicalIndexScan(table=[[OpenSearch, accounts]])

Physical Plan:

CalciteEnumerableIndexScan(table=[[OpenSearch, accounts]], PushDownContext=[[PROJECT->[account_number, firstname, address, balance, gender, city, employer, state, age, email, lastname], SORT->[
  {
    \"balance\" : {
      \"order\" : \"asc\",
      \"missing\" : \"_first\"
    }
  },
  {
    \"firstname.keyword\" : {
      \"order\" : \"desc\",
      \"missing\" : \"_last\"
    }
  }
], LIMIT->10000], OpenSearchRequestBuilder(sourceBuilder={
  \"from\":0,
  \"size\":10000,
  \"timeout\":\"1m\",
  \"_source\":{
    \"includes\":[\"account_number\",\"firstname\",\"address\",\"balance\",\"gender\",\"city\",\"employer\",\"state\",\"age\",\"email\",\"lastname\"],
    \"excludes\":[]
  },
  \"sort\":[
    {\"balance\":{\"order\":\"asc\",\"missing\":\"_first\"}},
    {\"firstname.keyword\":{\"order\":\"desc\",\"missing\":\"_last\"}}
  ]
}, requestedTotalSize=10000, pageSize=null, startFrom=0)])

Explanation:
The two reverses cancel each other out. The final sort order is the same as the original query (balance ASC, firstname DESC).

3. Reverse after multiple sorts

Query:

source = accounts | sort + balance | sort - firstname | reverse

Logical Plan:

LogicalSystemLimit(sort0=[$1], dir0=[ASC-nulls-first], fetch=[10000], type=[QUERY_SIZE_LIMIT])
  LogicalProject(account_number=[$0], firstname=[$1], address=[$2], balance=[$3], gender=[$4], city=[$5], employer=[$6], state=[$7], age=[$8], email=[$9], lastname=[$10])
  LogicalSort(sort0=[$1], dir0=[ASC-nulls-first])
    LogicalSort(sort0=[$1], dir0=[DESC-nulls-last])
      LogicalSort(sort0=[$3], dir0=[ASC-nulls-first])
        CalciteLogicalIndexScan(table=[[OpenSearch, accounts]])

Physical Plan:

CalciteEnumerableIndexScan(table=[[OpenSearch, accounts]], PushDownContext=[[PROJECT->[account_number, firstname, address, balance, gender, city, employer, state, age, email, lastname], SORT->[
  {
    \"firstname.keyword\" : {
      \"order\" : \"asc\",
      \"missing\" : \"_first\"
    }
  }
], LIMIT->10000], OpenSearchRequestBuilder(sourceBuilder={
  \"from\":0,
  \"size\":10000,
  \"timeout\":\"1m\",
  \"_source\":{
    \"includes\":[\"account_number\",\"firstname\",\"address\",\"balance\",\"gender\",\"city\",\"employer\",\"state\",\"age\",\"email\",\"lastname\"],
    \"excludes\":[]
  },
  \"sort\":[
    {\"firstname.keyword\":{\"order\":\"asc\",\"missing\":\"_first\"}}
  ]
}, requestedTotalSize=10000, pageSize=null, startFrom=0)])

Explanation:
The last sort + reverse overrides the earlier sort + balance. The final pushed-down order is firstname ASC.

Related Issues

Resolves #3924

Check List

  • New functionality includes testing.
  • New functionality has been documented.
  • New functionality has javadoc added.
  • New functionality has a user manual doc added.
  • API changes companion pull request created.
  • Commits are signed per the DCO using --signoff.
  • Public documentation issue/PR created.

By submitting this pull request, I confirm that my contribution is made under the terms of the Apache 2.0 license.
For more information on following Developer Certificate of Origin and signing off your commits, please check here.

@RyanL1997 RyanL1997 added enhancement New feature or request PPL Piped processing language calcite calcite migration releated backport 2.19-dev labels Aug 18, 2025
@selsong selsong changed the title Implement reverse pushdown Implement reverse performance optimization Aug 18, 2025
@selsong selsong marked this pull request as ready for review August 18, 2025 23:42
Swiddis
Swiddis previously approved these changes Aug 19, 2025
Copy link
Collaborator

@Swiddis Swiddis left a comment

Choose a reason for hiding this comment

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

I'm curious about the cases where the double reverse optimization matters in practice, seems unlikely that someone would reverse something twice like that except by accident

@Swiddis Swiddis enabled auto-merge (squash) August 19, 2025 16:21
Comment on lines 365 to 367
// Check if the child is also a Reverse node
if (node.getChild().size() == 1
&& node.getChild().get(0) instanceof org.opensearch.sql.ast.tree.Reverse) {
Copy link
Collaborator

Choose a reason for hiding this comment

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

Is logical optimization rule better way to do this? Or this is just edge case and usually users won't do reverse | reverse?

Copy link
Collaborator

Choose a reason for hiding this comment

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

+1, calcite opt-rule does not cover this?

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 it's not necessary to check duplicate Reverse nodes. Calcite will ensure the top sort collation by overriding the child's collation with current one.

Copy link
Member

Choose a reason for hiding this comment

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

I think it's not necessary to check duplicate Reverse nodes. Calcite will ensure the top sort collation by overriding the child's collation with current one.

For the previous implementation (adding row_number window function), Calcite can do nothing.

+1 to add a opt-rule, it could cover more cases:

... | reverse | reverse
... | reverse | fields ... | reverse
... | reverse | where ... | reverse

Copy link
Contributor Author

@selsong selsong Sep 9, 2025

Choose a reason for hiding this comment

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

Thank you all for the excellent feedback about using Calcite optimization rules! After implementing and testing the SortDirectionOptRule, I discovered that you were right - Calcite's built-in optimization already handles sort merging effectively. The physical plans were identical with or without the custom rule.

Based on this, I've removed the custom optimization rule and now rely entirely on Calcite's mature optimization framework. The core improvement remains in PlanUtils: reverse detection that flips sort directions instead of using window functions.

// Remove row number column
context.relBuilder.projectExcept(context.relBuilder.field(REVERSE_ROW_NUM));

RelCollation collation = context.relBuilder.peek().getTraitSet().getCollation();
Copy link
Contributor

Choose a reason for hiding this comment

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

Be careful of using getTraitSet() to get fieldCollations because there is an edge case when it returns a different instance than RelCollation.

Sometimes, it returns a RelCompositeTrait instance containing all collation equivalent candidates. For example, this child PPL query source=EMP | sort SAL | eval a = cast(SAL as double) | where a > 2 will return RelCompositeTrait instance like [[5 ASC-nulls-first], [8]]. The eighth projected column is a cast expression but has the equivalent collation with [5 ASC-nulls-first]. The traitSet put equivalences together into a RelCompositeTrait.

A better way is probably List<RelCollation> collations = context.relBuilder.getCluster().getMetadataQuery().collations(context.relBuilder.peek()). You can pick the first valid RelCollation.

Copy link
Contributor

Choose a reason for hiding this comment

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

Calcite has some issue of carrying NullDirection when deducing collation for expressions. See: https://github.com/apache/calcite/blob/main/core/src/main/java/org/apache/calcite/rel/metadata/RelMdCollation.java#L358-L360.

If we really care about reversing null direction, another option is we can find the closest sort node and reverse based on that collation.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Thanks! I switched from getTraitSet().getCollation() to getMetadataQuery().collations() to properly handle RelCompositeTrait instances and collation equivalences.
Null direction preservation: The optimization rule now properly handles and reverses null directions when flipping sort collations.

Selina Song added 4 commits September 8, 2025 14:45
Signed-off-by: Selina Song <[email protected]>
Signed-off-by: Selina Song <[email protected]>
Signed-off-by: Selina Song <[email protected]>
Signed-off-by: Selina Song <[email protected]>
Copy link
Collaborator

@RyanL1997 RyanL1997 left a comment

Choose a reason for hiding this comment

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

Hi @selsong , thanks for putting this together, and I just left some comments.

Selina Song added 4 commits September 8, 2025 16:36
Signed-off-by: Selina Song <[email protected]>
Signed-off-by: Selina Song <[email protected]>
…mplify null direction handling

Signed-off-by: Selina Song <[email protected]>
Signed-off-by: Selina Song <[email protected]>
@selsong
Copy link
Contributor Author

selsong commented Sep 9, 2025

I'm curious about the cases where the double reverse optimization matters in practice, seems unlikely that someone would reverse something twice like that except by accident

Thanks for the note! I agree. It’s extremely unlikely that users would intentionally issue a double reverse and there is no usage as of now. This PR is now only focused on flipping the sort direction, and Calcite's default optimization takes care of the double reverse.

@selsong selsong requested review from Swiddis and dai-chen September 9, 2025 22:20
import org.apache.logging.log4j.Logger;
import org.opensearch.sql.calcite.plan.LogicalSystemLimit;

/** Combines sort then reverse into 1 sort. */
Copy link
Collaborator

@yuancu yuancu Sep 10, 2025

Choose a reason for hiding this comment

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

I'm a little about curious what's the behavior without this rule. Won't multiple sorts on the same fields be merged?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

You're right. Multiple sorts on the same fields are merged so without this rule physical plans are identical due to Calcite's built-in physical optimization. I think this rule ensures consistent optimization across different query patterns, especially in post-aggregation scenarios where physical rules might not apply.

Copy link
Contributor

Choose a reason for hiding this comment

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

Could you provide an example where default physical rule might not apply?

There is a SortRemoveRule in Calcite that changes the traits based on the Sort collation requirement. For this two sorts case, I think it will pick one of them anyway. Seems this optimization might be overlapped with existing rule.

In physical plan rules, Calcite will ensure query do not lose sort semantics by adding AbstractConverter node over applicable nodes that require sorting. The AbstractConverter node checks if the child collation satisfies the expected collation to decide whether to add a sort between parent and child. The sort added by AbstractConverter should be winner of the two sorts.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Thank you both for the insightful discussion! You were absolutely correct, and I was initially wrong in my assessment. After further testing and analysis, I've confirmed that:
Calcite's built-in optimization is sufficient - SortRemoveRule and AbstractConverter nodes handle sort merging effectively
Physical plans are identical with or without the custom rule, so the custom rule was redundant
Based on your feedback, I've removed the SortDirectionOptRule entirely. The core improvement remains: reverse detection that flips existing sort directions. Calcite's built-in optimization then handles the sort merging at the physical level.

}

@Override
public boolean matches(RelOptRuleCall call) {
Copy link
Collaborator

@yuancu yuancu Sep 10, 2025

Choose a reason for hiding this comment

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

Are there specific reasons on overriding the method instead of using a config?

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 think in this case override provides more control for complex matching logic like checking fetch limits and intermediate nodes.

+ " LogicalProject(EMPNO=[$0], ENAME=[$1], JOB=[$2], MGR=[$3], HIREDATE=[$4],"
+ " SAL=[$5], COMM=[$6], DEPTNO=[$7], __reverse_row_num__=[ROW_NUMBER() OVER ()])\n"
+ " LogicalTableScan(table=[[scott, EMP]])\n";
"LogicalSort(sort0=[$0], dir0=[DESC])\n" + " LogicalTableScan(table=[[scott, EMP]])\n";
Copy link
Collaborator

@yuancu yuancu Sep 10, 2025

Choose a reason for hiding this comment

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

In my understanding, it falls back to the original implementation with window functions when there is no preceding sort. In this test, if there is not reverse, are the fields sort by $0 (EMPNO) by default?

If not, this test case should fallback.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Yes, EMP table has implicit ordering by EMPNO ($0) in test data. This is detected as an existing sort and converts reverse to a simple DESC sort instead of using window functions.

Copy link
Collaborator

@yuancu yuancu Sep 12, 2025

Choose a reason for hiding this comment

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

Why is EMP table implicitly ordered by EMPNO? How is the order inferred / detected?

Update: I tried debugging, and found that the table does contain a collation trait with EMPNO sorted even with source=EMP. I don't know where is this collation from though.

Copy link
Collaborator

Choose a reason for hiding this comment

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

interesting, that's why there is no test case for using ROW_NUMBER in this test right?

Comment on lines 62 to 64
LogicalSort outerSort = call.rel(0);
org.apache.calcite.rel.RelNode intermediate = call.rel(1);
LogicalSort innerSort = call.rel(2);
Copy link
Contributor

@songkant-aws songkant-aws Sep 11, 2025

Choose a reason for hiding this comment

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

I think this pattern needs careful consideration. There are lots of cases to be considered.

If intermediate is a Project node without WINDOW function, the pattern is supposed to be optimized by SortProjectTransposeRule. And it will be changed to Project - outerSort - innerSort. Other bunch of rules may optimize the changed pattern to keep outerSort.

If the Project contains WINDOW function, we should not change outerSort under the intermediate node. Because it may change its semantics.

If the intermediate node is aggregation, suppose the sort is on the group by column, I think the outerSort over aggregated result will be more efficient because the row count will be reduced a lot after aggregation. Check if Calcite can make it like outerSort(groupByColumn) - Aggregate(count) by groupByColumn

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Thanks, you're right about these cases. Given that Calcite's built-in optimization already handles these cases correctly and efficiently, I've removed the custom optimization rule entirely. This eliminates the risk of interfering with Calcite's sophisticated rule interactions while preserving the core performance improvement.

Selina Song and others added 2 commits September 11, 2025 11:42
Selina Song added 2 commits September 12, 2025 09:49
Signed-off-by: Selina Song <[email protected]>
+ " LogicalProject(EMPNO=[$0], ENAME=[$1], JOB=[$2], MGR=[$3], HIREDATE=[$4],"
+ " SAL=[$5], COMM=[$6], DEPTNO=[$7], __reverse_row_num__=[ROW_NUMBER() OVER ()])\n"
+ " LogicalTableScan(table=[[scott, EMP]])\n";
"LogicalSort(sort0=[$0], dir0=[DESC])\n" + " LogicalTableScan(table=[[scott, EMP]])\n";
Copy link
Collaborator

Choose a reason for hiding this comment

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

interesting, that's why there is no test case for using ROW_NUMBER in this test right?

context.relBuilder.projectExcept(context.relBuilder.field(REVERSE_ROW_NUM));

// Check if there's an existing sort to reverse
List<RelCollation> collations =
Copy link
Collaborator

Choose a reason for hiding this comment

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

So we assume the head of this collation list must come from the sort command right before reverse command right? Is it always true or the reverse logic below works even though it's not true?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

  1. Yes, this test uses the EMP table which has a natural ordering by EMPNO. When reverse is called, Calcite's metadata query detects this existing collation and reverses it directly, so we see LogicalSort(sort0=[$0], dir0=[DESC]) instead of ROW_NUMBER. The ROW_NUMBER fallback only occurs when there's no existing collation.
  2. No, the assumption isn't always true, but I think the logic works correctly when its not true. getMetadataQuery().collations() returns all collations Calcite detects (natural orderings, index orderings, sort operations), not just the immediate sort command. We take the first available RelCollation (which may contain multiple sort fields) and reverse all its field collations at once.

Copy link
Collaborator

@dai-chen dai-chen Sep 12, 2025

Choose a reason for hiding this comment

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

For Q2: Take query ... | sort | X | Y | Z | reverse for example, we should be good as long as commands in-between (X, Y, Z) doesn't rely on the output order of sort command, right? Just thinking any counterexample, such as ... | sort | head | reverse?

I'm thinking is it too early to do this in visitReverse? Essentially, our CalciteRelNodeVisitor converts AST to its logical representation by RelNode. Just wondering what does it look like if we simply translate reverse to logical sort operator here?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Great question about edge cases! I tested both | sort | head 10 | reverse and | sort | eval | head | reverse patterns and they work correctly. In the first part of visitReverse the SORT ASC and LIMIT for head are combined into LogicalSort(sort0=[$0], dir0=[ASC-nulls-first], fetch=[10]) and then the reverseCollation is added with LogicalSort(sort0=[$0], dir0=[DESC-nulls-last]). The physical plan pushes SORT ASC + LIMIT N to OpenSearch, then applies DESC sort on the limited results. Let me know if there are any other edge cases, Thanks!

Comment on lines +1 to +6
{
"calcite": {
"logical": "LogicalSystemLimit(fetch=[10000], type=[QUERY_SIZE_LIMIT])\n LogicalProject(cpu_usage=[$0], @timestamp=[$1])\n LogicalSort(sort0=[$8], dir0=[DESC])\n LogicalProject(cpu_usage=[$0], @timestamp=[$1], _id=[$2], _index=[$3], _score=[$4], _maxscore=[$5], _sort=[$6], _routing=[$7], __reverse_row_num__=[ROW_NUMBER() OVER ()])\n LogicalSort(sort0=[$8], dir0=[DESC])\n LogicalProject(cpu_usage=[$0], @timestamp=[$1], _id=[$2], _index=[$3], _score=[$4], _maxscore=[$5], _sort=[$6], _routing=[$7], __reverse_row_num__=[ROW_NUMBER() OVER ()])\n CalciteLogicalIndexScan(table=[[OpenSearch, events]])\n",
"physical": "EnumerableCalc(expr#0..3=[{inputs}], proj#0..1=[{exprs}])\n EnumerableLimit(fetch=[10000])\n EnumerableSort(sort0=[$3], dir0=[DESC])\n EnumerableWindow(window#0=[window(rows between UNBOUNDED PRECEDING and CURRENT ROW aggs [ROW_NUMBER()])])\n EnumerableSort(sort0=[$2], dir0=[DESC])\n EnumerableWindow(window#0=[window(rows between UNBOUNDED PRECEDING and CURRENT ROW aggs [ROW_NUMBER()])])\n CalciteEnumerableIndexScan(table=[[OpenSearch, events]], PushDownContext=[[PROJECT->[cpu_usage, @timestamp]], OpenSearchRequestBuilder(sourceBuilder={\"from\":0,\"timeout\":\"1m\",\"_source\":{\"includes\":[\"cpu_usage\",\"@timestamp\"],\"excludes\":[]}}, requestedTotalSize=2147483647, pageSize=null, startFrom=0)])\n"
}
} No newline at end of file
Copy link
Collaborator

@yuancu yuancu Sep 15, 2025

Choose a reason for hiding this comment

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

Is this explain result and explain_double_reverse_sort_no_op used somewhere? I guess they were used for the elimination rule of double reverse. Then you delete the rule and relevant tests

@ahkcs
Copy link
Contributor

ahkcs commented Sep 29, 2025

@selsong Can you follow up on this? If not, we can take it over

@opensearch-trigger-bot
Copy link
Contributor

This PR is stalled because it has been open for 2 weeks with no activity.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

backport 2.19-dev calcite calcite migration releated enhancement New feature or request PPL Piped processing language stalled

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[FEATURE] Support reverse pushdown with Calcite

9 participants