You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@datafusion.apache.org by gi...@apache.org on 2026/05/31 08:59:34 UTC
(datafusion) 01/01: fix(physical-optimizer): make OutputRequirements idempotent (#22522)
This is an automated email from the ASF dual-hosted git repository.
github-merge-queue[bot] pushed a commit to branch gh-readonly-queue/main/pr-22522-fa724c1ad527c0cd171e14c856299a1e1ad30e01
in repository https://gitbox.apache.org/repos/asf/datafusion.git
commit d9ea38b95123159161c017840d3e6256e41988dd
Author: Xuanyi Li <sh...@outlook.com>
AuthorDate: Sun May 31 01:59:10 2026 -0700
fix(physical-optimizer): make OutputRequirements idempotent (#22522)
## Which issue does this PR close?
Related to https://github.com/apache/datafusion-ballista/issues/1359
## Rationale
Ballista's Adaptive Query Execution (AQE) planner re-invokes
DataFusion's full `PhysicalOptimizer` chain after every completed stage
(`AdaptivePlanner::replan_stages`). Rules that are not idempotent
(`rule(rule(x)) != rule(x)`) stack execution-plan nodes on each pass.
`OutputRequirements::new_add_mode()` wraps the plan root with
`OutputRequirementExec` to preserve global ordering/distribution
requirements. On a second pass the wrapper's `maintains_input_order() ==
[true]` and `required_input_ordering() == [None]` cause
`require_top_ordering_helper` to recurse through it and produce a
*second* wrapper, yielding
`OutputRequirementExec(OutputRequirementExec(...))`. Each AQE replan
adds another layer.
## What changes are included in this PR?
- **Guard in `require_top_ordering()`**: if the plan root is already an
`OutputRequirementExec`, return it unchanged. This makes the rule
idempotent with zero overhead for single-pass use.
- **Doc-comment update** on `new_add_mode()` and
`require_top_ordering()` documenting the idempotence guarantee.
- **Two tests** in `tests/physical_optimizer/output_requirements.rs`:
- `add_mode_is_idempotent_on_bare_scan` — bare `ParquetExec` (exercises
`is_changed = false` path).
- `add_mode_is_idempotent_on_sorted_plan` — `SortExec → ParquetExec`
(exercises `is_changed = true` path).
## Are these changes tested?
Yes. Two new tests run the rule twice on distinct fixtures and assert
structural equality via `get_plan_string`. Both fail without the fix
(double-wrapped `OutputRequirementExec`) and pass with it.
## Are there any user-facing changes?
No. `OutputRequirementExec` is an internal ancillary node stripped
before execution; the idempotence guard only affects re-optimization
scenarios (AQE).
Co-authored-by: Claude Opus 4.7 (1M context) <no...@anthropic.com>
---
datafusion/core/tests/physical_optimizer/mod.rs | 1 +
.../physical_optimizer/output_requirements.rs | 65 ++++++++++++++++++++++
.../physical-optimizer/src/output_requirements.rs | 12 +++-
3 files changed, 77 insertions(+), 1 deletion(-)
diff --git a/datafusion/core/tests/physical_optimizer/mod.rs b/datafusion/core/tests/physical_optimizer/mod.rs
index 801c2f30f9..f3b2884dab 100644
--- a/datafusion/core/tests/physical_optimizer/mod.rs
+++ b/datafusion/core/tests/physical_optimizer/mod.rs
@@ -30,6 +30,7 @@ mod join_selection;
#[expect(clippy::needless_pass_by_value)]
mod limit_pushdown;
mod limited_distinct_aggregation;
+mod output_requirements;
mod partition_statistics;
mod projection_pushdown;
mod pushdown_sort;
diff --git a/datafusion/core/tests/physical_optimizer/output_requirements.rs b/datafusion/core/tests/physical_optimizer/output_requirements.rs
new file mode 100644
index 0000000000..846589104e
--- /dev/null
+++ b/datafusion/core/tests/physical_optimizer/output_requirements.rs
@@ -0,0 +1,65 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements. See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership. The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License. You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied. See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+use std::sync::Arc;
+
+use crate::physical_optimizer::test_utils::{parquet_exec, schema, sort_exec, sort_expr};
+
+use datafusion_common::config::ConfigOptions;
+use datafusion_physical_expr_common::sort_expr::LexOrdering;
+use datafusion_physical_optimizer::PhysicalOptimizerRule;
+use datafusion_physical_optimizer::output_requirements::OutputRequirements;
+use datafusion_physical_plan::ExecutionPlan;
+use datafusion_physical_plan::get_plan_string;
+
+/// `OutputRequirements::new_add_mode()` must be idempotent: re-applying it to
+/// its own output must not stack additional `OutputRequirementExec` wrappers.
+///
+/// AQE (datafusion-ballista#1359) re-runs the optimizer chain after every
+/// completed stage; without this guarantee, every replan adds another wrapper.
+#[test]
+fn add_mode_is_idempotent_on_bare_scan() {
+ // Exercises the path where `require_top_ordering_helper` returns
+ // `is_changed = false` and the rule adds a default (empty-requirement)
+ // wrapper.
+ assert_add_mode_idempotent(parquet_exec(schema()));
+}
+
+#[test]
+fn add_mode_is_idempotent_on_sorted_plan() {
+ // Exercises the path where the helper recognizes a top-level `SortExec`
+ // and produces a wrapper carrying that ordering requirement
+ // (`is_changed = true` branch).
+ let s = schema();
+ let ordering: LexOrdering = [sort_expr("a", &s)].into();
+ let plan = sort_exec(ordering, parquet_exec(Arc::clone(&s)));
+ assert_add_mode_idempotent(plan);
+}
+
+fn assert_add_mode_idempotent(plan: Arc<dyn ExecutionPlan>) {
+ let config = ConfigOptions::new();
+ let rule = OutputRequirements::new_add_mode();
+
+ let once = rule.optimize(plan, &config).unwrap();
+ let twice = rule.optimize(Arc::clone(&once), &config).unwrap();
+
+ assert_eq!(
+ get_plan_string(&once),
+ get_plan_string(&twice),
+ "second invocation of OutputRequirements::new_add_mode mutated the plan",
+ );
+}
diff --git a/datafusion/physical-optimizer/src/output_requirements.rs b/datafusion/physical-optimizer/src/output_requirements.rs
index 24eb3af5f5..899abcc88b 100644
--- a/datafusion/physical-optimizer/src/output_requirements.rs
+++ b/datafusion/physical-optimizer/src/output_requirements.rs
@@ -61,7 +61,9 @@ impl OutputRequirements {
/// Create a new rule which works in `Add` mode; i.e. it simply adds a
/// top-level [`OutputRequirementExec`] into the physical plan to keep track
/// of global ordering and distribution requirements if there are any.
- /// Note that this rule should run at the beginning.
+ /// Note that this rule should run at the beginning. It is idempotent: when
+ /// invoked on a plan that is already topped by an `OutputRequirementExec`,
+ /// it returns the plan unchanged.
pub fn new_add_mode() -> Self {
Self {
mode: RuleMode::Add,
@@ -325,7 +327,15 @@ impl PhysicalOptimizerRule for OutputRequirements {
/// This functions adds ancillary `OutputRequirementExec` to the physical plan, so that
/// global requirements are not lost during optimization.
+///
+/// Idempotent: if the plan is already topped by an `OutputRequirementExec`, it
+/// is returned unchanged so that re-running this rule (as adaptive execution
+/// in datafusion-ballista AQE does after every completed stage, see
+/// datafusion-ballista#1359) does not stack wrappers.
fn require_top_ordering(plan: Arc<dyn ExecutionPlan>) -> Result<Arc<dyn ExecutionPlan>> {
+ if plan.downcast_ref::<OutputRequirementExec>().is_some() {
+ return Ok(plan);
+ }
let (new_plan, is_changed) = require_top_ordering_helper(plan)?;
if is_changed {
Ok(new_plan)
---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@datafusion.apache.org
For additional commands, e-mail: commits-help@datafusion.apache.org