Skip to content

Commit cae813a

Browse files
wirybeaverclaude
andcommitted
fix(physical-optimizer): make OutputRequirements idempotent
`OutputRequirements::new_add_mode()` was stacking a new `OutputRequirementExec` wrapper at the top of the plan on every invocation. `require_top_ordering_helper` descends through any operator that maintains input order and has no hard ordering requirement, and `OutputRequirementExec` itself qualifies — so on a second pass the helper walked past the existing wrapper, found no SortExec/SPM beneath, and `require_top_ordering` added a fresh wrapper above the old one. Fix: at the start of `require_top_ordering`, return the plan unchanged when it is already topped by an `OutputRequirementExec`. The existing wrapper was either added by this rule's prior run (in which case it is already correct) or inserted intentionally by the caller (in which case we should not disturb it). Motivation: adaptive execution in datafusion-ballista AQE (apache/datafusion-ballista#1359) re-runs the entire `PhysicalOptimizer` chain after every completed stage. Although the chain-level effect is masked when `OutputRequirements::new_remove_mode()` later strips the wrapper, the rule itself violates the idempotence property that `PhysicalOptimizerRule`s are expected to satisfy. Surfaced by the discovery harness added in a sibling PR. Adds `datafusion/core/tests/physical_optimizer/output_requirements.rs` with a focused test that invokes `new_add_mode` twice on a bare parquet scan and asserts structural equality. Fails before this fix (two stacked wrappers); passes after. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent e62f06c commit cae813a

3 files changed

Lines changed: 77 additions & 1 deletion

File tree

datafusion/core/tests/physical_optimizer/mod.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ mod join_selection;
2929
#[expect(clippy::needless_pass_by_value)]
3030
mod limit_pushdown;
3131
mod limited_distinct_aggregation;
32+
mod output_requirements;
3233
mod partition_statistics;
3334
mod projection_pushdown;
3435
mod pushdown_sort;
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
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+
use std::sync::Arc;
19+
20+
use crate::physical_optimizer::test_utils::{parquet_exec, schema, sort_exec, sort_expr};
21+
22+
use datafusion_common::config::ConfigOptions;
23+
use datafusion_physical_expr_common::sort_expr::LexOrdering;
24+
use datafusion_physical_optimizer::PhysicalOptimizerRule;
25+
use datafusion_physical_optimizer::output_requirements::OutputRequirements;
26+
use datafusion_physical_plan::ExecutionPlan;
27+
use datafusion_physical_plan::get_plan_string;
28+
29+
/// `OutputRequirements::new_add_mode()` must be idempotent: re-applying it to
30+
/// its own output must not stack additional `OutputRequirementExec` wrappers.
31+
///
32+
/// AQE (datafusion-ballista#1359) re-runs the optimizer chain after every
33+
/// completed stage; without this guarantee, every replan adds another wrapper.
34+
#[test]
35+
fn add_mode_is_idempotent_on_bare_scan() {
36+
// Exercises the path where `require_top_ordering_helper` returns
37+
// `is_changed = false` and the rule adds a default (empty-requirement)
38+
// wrapper.
39+
assert_add_mode_idempotent(parquet_exec(schema()));
40+
}
41+
42+
#[test]
43+
fn add_mode_is_idempotent_on_sorted_plan() {
44+
// Exercises the path where the helper recognizes a top-level `SortExec`
45+
// and produces a wrapper carrying that ordering requirement
46+
// (`is_changed = true` branch).
47+
let s = schema();
48+
let ordering: LexOrdering = [sort_expr("a", &s)].into();
49+
let plan = sort_exec(ordering, parquet_exec(Arc::clone(&s)));
50+
assert_add_mode_idempotent(plan);
51+
}
52+
53+
fn assert_add_mode_idempotent(plan: Arc<dyn ExecutionPlan>) {
54+
let config = ConfigOptions::new();
55+
let rule = OutputRequirements::new_add_mode();
56+
57+
let once = rule.optimize(plan, &config).unwrap();
58+
let twice = rule.optimize(Arc::clone(&once), &config).unwrap();
59+
60+
assert_eq!(
61+
get_plan_string(&once),
62+
get_plan_string(&twice),
63+
"second invocation of OutputRequirements::new_add_mode mutated the plan",
64+
);
65+
}

datafusion/physical-optimizer/src/output_requirements.rs

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,9 @@ impl OutputRequirements {
6363
/// Create a new rule which works in `Add` mode; i.e. it simply adds a
6464
/// top-level [`OutputRequirementExec`] into the physical plan to keep track
6565
/// of global ordering and distribution requirements if there are any.
66-
/// Note that this rule should run at the beginning.
66+
/// Note that this rule should run at the beginning. It is idempotent: when
67+
/// invoked on a plan that is already topped by an `OutputRequirementExec`,
68+
/// it returns the plan unchanged.
6769
pub fn new_add_mode() -> Self {
6870
Self {
6971
mode: RuleMode::Add,
@@ -357,7 +359,15 @@ impl PhysicalOptimizerRule for OutputRequirements {
357359

358360
/// This functions adds ancillary `OutputRequirementExec` to the physical plan, so that
359361
/// global requirements are not lost during optimization.
362+
///
363+
/// Idempotent: if the plan is already topped by an `OutputRequirementExec`, it
364+
/// is returned unchanged so that re-running this rule (as adaptive execution
365+
/// in datafusion-ballista AQE does after every completed stage, see
366+
/// datafusion-ballista#1359) does not stack wrappers.
360367
fn require_top_ordering(plan: Arc<dyn ExecutionPlan>) -> Result<Arc<dyn ExecutionPlan>> {
368+
if plan.downcast_ref::<OutputRequirementExec>().is_some() {
369+
return Ok(plan);
370+
}
361371
let (new_plan, is_changed) = require_top_ordering_helper(plan)?;
362372
if is_changed {
363373
Ok(new_plan)

0 commit comments

Comments
 (0)