Skip to content

fix: Capture global ORDER BY requirement under ScalarSubqueryExec root#23677

Open
sgrebnov wants to merge 1 commit into
apache:mainfrom
spiceai:sgrebnov/capture-order-by-scalar-subquery
Open

fix: Capture global ORDER BY requirement under ScalarSubqueryExec root#23677
sgrebnov wants to merge 1 commit into
apache:mainfrom
spiceai:sgrebnov/capture-order-by-scalar-subquery

Conversation

@sgrebnov

@sgrebnov sgrebnov commented Jul 17, 2026

Copy link
Copy Markdown
Member

Which issue does this PR close?

After the DataFusion 53 → 54 upgrade, some queries with a ScalarSubquery stopped respecting the global ORDER BY and returned unordered results.

Rationale for this change

In add mode, OutputRequirements walks the plan via require_top_ordering_helper to capture the global ORDER BY as an OutputRequirementExec. The helper bails on any node with children.len() != 1. ScalarSubqueryExec has multiple children (child 0 = order-transparent main input, the rest = uncorrelated subquery plans),
so when it is the root the rule stops immediately and stamps an empty OutputRequirementExec(order_by=[], dist_by=Unspecified) at the top — losing the ordering requirement.

ScalarSubqueryExec is order-transparent on child 0 (maintains_input_order()[0]== true, no required input ordering), so the search should descend through it like any other single-child order-preserving operator.

What changes are included in this PR?

require_top_ordering_helper now special-cases ScalarSubqueryExec: it descends through child 0 to find and wrap the top SortExec / SortPreservingMergeExec, reattaching the subquery children unchanged.

Are these changes tested?

Yes — two tests in datafusion/core/tests/physical_optimizer/output_requirements.rs:

  1. Rule level (require_top_ordering_descends_through_scalar_subquery): asserts the OutputRequirementExec carrying the ordering is placed below the ScalarSubqueryExec; without the fix it lands empty at the root.

  2. End to end (scalar_subquery_root_preserves_global_ordering_end_to_end): runs the full default optimizer pipeline over a ScalarSubqueryExec root whose ordering comes from a SortPreservingMergeExec over a two-partition ordered source, then executes the plan and checks the rows. Verified by toggling the fix:

    executed output
    without fix 1, 3, 5, 7, 2, 4, 6, 8 — merge dropped, rows partition-interleaved
    with fix 1, 2, 3, 4, 5, 6, 7, 8 — globally ordered

Existing subquery.slt / TPC-H snapshots are unchanged. I was unable to construct a SQL query that triggers the bug - over built-in sources the global ORDER BY is always preserved regardless of the missing requirement. It only surfaces when a custom physical planner supplies a plan shaped like the end-to-end test: a SortPreservingMergeExec over already-sorted partitions, with no SortExec above it.

Are there any user-facing changes?

No.

`OutputRequirements` (add mode) walks the plan via `require_top_ordering_helper`
to capture the query's global ORDER BY as an `OutputRequirementExec`. The helper
bails on any node with `children.len() != 1`. `ScalarSubqueryExec` is such a node
(child 0 is the order-transparent main input, the rest are uncorrelated subquery
plans), so when it is the plan root the rule stamps an empty
`OutputRequirementExec(order_by=[], dist_by=Unspecified)` at the top and never
records the global ordering requirement.

`ScalarSubqueryExec` is order-transparent on child 0 (`maintains_input_order()[0]
== true`, no required input ordering), so `require_top_ordering_helper` now
descends through child 0 to find and wrap the top `SortExec` /
`SortPreservingMergeExec`, reattaching the subquery children unchanged.

Tested at two levels in
`datafusion/core/tests/physical_optimizer/output_requirements.rs`:
- `require_top_ordering_descends_through_scalar_subquery` (rule level)
- `scalar_subquery_root_preserves_global_ordering_end_to_end` (end to end)
@codecov-commenter

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 88.88889% with 1 line in your changes missing coverage. Please review.
⚠️ Please upload report for BASE (main@f87e817). Learn more about missing BASE report.

Files with missing lines Patch % Lines
...sion/physical-optimizer/src/output_requirements.rs 88.88% 0 Missing and 1 partial ⚠️
Additional details and impacted files
@@           Coverage Diff           @@
##             main   #23677   +/-   ##
=======================================
  Coverage        ?   80.66%           
=======================================
  Files           ?     1087           
  Lines           ?   367420           
  Branches        ?   367420           
=======================================
  Hits            ?   296368           
  Misses          ?    53395           
  Partials        ?    17657           

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@neilconway neilconway left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Thanks for investigating this, and for the well-reasoned and well-tested PR.

Comment on lines 354 to 357
/// Idempotent: if the plan is already topped by an `OutputRequirementExec`, it
/// is returned unchanged so that re-running this rule (as adaptive execution
/// in datafusion-ballista AQE does after every completed stage, see
/// datafusion-ballista#1359) does not stack wrappers.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This rule is not actually idempotent, because we only check for an existing wrapper at the root of the plan, but wrappers might get added below the root of the plan, so in some cases we'll add a redundant OutputRequirementExec. Should we update the logic to add a top-level OutputRequirementExec to be more intelligent?

Comment on lines +385 to +402
// `ScalarSubqueryExec` is a multi-child but order-transparent root: child 0 is
// the main input (it copies that child's `PlanProperties` and reports
// `maintains_input_order()[0] == true` with no required input ordering), while
// the remaining children are subquery plans that don't contribute to output
// ordering. The generic `children.len() != 1` guard below would stop the search
// at this node and lose the query's global ORDER BY (the top `SortExec` lives
// below the main input), so descend through child 0 and reattach the rest.
if plan.downcast_ref::<ScalarSubqueryExec>().is_some() {
let (new_main, is_changed) =
require_top_ordering_helper(Arc::clone(children[0]))?;
if is_changed {
let mut new_children: Vec<Arc<dyn ExecutionPlan>> =
children.iter().map(|&c| Arc::clone(c)).collect();
new_children[0] = new_main;
return Ok((plan.with_new_children(new_children)?, true));
}
return Ok((plan, false));
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

What if we added a helper like

  fn output_requirement_child(plan: &dyn ExecutionPlan) -> Option<usize> {
      let children = plan.children();

      if children.len() == 1 {
          Some(0)
      } else if plan.is::<ScalarSubqueryExec>() {
          Some(0)
      } else {
          None
      }
  }

That might isolate the subquery-specific logic to a single place?

.as_ref()
.is_none_or(|o| matches!(o, OrderingRequirements::Soft(_))))
{
// Keep searching for a `SortExec` as long as ordering is maintained,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Not yours, but this comment should be generalized to SortExec + SortPreservingMergeExec.

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

Labels

core Core DataFusion crate optimizer Optimizer rules

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants