Skip to main content

datafusion_physical_optimizer/ensure_requirements/
mod.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//! [`EnsureRequirements`] optimizer rule that enforces distribution and
19//! sorting requirements together so that the two never invalidate each other.
20//!
21//! This rule replaces the separate `EnforceDistribution` + `EnforceSorting`
22//! rules with a unified approach inspired by Apache Spark's `EnsureRequirements`
23//! and Presto/Trino's `AddExchanges`.
24//!
25//! # Motivation
26//!
27//! The previous two-rule design (`EnforceDistribution` then `EnforceSorting`)
28//! suffers from non-idempotent composition: `EnforceSorting`'s `pushdown_sorts`
29//! can break distribution invariants established by `EnforceDistribution`,
30//! because `SortExec.preserve_partitioning` couples sorting and distribution
31//! decisions. See <https://github.com/apache/datafusion/issues/21973> for details.
32//!
33//! # Architecture
34//!
35//! `optimize` runs several tree traversals. The defining property of this
36//! rule is **Phase 2**: a single combined bottom-up pass that resolves
37//! distribution *and* sorting for each node together. The surrounding phases
38//! are independent traversals (top-down join-key reorder, then several
39//! follow-up sort/order rewrites). Some of those could be consolidated
40//! further in a follow-up.
41//!
42//! ```text
43//! EnsureRequirements::optimize(plan)
44//! │
45//! ├─ Phase 1: top-down join-key reorder        (adjust_input_keys_ordering)
46//! │
47//! ├─ Phase 2: combined distribution + sorting  (single bottom-up pass)
48//! │   └─ For each node (bottom-up), for each child:
49//! │       Step 1: ensure distribution requirement
50//! │         └─ insert RepartitionExec / CoalescePartitionsExec /
51//! │            SortPreservingMergeExec as needed
52//! │       Step 2: ensure ordering requirement (distribution-aware)
53//! │         └─ insert SortExec with the correct `preserve_partitioning`,
54//! │            with SortPreservingMergeExec on top if needed
55//! │
56//! └─ Phase 3: small follow-up passes (bottom-up unless noted)
57//!     ├─ parallelize_sorts
58//!     ├─ replace_with_order_preserving_variants
59//!     ├─ pushdown_sorts                         (recursive walk)
60//!     └─ replace_with_partial_sort
61//! ```
62//!
63//! # Key Properties
64//!
65//! - **Idempotent across the whole rule**: Running `EnsureRequirements`
66//!   twice produces the same plan. This is the property that fixes
67//!   <https://github.com/apache/datafusion/issues/21973>, where the old
68//!   two-rule pipeline could regress a parallel sort plan into a serial one
69//!   on pass 2.
70//! - **Distribution before sorting**: For each child, distribution is
71//!   resolved before ordering, so sorting decisions always have full
72//!   distribution context.
73//! - **Sort pushdown is implicit**: Phase 2 only adds `SortExec` where the
74//!   child doesn't already satisfy the ordering requirement, so sorts land
75//!   at the deepest valid position without a separate destructive pass.
76//!
77//! # Behavior: parallelism via repartitioning
78//!
79//! Phase 2 Step 1 inserts `RepartitionExec` to satisfy distribution
80//! requirements. When configuration allows, it also increases parallelism by
81//! repartitioning over otherwise-serial inputs. For example, given two
82//! 1-partition inputs feeding an operator that can run with more
83//! parallelism:
84//!
85//! ```text
86//! ┌─────────────────────────────────┐
87//! │          ExecutionPlan          │
88//! └─────────────────────────────────┘
89//!         ▲                 ▲
90//!         │                 │
91//!   ┌───────────┐     ┌───────────┐
92//!   │  batch A  │     │  batch B  │      Input: 2 partitions
93//!   └───────────┘     └───────────┘
94//! ```
95//!
96//! `EnsureRequirements` inserts a `RepartitionExec` so the operator runs
97//! with three partitions:
98//!
99//! ```text
100//! ┌─────────────────────────────────┐
101//! │          ExecutionPlan          │      Input now has 3 partitions
102//! └─────────────────────────────────┘
103//!         ▲      ▲       ▲
104//!         └──────┼───────┘
105//!                │
106//! ┌─────────────────────────────────┐
107//! │       RepartitionExec(3)        │      batches are repartitioned
108//! │           RoundRobin            │
109//! └─────────────────────────────────┘
110//!         ▲                 ▲
111//!   ┌───────────┐     ┌───────────┐
112//!   │  batch A  │     │  batch B  │
113//!   └───────────┘     └───────────┘
114//! ```
115//!
116//! # Behavior: joint distribution + sorting
117//!
118//! Resolving distribution and sorting together lets Phase 2 produce a
119//! parallel sort plan in cases where the two-rule pipeline historically
120//! risked a serial one. Given `Sort(DESC) ← Coalesce ← MultiPartitionSource`,
121//! `EnsureRequirements` rewrites it into:
122//!
123//! ```text
124//! SortPreservingMergeExec: [a DESC]            (cheap k-way merge of sorted streams)
125//!   SortExec: [a DESC], preserve_partitioning=true   (N sorts run in parallel)
126//!     MultiPartitionSource
127//! ```
128//!
129//! Each input partition is sorted in parallel, then a `SortPreservingMergeExec`
130//! at the top performs a cheap merge of pre-sorted streams. For TopK queries
131//! (`fetch=K`), each parallel sort only keeps K rows per partition, so total
132//! memory is `N × K` rather than coalescing the entire stream first.
133//!
134//! # Behavior: strictest distribution match for joins
135//!
136//! Distribution requirements are met in the strictest way. For example, a
137//! hash join with keys `(a, b, c)` requires `Distribution(a, b, c)`. This
138//! can in principle be satisfied by partitioning on any superset of any
139//! subset of `(a, b, c)`, but this rule always partitions on the exact key
140//! tuple `(a, b, c)`. This is sometimes more aggressive than strictly
141//! necessary, but the strictest match helps avoid data skew in joins.
142
143// Internal implementation modules. Re-exported from `crate` root for tests
144// in `core/tests/physical_optimizer/{enforce_distribution,enforce_sorting}.rs`.
145pub mod enforce_distribution;
146pub mod enforce_sorting;
147
148use std::sync::Arc;
149
150use crate::PhysicalOptimizerRule;
151
152use datafusion_common::Result;
153use datafusion_common::config::ConfigOptions;
154use datafusion_common::tree_node::{Transformed, TransformedResult, TreeNode};
155use datafusion_physical_plan::ExecutionPlan;
156
157/// Optimizer rule that enforces both distribution and sorting requirements.
158///
159/// This rule combines the functionality of `EnforceDistribution` and
160/// `EnforceSorting` into a coordinated sequence where distribution is
161/// always settled before sorting for each operator, preventing the
162/// non-idempotent interactions between the two separate rules.
163///
164/// See [module level documentation](self) for more details.
165#[derive(Default, Debug)]
166pub struct EnsureRequirements {}
167
168impl EnsureRequirements {
169    /// Create a new `EnsureRequirements` optimizer rule.
170    pub fn new() -> Self {
171        Self {}
172    }
173}
174
175impl PhysicalOptimizerRule for EnsureRequirements {
176    fn optimize(
177        &self,
178        plan: Arc<dyn ExecutionPlan>,
179        config: &ConfigOptions,
180    ) -> Result<Arc<dyn ExecutionPlan>> {
181        // Phase 1: Join key reordering (top-down, from EnforceDistribution)
182        use super::enforce_distribution::{
183            PlanWithKeyRequirements, adjust_input_keys_ordering,
184        };
185        let top_down_join_key_reordering = config.optimizer.top_down_join_key_reordering;
186        let plan = if top_down_join_key_reordering {
187            let ctx = PlanWithKeyRequirements::new_default(plan);
188            ctx.transform_down(adjust_input_keys_ordering).data()?.plan
189        } else {
190            use super::enforce_distribution::reorder_join_keys_to_inputs;
191            plan.transform_up(|p| Ok(Transformed::yes(reorder_join_keys_to_inputs(p)?)))
192                .data()?
193        };
194
195        // Phase 2: Combined distribution + sorting enforcement (single bottom-up pass)
196        // For each node: distribution first, then sorting.
197        use super::enforce_distribution::{DistributionContext, ensure_distribution};
198        use super::enforce_sorting::{PlanWithCorrespondingSort, ensure_sorting};
199
200        // Step 2a: Distribution enforcement (bottom-up)
201        let dist_ctx = DistributionContext::new_default(plan);
202        let dist_ctx = dist_ctx
203            .transform_up(|ctx| ensure_distribution(ctx, config))
204            .data()?;
205
206        // Step 2b: Sorting enforcement (bottom-up) — runs on distribution-fixed plan
207        let sort_ctx = PlanWithCorrespondingSort::new_default(dist_ctx.plan);
208        let sort_ctx = sort_ctx.transform_up(ensure_sorting)?.data;
209
210        // Phase 3: Optimization passes
211        // 3a: Parallelize sorts (Coalesce+Sort → SPM+Sort)
212        use super::enforce_sorting::{
213            PlanWithCorrespondingCoalescePartitions, parallelize_sorts,
214            replace_with_partial_sort,
215        };
216        let plan = if config.optimizer.repartition_sorts {
217            let ctx = PlanWithCorrespondingCoalescePartitions::new_default(sort_ctx.plan);
218            ctx.transform_up(parallelize_sorts).data()?.plan
219        } else {
220            sort_ctx.plan
221        };
222
223        // 3b: Order-preserving variants
224        use super::enforce_sorting::replace_with_order_preserving_variants::{
225            OrderPreservationContext, replace_with_order_preserving_variants,
226        };
227        let ctx = OrderPreservationContext::new_default(plan);
228        let plan = ctx
229            .transform_up(|c| {
230                replace_with_order_preserving_variants(c, false, true, config)
231            })
232            .data()?
233            .plan;
234
235        // 3c: Sort pushdown (distribution-aware)
236        use super::enforce_sorting::sort_pushdown::{
237            SortPushDown, assign_initial_requirements, pushdown_sorts,
238        };
239        let mut sort_pushdown = SortPushDown::new_default(plan);
240        assign_initial_requirements(&mut sort_pushdown);
241        let adjusted = pushdown_sorts(sort_pushdown)?;
242
243        // 3d: Partial sort
244        adjusted
245            .plan
246            .transform_up(|p| Ok(Transformed::yes(replace_with_partial_sort(p)?)))
247            .data()
248    }
249
250    fn name(&self) -> &str {
251        "EnsureRequirements"
252    }
253
254    fn schema_check(&self) -> bool {
255        true
256    }
257}
258
259// See tests in datafusion/core/tests/physical_optimizer/ensure_requirements.rs