datafusion_physical_optimizer/pushdown_sort.rs
1// Licensed to the Apache Software Foundation (ASF) under one
2// or more contributor license agreements. See the NOTICE file
3// distributed with this work for additional information
4// regarding copyright ownership. The ASF licenses this file
5// to you under the Apache License, Version 2.0 (the
6// "License"); you may not use this file except in compliance
7// with the License. You may obtain a copy of the License at
8//
9// http://www.apache.org/licenses/LICENSE-2.0
10//
11// Unless required by applicable law or agreed to in writing,
12// software distributed under the License is distributed on an
13// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
14// KIND, either express or implied. See the License for the
15// specific language governing permissions and limitations
16// under the License.
17
18//! Sort Pushdown Optimization
19//!
20//! This optimizer attempts to push sort requirements down through the execution plan
21//! tree to data sources that can natively handle them (e.g., by scanning files in
22//! reverse order).
23//!
24//! ## How it works
25//!
26//! 1. Detects `SortExec` nodes in the plan
27//! 2. Calls `try_pushdown_sort()` on the input to recursively push the sort requirement
28//! 3. Each node type defines its own pushdown behavior:
29//! - **Transparent nodes** (CoalesceBatchesExec, RepartitionExec, etc.) delegate to
30//! their children and wrap the result
31//! - **Data sources** (DataSourceExec) check if they can optimize for the ordering
32//! - **Blocking nodes** return `Unsupported` to stop pushdown
33//! 4. Based on the result:
34//! - `Exact`: Remove the Sort operator (data source guarantees perfect ordering)
35//! - `Inexact`: Keep Sort but use optimized input (enables early termination for TopK)
36//! - `Unsupported`: No change
37//!
38//! ## Capabilities
39//!
40//! - **Sort elimination**: when a data source's natural ordering satisfies the
41//! request, return `Exact` and remove the `SortExec` entirely. Preserves
42//! `fetch` (LIMIT) from the eliminated `SortExec` for early termination.
43//! - **Statistics-based file sorting**: sort files within each partition by
44//! min/max statistics. When files are non-overlapping but listed in wrong
45//! order (e.g., alphabetical order ≠ sort key order), this fixes the ordering
46//! and enables sort elimination. Works for both single-partition and
47//! multi-partition plans with multi-file groups.
48//! - **Reverse scan optimization**: when required sort is the reverse of the data source's
49//! natural ordering, enable reverse scanning (reading row groups in reverse order)
50//! - **Prefix matching**: if data has ordering [A DESC, B ASC] and query needs
51//! [A DESC], the existing ordering satisfies the requirement (`Exact`).
52//! If the query needs [A ASC] (reverse of the prefix), a reverse scan is
53//! used (`Inexact`, `SortExec` retained)
54//!
55//! Related issue: <https://github.com/apache/datafusion/issues/17348>
56
57use crate::PhysicalOptimizerRule;
58use datafusion_common::Result;
59use datafusion_common::config::ConfigOptions;
60use datafusion_common::tree_node::{
61 Transformed, TransformedResult, TreeNode, TreeNodeRecursion,
62};
63use datafusion_physical_plan::SortOrderPushdownResult;
64use datafusion_physical_plan::buffer::BufferExec;
65use datafusion_physical_plan::limit::{GlobalLimitExec, LocalLimitExec};
66use datafusion_physical_plan::sorts::sort::SortExec;
67use datafusion_physical_plan::sorts::sort_preserving_merge::SortPreservingMergeExec;
68use datafusion_physical_plan::{ExecutionPlan, ExecutionPlanProperties};
69use std::sync::Arc;
70
71/// A PhysicalOptimizerRule that attempts to push down sort requirements to data sources.
72///
73/// See module-level documentation for details.
74#[derive(Debug, Clone, Default)]
75pub struct PushdownSort;
76
77impl PushdownSort {
78 pub fn new() -> Self {
79 Self {}
80 }
81}
82
83impl PhysicalOptimizerRule for PushdownSort {
84 fn optimize(
85 &self,
86 plan: Arc<dyn ExecutionPlan>,
87 config: &ConfigOptions,
88 ) -> Result<Arc<dyn ExecutionPlan>> {
89 // Check if sort pushdown optimization is enabled
90 if !config.optimizer.enable_sort_pushdown {
91 return Ok(plan);
92 }
93
94 let buffer_capacity = config.execution.sort_pushdown_buffer_capacity;
95
96 // Use transform_down to find and optimize all SortExec nodes (including nested ones)
97 // Also handles SPM → SortExec pattern to insert BufferExec when sort is eliminated
98 plan.transform_down(|plan: Arc<dyn ExecutionPlan>| {
99 // Pattern 1: SPM → SortExec(preserve_partitioning)
100 // When we eliminate the SortExec, SPM loses its memory buffer and reads
101 // directly from I/O-bound sources. Insert a BufferExec to compensate.
102 if let Some(spm) = plan.downcast_ref::<SortPreservingMergeExec>()
103 && let Some(sort_child) = spm.input().downcast_ref::<SortExec>()
104 && sort_child.preserve_partitioning()
105 {
106 let sort_input = Arc::clone(sort_child.input());
107 let required_ordering = sort_child.expr();
108 match sort_input.try_pushdown_sort(required_ordering)? {
109 SortOrderPushdownResult::Exact { inner } => {
110 // Preserve fetch (LIMIT) from the eliminated SortExec.
111 // Use LocalLimitExec (not Global) since input is multi-partition.
112 let inner = if let Some(fetch) = sort_child.fetch() {
113 inner.with_fetch(Some(fetch)).unwrap_or_else(|| {
114 Arc::new(LocalLimitExec::new(inner, fetch))
115 })
116 } else {
117 inner
118 };
119 // Insert BufferExec to replace SortExec's buffering role.
120 // SortExec buffered all data in memory; BufferExec provides
121 // bounded buffering so SPM doesn't stall on I/O.
122 let buffered: Arc<dyn ExecutionPlan> =
123 Arc::new(BufferExec::new(inner, buffer_capacity));
124 let new_spm =
125 SortPreservingMergeExec::new(spm.expr().clone(), buffered)
126 .with_fetch(spm.fetch());
127 return Ok(Transformed::yes(Arc::new(new_spm)));
128 }
129 SortOrderPushdownResult::Inexact { inner } => {
130 let new_sort = SortExec::new(required_ordering.clone(), inner)
131 .with_fetch(sort_child.fetch())
132 .with_preserve_partitioning(true);
133 let new_spm = SortPreservingMergeExec::new(
134 spm.expr().clone(),
135 Arc::new(new_sort),
136 )
137 .with_fetch(spm.fetch());
138 // The replacement already has the required
139 // `SortPreservingMergeExec` parent. Do not descend
140 // into its `SortExec` child and treat it as a
141 // standalone TopK.
142 return Ok(Transformed::new(
143 Arc::new(new_spm),
144 true,
145 TreeNodeRecursion::Jump,
146 ));
147 }
148 SortOrderPushdownResult::Unsupported => {
149 return Ok(Transformed::no(plan));
150 }
151 }
152 }
153
154 // Pattern 2: Standalone SortExec (no SPM parent)
155 let Some(sort_exec) = plan.downcast_ref::<SortExec>() else {
156 return Ok(Transformed::no(plan));
157 };
158
159 let sort_input = Arc::clone(sort_exec.input());
160 let required_ordering = sort_exec.expr();
161
162 // Try to push the sort requirement down through the plan tree
163 // Each node type defines its own pushdown behavior via try_pushdown_sort()
164 match sort_input.try_pushdown_sort(required_ordering)? {
165 SortOrderPushdownResult::Exact { inner } => {
166 // Data source guarantees perfect ordering - remove the Sort operator.
167 //
168 // If the SortExec carried a fetch (LIMIT), we must preserve it.
169 // First try pushing the limit into the source via `with_fetch()`.
170 // If the source doesn't support `with_fetch`, fall back to
171 // wrapping with GlobalLimitExec.
172 if let Some(fetch) = sort_exec.fetch() {
173 let inner = inner.with_fetch(Some(fetch)).unwrap_or_else(|| {
174 Arc::new(GlobalLimitExec::new(inner, 0, Some(fetch)))
175 });
176 Ok(Transformed::yes(inner))
177 } else {
178 Ok(Transformed::yes(inner))
179 }
180 }
181 SortOrderPushdownResult::Inexact { inner } => {
182 // Data source is optimized for the ordering but not perfectly sorted
183 // Keep the Sort operator but use the optimized input
184 // Benefits: TopK queries can terminate early, better cache locality
185 // A standalone multi-partition TopK still needs a global
186 // merge; otherwise a later coalesce can concatenate
187 // locally sorted partitions.
188 let preserve_partitioning = sort_exec.preserve_partitioning();
189 let needs_global_topk =
190 preserve_partitioning && sort_exec.fetch().is_some();
191 let input_partitions = inner.output_partitioning().partition_count();
192 let new_sort: Arc<dyn ExecutionPlan> = Arc::new(
193 SortExec::new(required_ordering.clone(), inner)
194 .with_fetch(sort_exec.fetch())
195 .with_preserve_partitioning(preserve_partitioning),
196 );
197 if needs_global_topk && input_partitions > 1 {
198 let new_spm = SortPreservingMergeExec::new(
199 required_ordering.clone(),
200 new_sort,
201 )
202 .with_fetch(sort_exec.fetch());
203 // Do not descend into the newly inserted
204 // `SortExec`, or this standalone branch will wrap it
205 // in another `SortPreservingMergeExec`.
206 Ok(Transformed::new(
207 Arc::new(new_spm),
208 true,
209 TreeNodeRecursion::Jump,
210 ))
211 } else {
212 Ok(Transformed::yes(new_sort))
213 }
214 }
215 SortOrderPushdownResult::Unsupported => {
216 // Cannot optimize for this ordering - no change
217 Ok(Transformed::no(plan))
218 }
219 }
220 })
221 .data()
222 }
223
224 fn name(&self) -> &str {
225 "PushdownSort"
226 }
227
228 fn schema_check(&self) -> bool {
229 true
230 }
231}