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