Skip to main content

datafusion_physical_optimizer/
limit_pushdown.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//! [`LimitPushdown`] pushes `LIMIT` down through `ExecutionPlan`s to reduce
19//! data transfer as much as possible.
20//!
21//! # Plan Limit Absorption
22//! In addition to pushing down `GlobalLimitExec` and `LocalLimitExec` nodes in
23//! the plan, some operators can "absorb" a limit and stop early during
24//! execution.
25//!
26//! ## Background: vectorized volcano execution model
27//! DataFusion uses a batched volcano model. For most operators, output is
28//! produced in batches of `datafusion.execution.batch_size` (default 8192), so
29//! the batch sizes typically look like:
30//! ```text
31//! 8192, 8192, ..., 8192, 100 (the final batch may be partial)
32//! ```
33//!
34//! ## Example
35//! For a join with an expensive, selective predicate:
36//! ```text
37//! GlobalLimitExec: skip=0, fetch=10
38//! -- NestedLoopJoinExec(on=expr_expensive_and_selective)
39//! --- DataSourceExec()
40//! --- DataSourceExec()
41//! ```
42//!
43//! Under this model, `NestedLoopJoinExec` would keep working until it can emit
44//! a full batch (8192 rows), even though the query only needs 10. If the limit
45//! cannot be pushed below the join, we can still embed it inside the join so it
46//! stops once the limit is satisfied. The transformed plan looks like:
47//!
48//! ```text
49//! NestedLoopJoinExec(on=expr_expensive_and_selective, fetch=10)
50//! --- DataSourceExec()
51//! --- DataSourceExec()
52//! ```
53//!
54//! ## Implementation
55//! The current optimizer rule optionally pushes `fetch` requirements into
56//! operators via [`ExecutionPlan::with_fetch`].
57//!
58//! To support early termination in operators, [`LimitedBatchCoalescer`](https://docs.rs/datafusion/latest/datafusion/physical_plan/coalesce/struct.LimitedBatchCoalescer.html)
59//! can help manage the output buffer.
60//!
61//! Reference implementation in Hash Join: <https://github.com/apache/datafusion/pull/20228>
62
63use std::fmt::Debug;
64use std::sync::Arc;
65
66use crate::PhysicalOptimizerRule;
67
68use datafusion_common::config::ConfigOptions;
69use datafusion_common::error::Result;
70use datafusion_common::stats::Precision;
71use datafusion_common::tree_node::{Transformed, TreeNodeRecursion};
72use datafusion_common::utils::combine_limit;
73use datafusion_physical_plan::coalesce_partitions::CoalescePartitionsExec;
74use datafusion_physical_plan::empty::EmptyExec;
75use datafusion_physical_plan::execution_plan::replace_children_if_necessary;
76use datafusion_physical_plan::limit::{GlobalLimitExec, LocalLimitExec};
77use datafusion_physical_plan::placeholder_row::PlaceholderRowExec;
78use datafusion_physical_plan::projection::ProjectionExec;
79use datafusion_physical_plan::sorts::sort_preserving_merge::SortPreservingMergeExec;
80use datafusion_physical_plan::statistics::{StatisticsArgs, StatisticsContext};
81use datafusion_physical_plan::{ExecutionPlan, ExecutionPlanProperties};
82/// This rule inspects [`ExecutionPlan`]'s and pushes down the fetch limit from
83/// the parent to the child if applicable.
84#[derive(Default, Debug)]
85pub struct LimitPushdown {}
86
87/// This is a "data class" we use within the [`LimitPushdown`] rule to push
88/// down limits in the plan. GlobalRequirements are hold as a rule-wide state
89/// and holds the fetch and skip information. The struct also has a field named
90/// satisfied which means if the "current" plan is valid in terms of limits or not.
91///
92/// For example: If the plan is satisfied with current fetch info, we decide to not add a LocalLimit
93///
94/// [`LimitPushdown`]: crate::limit_pushdown::LimitPushdown
95#[derive(Default, Clone, Debug)]
96pub struct GlobalRequirements {
97    fetch: Option<usize>,
98    skip: usize,
99    satisfied: bool,
100    preserve_order: bool,
101}
102
103impl LimitPushdown {
104    #[expect(missing_docs)]
105    pub fn new() -> Self {
106        Self {}
107    }
108}
109
110impl PhysicalOptimizerRule for LimitPushdown {
111    fn optimize(
112        &self,
113        plan: Arc<dyn ExecutionPlan>,
114        _config: &ConfigOptions,
115    ) -> Result<Arc<dyn ExecutionPlan>> {
116        let global_state = GlobalRequirements {
117            fetch: None,
118            skip: 0,
119            satisfied: false,
120            preserve_order: false,
121        };
122        pushdown_limits(plan, global_state)
123    }
124
125    fn name(&self) -> &str {
126        "LimitPushdown"
127    }
128
129    fn schema_check(&self) -> bool {
130        true
131    }
132}
133
134struct LimitInfo {
135    input: Arc<dyn ExecutionPlan>,
136    fetch: Option<usize>,
137    skip: usize,
138    preserve_order: bool,
139}
140
141/// This function is the main helper function of the `LimitPushDown` rule.
142/// The helper takes an `ExecutionPlan` and a global (algorithm) state which is
143/// an instance of `GlobalRequirements` and modifies these parameters while
144/// checking if the limits can be pushed down or not.
145///
146/// If a limit is encountered, a [`TreeNodeRecursion::Stop`] is returned. Otherwise,
147/// return a [`TreeNodeRecursion::Continue`].
148pub fn pushdown_limit_helper(
149    mut pushdown_plan: Arc<dyn ExecutionPlan>,
150    mut global_state: GlobalRequirements,
151) -> Result<(Transformed<Arc<dyn ExecutionPlan>>, GlobalRequirements)> {
152    // Extract limit, if exist, and return child inputs.
153    if let Some(limit_info) = extract_limit(&pushdown_plan) {
154        // If we have fetch/skip info in the global state already, we need to
155        // decide which one to continue with:
156        let (skip, fetch) = combine_limit(
157            global_state.skip,
158            global_state.fetch,
159            limit_info.skip,
160            limit_info.fetch,
161        );
162        global_state.skip = skip;
163        global_state.fetch = fetch;
164        global_state.preserve_order = limit_info.preserve_order;
165        global_state.satisfied = false;
166
167        if let Some(fetch) = fetch
168            && limit_satisfied_by_input(&limit_info.input, skip, fetch)?
169        {
170            // The input already produces at most `fetch` rows, so no new limit
171            // node is needed. Mark satisfied so downstream won't re-add one,
172            // but preserve skip/fetch so any nested limit nodes (e.g. an inner
173            // GlobalLimitExec) can still be merged with the outer constraint.
174            global_state.satisfied = true;
175
176            return Ok((
177                Transformed {
178                    data: limit_info.input,
179                    transformed: true,
180                    tnr: TreeNodeRecursion::Stop,
181                },
182                global_state,
183            ));
184        }
185
186        // Now the global state has the most recent information, we can remove
187        // the limit node. We will decide later if we should add it again or
188        // not.
189        return Ok((
190            Transformed {
191                data: limit_info.input,
192                transformed: true,
193                tnr: TreeNodeRecursion::Stop,
194            },
195            global_state,
196        ));
197    }
198
199    // If we have a non-limit operator with fetch capability, update global
200    // state as necessary:
201    if pushdown_plan.fetch().is_some() {
202        if global_state.skip == 0 {
203            global_state.satisfied = true;
204        }
205        (global_state.skip, global_state.fetch) = combine_limit(
206            global_state.skip,
207            global_state.fetch,
208            0,
209            pushdown_plan.fetch(),
210        );
211    }
212
213    let Some(global_fetch) = global_state.fetch else {
214        // There's no valid fetch information, exit early:
215        return if global_state.skip > 0 && !global_state.satisfied {
216            // There might be a case with only offset, if so add a global limit:
217            global_state.satisfied = true;
218            Ok((
219                Transformed::yes(add_global_limit(
220                    pushdown_plan,
221                    global_state.skip,
222                    None,
223                )),
224                global_state,
225            ))
226        } else {
227            // There's no info on offset or fetch, nothing to do:
228            Ok((Transformed::no(pushdown_plan), global_state))
229        };
230    };
231
232    let skip_and_fetch = Some(global_fetch + global_state.skip);
233
234    if pushdown_plan.supports_limit_pushdown() {
235        if !combines_input_partitions(&pushdown_plan) {
236            // We have information in the global state and the plan pushes down,
237            // continue:
238            Ok((Transformed::no(pushdown_plan), global_state))
239        } else if let Some(plan_with_fetch) = pushdown_plan.with_fetch(skip_and_fetch) {
240            // This plan is combining input partitions, so we need to add the
241            // fetch info to plan if possible. If not, we must add a limit node
242            // with the information from the global state.
243            let mut new_plan = plan_with_fetch;
244            // Execution plans can't (yet) handle skip, so if we have one,
245            // we still need to add a global limit
246            if global_state.skip > 0 {
247                new_plan =
248                    add_global_limit(new_plan, global_state.skip, global_state.fetch);
249            }
250            global_state.fetch = skip_and_fetch;
251            global_state.skip = 0;
252            global_state.satisfied = true;
253            Ok((Transformed::yes(new_plan), global_state))
254        } else if global_state.satisfied {
255            // If the plan is already satisfied, do not add a limit:
256            Ok((Transformed::no(pushdown_plan), global_state))
257        } else {
258            global_state.satisfied = true;
259            Ok((
260                Transformed::yes(add_limit(
261                    pushdown_plan,
262                    global_state.skip,
263                    global_fetch,
264                )),
265                global_state,
266            ))
267        }
268    } else {
269        // The plan does not support push down and it is not a limit. We will need
270        // to add a limit or a fetch. If the plan is already satisfied, we will try
271        // to add the fetch info and return the plan.
272
273        // There's no push down, change fetch & skip to default values:
274        let global_skip = global_state.skip;
275        global_state.fetch = None;
276        global_state.skip = 0;
277
278        let maybe_fetchable = pushdown_plan.with_fetch(skip_and_fetch);
279        if global_state.satisfied {
280            if let Some(plan_with_fetch) = maybe_fetchable {
281                let plan_with_preserve_order = plan_with_fetch
282                    .with_preserve_order(global_state.preserve_order)
283                    .unwrap_or(plan_with_fetch);
284                Ok((Transformed::yes(plan_with_preserve_order), global_state))
285            } else {
286                Ok((Transformed::no(pushdown_plan), global_state))
287            }
288        } else {
289            global_state.satisfied = true;
290            pushdown_plan = if let Some(plan_with_fetch) = maybe_fetchable {
291                let plan_with_preserve_order = plan_with_fetch
292                    .with_preserve_order(global_state.preserve_order)
293                    .unwrap_or(plan_with_fetch);
294
295                if global_skip > 0 {
296                    add_global_limit(
297                        plan_with_preserve_order,
298                        global_skip,
299                        Some(global_fetch),
300                    )
301                } else {
302                    plan_with_preserve_order
303                }
304            } else {
305                add_limit(pushdown_plan, global_skip, global_fetch)
306            };
307            Ok((Transformed::yes(pushdown_plan), global_state))
308        }
309    }
310}
311
312/// Returns true if exact input statistics prove that applying the limit would
313/// not remove any rows.
314fn limit_satisfied_by_input(
315    plan: &Arc<dyn ExecutionPlan>,
316    skip: usize,
317    fetch: usize,
318) -> Result<bool> {
319    if skip > 0 {
320        return Ok(false);
321    }
322
323    if plan.output_partitioning().partition_count() != 1 {
324        return Ok(false);
325    }
326
327    let Some(num_rows) = limit_eliminable_exact_num_rows(plan)? else {
328        return Ok(false);
329    };
330
331    Ok(num_rows <= fetch)
332}
333
334/// Returns exact row counts only from a conservative whitelist of operators
335/// whose row-count guarantees are strong enough to remove a limit.
336fn limit_eliminable_exact_num_rows(
337    plan: &Arc<dyn ExecutionPlan>,
338) -> Result<Option<usize>> {
339    // Unwrap any wrapping ProjectionExec layers; projections preserve row count
340    // but may derive statistics in ways that are not trustworthy, so we peek
341    // through them to the underlying producer.
342    let mut current = plan;
343    while let Some(projection) = current.downcast_ref::<ProjectionExec>() {
344        current = projection.input();
345    }
346
347    if current.is::<EmptyExec>() {
348        return Ok(Some(0));
349    }
350
351    if current.is::<PlaceholderRowExec>() {
352        return Ok(Some(1));
353    }
354
355    if matches!(
356        StatisticsContext::new()
357            .compute(current.as_ref(), &StatisticsArgs::new())?
358            .num_rows,
359        Precision::Exact(0)
360    ) {
361        return Ok(Some(0));
362    }
363
364    Ok(None)
365}
366
367/// Pushes down the limit through the plan.
368pub(crate) fn pushdown_limits(
369    pushdown_plan: Arc<dyn ExecutionPlan>,
370    global_state: GlobalRequirements,
371) -> Result<Arc<dyn ExecutionPlan>> {
372    // Call pushdown_limit_helper.
373    // This will either extract the limit node (returning the child), or apply the limit pushdown.
374    let (mut new_node, mut global_state) =
375        pushdown_limit_helper(pushdown_plan, global_state)?;
376
377    // While limits exist, continue combining the global_state.
378    while new_node.tnr == TreeNodeRecursion::Stop {
379        (new_node, global_state) = pushdown_limit_helper(new_node.data, global_state)?;
380    }
381
382    // Once a limit has been materialized above the current node, child
383    // subtrees should not inherit its `skip`. Keep `fetch`, but clear
384    // `skip` before recursing so child-local limits are not merged with
385    // an `OFFSET` that has already been applied.
386    if global_state.satisfied {
387        global_state.skip = 0;
388    }
389
390    // Apply pushdown limits in children
391    let children = new_node.data.children();
392    let mut changed = false;
393    let new_children = children
394        .into_iter()
395        .map(|child: &Arc<dyn ExecutionPlan>| {
396            let new_child = pushdown_limits(
397                Arc::<dyn ExecutionPlan>::clone(child),
398                global_state.clone(),
399            )?;
400            // Tracking if any of the children changed
401            changed |= !Arc::ptr_eq(child, &new_child);
402            Ok(new_child)
403        })
404        .collect::<Result<_>>()?;
405
406    if changed {
407        replace_children_if_necessary(new_node.data, new_children)
408    } else {
409        Ok(new_node.data)
410    }
411}
412
413/// Extracts limit information from the [`ExecutionPlan`] if it is a
414/// [`GlobalLimitExec`] or a [`LocalLimitExec`].
415fn extract_limit(plan: &Arc<dyn ExecutionPlan>) -> Option<LimitInfo> {
416    if let Some(global_limit) = plan.downcast_ref::<GlobalLimitExec>() {
417        Some(LimitInfo {
418            input: Arc::clone(global_limit.input()),
419            fetch: global_limit.fetch(),
420            skip: global_limit.skip(),
421            preserve_order: global_limit.required_ordering().is_some(),
422        })
423    } else {
424        plan.downcast_ref::<LocalLimitExec>()
425            .map(|local_limit| LimitInfo {
426                input: Arc::clone(local_limit.input()),
427                fetch: Some(local_limit.fetch()),
428                skip: 0,
429                preserve_order: local_limit.required_ordering().is_some(),
430            })
431    }
432}
433
434/// Checks if the given plan combines input partitions.
435fn combines_input_partitions(plan: &Arc<dyn ExecutionPlan>) -> bool {
436    plan.is::<CoalescePartitionsExec>() || plan.is::<SortPreservingMergeExec>()
437}
438
439/// Adds a limit to the plan, chooses between global and local limits based on
440/// skip value and the number of partitions.
441fn add_limit(
442    pushdown_plan: Arc<dyn ExecutionPlan>,
443    skip: usize,
444    fetch: usize,
445) -> Arc<dyn ExecutionPlan> {
446    if skip > 0 || pushdown_plan.output_partitioning().partition_count() == 1 {
447        add_global_limit(pushdown_plan, skip, Some(fetch))
448    } else {
449        Arc::new(LocalLimitExec::new(pushdown_plan, fetch + skip)) as _
450    }
451}
452
453/// Adds a global limit to the plan.
454fn add_global_limit(
455    pushdown_plan: Arc<dyn ExecutionPlan>,
456    skip: usize,
457    fetch: Option<usize>,
458) -> Arc<dyn ExecutionPlan> {
459    Arc::new(GlobalLimitExec::new(pushdown_plan, skip, fetch)) as _
460}
461
462// See tests in datafusion/core/tests/physical_optimizer