Skip to content

Commit a6aad77

Browse files
committed
fix: enforce pending limits before fan-out
Signed-off-by: discord9 <55937128+discord9@users.noreply.github.com>
1 parent f768fab commit a6aad77

2 files changed

Lines changed: 239 additions & 0 deletions

File tree

‎datafusion/core/tests/physical_optimizer/limit_pushdown.rs‎

Lines changed: 214 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -289,6 +289,115 @@ impl ExecutionPlan for TestFetchOnlyExec {
289289
}
290290
}
291291

292+
/// Test multi-child plan with a single output partition that allows limit
293+
/// pushdown. Optionally absorbs a fetch via `with_fetch`.
294+
#[derive(Debug, Clone)]
295+
struct TestMultiChildExec {
296+
inputs: Vec<Arc<dyn ExecutionPlan>>,
297+
properties: Arc<PlanProperties>,
298+
supports_fetch: bool,
299+
fetch: Option<usize>,
300+
}
301+
302+
impl TestMultiChildExec {
303+
fn new(inputs: Vec<Arc<dyn ExecutionPlan>>) -> Self {
304+
let properties = PlanProperties::new(
305+
EquivalenceProperties::new(inputs[0].schema()),
306+
Partitioning::UnknownPartitioning(1),
307+
EmissionType::Incremental,
308+
Boundedness::Bounded,
309+
);
310+
Self {
311+
inputs,
312+
properties: Arc::new(properties),
313+
supports_fetch: false,
314+
fetch: None,
315+
}
316+
}
317+
318+
/// Set whether `with_fetch()` returns `Some` (true) or `None` (false).
319+
fn with_supports_fetch(mut self, supports: bool) -> Self {
320+
self.supports_fetch = supports;
321+
self
322+
}
323+
}
324+
325+
impl DisplayAs for TestMultiChildExec {
326+
fn fmt_as(&self, _t: DisplayFormatType, f: &mut Formatter) -> std::fmt::Result {
327+
write!(f, "TestMultiChildExec")?;
328+
if let Some(fetch) = self.fetch {
329+
write!(f, ": fetch={fetch}")?;
330+
}
331+
Ok(())
332+
}
333+
}
334+
335+
impl ExecutionPlan for TestMultiChildExec {
336+
fn name(&self) -> &str {
337+
"TestMultiChildExec"
338+
}
339+
340+
fn properties(&self) -> &Arc<PlanProperties> {
341+
&self.properties
342+
}
343+
344+
fn children(&self) -> Vec<&Arc<dyn ExecutionPlan>> {
345+
self.inputs.iter().collect()
346+
}
347+
348+
fn apply_expressions(
349+
&self,
350+
_f: &mut dyn FnMut(&PhysicalExprRef) -> Result<TreeNodeRecursion>,
351+
) -> Result<TreeNodeRecursion> {
352+
// `TestMultiChildExec` owns no `PhysicalExpr`s.
353+
Ok(TreeNodeRecursion::Continue)
354+
}
355+
356+
fn with_new_children(
357+
self: Arc<Self>,
358+
children: Vec<Arc<dyn ExecutionPlan>>,
359+
) -> Result<Arc<dyn ExecutionPlan>> {
360+
assert_eq!(children.len(), self.inputs.len());
361+
let mut new_plan = Self::new(children).with_supports_fetch(self.supports_fetch);
362+
new_plan.fetch = self.fetch;
363+
Ok(Arc::new(new_plan))
364+
}
365+
366+
fn execute(
367+
&self,
368+
_partition: usize,
369+
_context: Arc<TaskContext>,
370+
) -> Result<SendableRecordBatchStream> {
371+
unreachable!("TestMultiChildExec is only used by optimizer tests")
372+
}
373+
374+
fn statistics_from_inputs(
375+
&self,
376+
_input_stats: &[Arc<Statistics>],
377+
_args: &StatisticsArgs,
378+
) -> Result<Arc<Statistics>> {
379+
Ok(Arc::new(Statistics::new_unknown(self.schema().as_ref())))
380+
}
381+
382+
fn supports_limit_pushdown(&self) -> bool {
383+
true
384+
}
385+
386+
fn with_fetch(&self, fetch: Option<usize>) -> Option<Arc<dyn ExecutionPlan>> {
387+
if self.supports_fetch {
388+
let mut new_plan = self.clone();
389+
new_plan.fetch = fetch;
390+
Some(Arc::new(new_plan))
391+
} else {
392+
None
393+
}
394+
}
395+
396+
fn fetch(&self) -> Option<usize> {
397+
self.fetch
398+
}
399+
}
400+
292401
#[test]
293402
fn transforms_streaming_table_exec_into_fetching_version_when_skip_is_zero() -> Result<()>
294403
{
@@ -573,6 +682,111 @@ fn materializes_pending_global_limit_below_extension_combiner() -> Result<()> {
573682
Ok(())
574683
}
575684

685+
#[test]
686+
fn materializes_global_limit_before_multi_child_extension() -> Result<()> {
687+
// Regression test: a pending global limit used to be cloned to every
688+
// child of a multi-child node, so each child applied the full LIMIT and
689+
// the merged output exceeded it. The limit must stay above the node.
690+
let schema = create_schema();
691+
let left =
692+
Arc::new(TestScan::new(Arc::clone(&schema), vec![]).with_supports_fetch(true));
693+
let right = Arc::new(TestScan::new(schema, vec![]).with_supports_fetch(true));
694+
let custom = Arc::new(TestMultiChildExec::new(vec![left, right]));
695+
let global_limit = global_limit_exec(custom, 0, Some(5));
696+
697+
let optimized = LimitPushdown::new().optimize(global_limit, &ConfigOptions::new())?;
698+
699+
insta::assert_snapshot!(
700+
format_plan(&optimized),
701+
@r"
702+
GlobalLimitExec: skip=0, fetch=5
703+
TestMultiChildExec
704+
TestScan: fetch=5
705+
TestScan: fetch=5
706+
"
707+
);
708+
709+
Ok(())
710+
}
711+
712+
#[test]
713+
fn materializes_global_offset_limit_before_multi_child_extension() -> Result<()> {
714+
// The offset stays in the GlobalLimitExec; children only get a fetch hint
715+
// of skip + fetch for early stopping.
716+
let schema = create_schema();
717+
let left =
718+
Arc::new(TestScan::new(Arc::clone(&schema), vec![]).with_supports_fetch(true));
719+
let right = Arc::new(TestScan::new(schema, vec![]).with_supports_fetch(true));
720+
let custom = Arc::new(TestMultiChildExec::new(vec![left, right]));
721+
let global_limit = global_limit_exec(custom, 2, Some(5));
722+
723+
let optimized = LimitPushdown::new().optimize(global_limit, &ConfigOptions::new())?;
724+
725+
insta::assert_snapshot!(
726+
format_plan(&optimized),
727+
@r"
728+
GlobalLimitExec: skip=2, fetch=5
729+
TestMultiChildExec
730+
TestScan: fetch=7
731+
TestScan: fetch=7
732+
"
733+
);
734+
735+
Ok(())
736+
}
737+
738+
#[test]
739+
fn multi_child_extension_absorbs_global_limit_and_hints_children() -> Result<()> {
740+
// When the multi-child node absorbs the fetch itself, no extra limit is
741+
// needed; children still receive the same fetch for early stopping.
742+
let schema = create_schema();
743+
let left =
744+
Arc::new(TestScan::new(Arc::clone(&schema), vec![]).with_supports_fetch(true));
745+
let right = Arc::new(TestScan::new(schema, vec![]).with_supports_fetch(true));
746+
let custom =
747+
Arc::new(TestMultiChildExec::new(vec![left, right]).with_supports_fetch(true));
748+
let global_limit = global_limit_exec(custom, 0, Some(5));
749+
750+
let optimized = LimitPushdown::new().optimize(global_limit, &ConfigOptions::new())?;
751+
752+
insta::assert_snapshot!(
753+
format_plan(&optimized),
754+
@r"
755+
TestMultiChildExec: fetch=5
756+
TestScan: fetch=5
757+
TestScan: fetch=5
758+
"
759+
);
760+
761+
Ok(())
762+
}
763+
764+
#[test]
765+
fn materializes_local_limit_before_multi_child_extension() -> Result<()> {
766+
// A local limit also cannot be replicated to every child of a multi-child
767+
// node with a single output partition.
768+
let schema = create_schema();
769+
let left =
770+
Arc::new(TestScan::new(Arc::clone(&schema), vec![]).with_supports_fetch(true));
771+
let right = Arc::new(TestScan::new(schema, vec![]).with_supports_fetch(true));
772+
let custom = Arc::new(TestMultiChildExec::new(vec![left, right]));
773+
let local_limit = local_limit_exec(custom, 5);
774+
775+
let optimized = LimitPushdown::new().optimize(local_limit, &ConfigOptions::new())?;
776+
777+
insta::assert_snapshot!(
778+
format_plan(&optimized),
779+
@r"
780+
GlobalLimitExec: skip=0, fetch=5
781+
TestMultiChildExec
782+
TestScan: fetch=5
783+
TestScan: fetch=5
784+
"
785+
);
786+
787+
Ok(())
788+
}
789+
576790
#[test]
577791
fn upgrades_pending_local_limit_before_extension_combiner() -> Result<()> {
578792
let schema = create_schema();

‎datafusion/physical-optimizer/src/limit_pushdown.rs‎

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,7 @@ use datafusion_physical_plan::placeholder_row::PlaceholderRowExec;
7878
use datafusion_physical_plan::projection::ProjectionExec;
7979
use datafusion_physical_plan::sorts::sort_preserving_merge::SortPreservingMergeExec;
8080
use datafusion_physical_plan::statistics::{StatisticsArgs, StatisticsContext};
81+
use datafusion_physical_plan::union::UnionExec;
8182
use datafusion_physical_plan::{ExecutionPlan, ExecutionPlanProperties};
8283
/// This rule inspects [`ExecutionPlan`]'s and pushes down the fetch limit from
8384
/// the parent to the child if applicable.
@@ -301,6 +302,30 @@ pub fn pushdown_limit_helper(
301302
let skip_and_fetch = Some(global_fetch + global_state.skip);
302303

303304
if pushdown_plan.supports_limit_pushdown() {
305+
// A pending limit cannot be replicated to every child of a multi-child
306+
// node: each child would enforce it and their merged output could
307+
// exceed it. Only a unary node can pass it through transparently, plus
308+
// `UnionExec` with `Local` (each output partition comes from one child).
309+
let can_delegate_pending = global_state.pending.is_none()
310+
|| pushdown_plan.children().len() <= 1
311+
|| (global_state.pending == Some(LimitScope::Local)
312+
&& pushdown_plan.is::<UnionExec>());
313+
if !can_delegate_pending {
314+
// Enforce the limit at this node's output, then let children stop
315+
// early with the same fetch hint.
316+
let new_plan = if global_state.skip > 0 {
317+
add_limit(pushdown_plan, global_state.skip, global_fetch)
318+
} else if let Some(plan_with_fetch) = pushdown_plan.with_fetch(skip_and_fetch)
319+
{
320+
plan_with_fetch
321+
} else {
322+
add_limit(pushdown_plan, 0, global_fetch)
323+
};
324+
global_state.fetch = skip_and_fetch;
325+
global_state.skip = 0;
326+
global_state.pending = None;
327+
return Ok((Transformed::yes(new_plan), global_state));
328+
}
304329
if !combines_input_partitions(&pushdown_plan) {
305330
// We have information in the global state and the plan pushes down,
306331
// continue:

0 commit comments

Comments
 (0)