Skip to main content

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}