Skip to main content

datafusion_physical_optimizer/
ensure_coop.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//! The [`EnsureCooperative`] optimizer rule inspects the physical plan to find all
19//! portions of the plan that will not yield cooperatively.
20//! It will insert `CooperativeExec` nodes where appropriate to ensure execution plans
21//! always yield cooperatively.
22
23use std::fmt::{Debug, Formatter};
24use std::sync::Arc;
25
26use crate::PhysicalOptimizerRule;
27
28use datafusion_common::Result;
29use datafusion_common::config::ConfigOptions;
30use datafusion_common::tree_node::{Transformed, TreeNode};
31use datafusion_physical_plan::ExecutionPlan;
32use datafusion_physical_plan::coop::CooperativeExec;
33use datafusion_physical_plan::execution_plan::{EvaluationType, SchedulingType};
34
35/// `EnsureCooperative` is a [`PhysicalOptimizerRule`] that inspects the physical plan for
36/// sub plans that do not participate in cooperative scheduling. The plan is subdivided into sub
37/// plans on eager evaluation boundaries. Leaf nodes and eager evaluation roots are checked
38/// to see if they participate in cooperative scheduling. Those that do no are wrapped in
39/// a [`CooperativeExec`] parent.
40pub struct EnsureCooperative {}
41
42impl EnsureCooperative {
43    pub fn new() -> Self {
44        Self {}
45    }
46}
47
48impl Default for EnsureCooperative {
49    fn default() -> Self {
50        Self::new()
51    }
52}
53
54impl Debug for EnsureCooperative {
55    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
56        f.debug_struct(self.name()).finish()
57    }
58}
59
60impl PhysicalOptimizerRule for EnsureCooperative {
61    fn name(&self) -> &str {
62        "EnsureCooperative"
63    }
64
65    fn optimize(
66        &self,
67        plan: Arc<dyn ExecutionPlan>,
68        _config: &ConfigOptions,
69    ) -> Result<Arc<dyn ExecutionPlan>> {
70        use std::cell::RefCell;
71
72        let ancestry_stack = RefCell::new(Vec::<(SchedulingType, EvaluationType)>::new());
73
74        plan.transform_down_up(
75            // Down phase: Push parent properties <SchedulingType, EvaluationType> into the stack
76            |plan| {
77                let props = plan.properties();
78                ancestry_stack
79                    .borrow_mut()
80                    .push((props.scheduling_type, props.evaluation_type));
81                Ok(Transformed::no(plan))
82            },
83            // Up phase: Wrap nodes with CooperativeExec if needed
84            |plan| {
85                ancestry_stack.borrow_mut().pop();
86
87                let props = plan.properties();
88                let is_cooperative = props.scheduling_type == SchedulingType::Cooperative;
89                let is_leaf = plan.children().is_empty();
90                let is_exchange = props.evaluation_type == EvaluationType::Eager;
91
92                let mut is_under_cooperative_context = false;
93                for (scheduling_type, evaluation_type) in
94                    ancestry_stack.borrow().iter().rev()
95                {
96                    // If nearest ancestor is cooperative, we are under a cooperative context
97                    if *scheduling_type == SchedulingType::Cooperative {
98                        is_under_cooperative_context = true;
99                        break;
100                    // If nearest ancestor is eager, the cooperative context will be reset
101                    } else if *evaluation_type == EvaluationType::Eager {
102                        is_under_cooperative_context = false;
103                        break;
104                    }
105                }
106
107                // Wrap if:
108                // 1. Node is a leaf or exchange point
109                // 2. Node is not already cooperative
110                // 3. Not under any Cooperative context
111                if (is_leaf || is_exchange)
112                    && !is_cooperative
113                    && !is_under_cooperative_context
114                {
115                    return Ok(Transformed::yes(Arc::new(CooperativeExec::new(plan))));
116                }
117
118                Ok(Transformed::no(plan))
119            },
120        )
121        .map(|t| t.data)
122    }
123
124    fn schema_check(&self) -> bool {
125        // Wrapping a leaf in YieldStreamExec preserves the schema, so it is safe.
126        true
127    }
128}
129
130#[cfg(test)]
131mod tests {
132    use super::*;
133    use datafusion_physical_plan::{
134        ChildrenPropertiesMode, ReplaceChildrenOptions, displayable,
135        test::scan_partitioned,
136    };
137    use insta::assert_snapshot;
138
139    #[tokio::test]
140    async fn test_cooperative_exec_for_custom_exec() {
141        let test_custom_exec = scan_partitioned(1);
142        let config = ConfigOptions::new();
143        let optimized = EnsureCooperative::new()
144            .optimize(test_custom_exec, &config)
145            .unwrap();
146
147        let display = displayable(optimized.as_ref()).indent(true).to_string();
148        // Use insta snapshot to ensure full plan structure
149        assert_snapshot!(display, @r"
150        CooperativeExec
151          DataSourceExec: partitions=1, partition_sizes=[1]
152        ");
153    }
154
155    #[tokio::test]
156    async fn test_optimizer_is_idempotent() {
157        // Comprehensive idempotency test: verify f(f(...f(x))) = f(x)
158        // This test covers:
159        // 1. Multiple runs on unwrapped plan
160        // 2. Multiple runs on already-wrapped plan
161        // 3. No accumulation of CooperativeExec nodes
162
163        let config = ConfigOptions::new();
164        let rule = EnsureCooperative::new();
165
166        // Test 1: Start with unwrapped plan, run multiple times
167        let unwrapped_plan = scan_partitioned(1);
168        let mut current = unwrapped_plan;
169        let mut stable_result = String::new();
170
171        for run in 1..=5 {
172            current = rule.optimize(current, &config).unwrap();
173            let display = displayable(current.as_ref()).indent(true).to_string();
174
175            if run == 1 {
176                stable_result = display.clone();
177                assert_eq!(display.matches("CooperativeExec").count(), 1);
178            } else {
179                assert_eq!(
180                    display, stable_result,
181                    "Run {run} should match run 1 (idempotent)"
182                );
183                assert_eq!(
184                    display.matches("CooperativeExec").count(),
185                    1,
186                    "Should always have exactly 1 CooperativeExec, not accumulate"
187                );
188            }
189        }
190
191        // Test 2: Start with already-wrapped plan, verify no double wrapping
192        let pre_wrapped = Arc::new(CooperativeExec::new(scan_partitioned(1)));
193        let result = rule.optimize(pre_wrapped, &config).unwrap();
194        let display = displayable(result.as_ref()).indent(true).to_string();
195
196        assert_eq!(
197            display.matches("CooperativeExec").count(),
198            1,
199            "Should not double-wrap already cooperative plans"
200        );
201        assert_eq!(
202            display, stable_result,
203            "Pre-wrapped plan should produce same result as unwrapped after optimization"
204        );
205    }
206
207    #[tokio::test]
208    async fn test_selective_wrapping() {
209        // Test that wrapping is selective: only leaf/eager nodes, not intermediate nodes
210        // Also verify depth tracking prevents double wrapping in subtrees
211        use datafusion_physical_expr::expressions::lit;
212        use datafusion_physical_plan::filter::FilterExec;
213
214        let config = ConfigOptions::new();
215        let rule = EnsureCooperative::new();
216
217        // Case 1: Filter -> Scan (middle node should not be wrapped)
218        let scan = scan_partitioned(1);
219        let filter = Arc::new(FilterExec::try_new(lit(true), scan).unwrap());
220        let optimized = rule.optimize(filter, &config).unwrap();
221        let display = displayable(optimized.as_ref()).indent(true).to_string();
222
223        assert_eq!(display.matches("CooperativeExec").count(), 1);
224        assert!(display.contains("FilterExec"));
225
226        // Case 2: Filter -> CoopExec -> Scan (depth tracking prevents double wrap)
227        let scan2 = scan_partitioned(1);
228        let wrapped_scan = Arc::new(CooperativeExec::new(scan2));
229        let filter2 = Arc::new(FilterExec::try_new(lit(true), wrapped_scan).unwrap());
230        let optimized2 = rule.optimize(filter2, &config).unwrap();
231        let display2 = displayable(optimized2.as_ref()).indent(true).to_string();
232
233        assert_eq!(display2.matches("CooperativeExec").count(), 1);
234    }
235
236    #[tokio::test]
237    async fn test_multiple_leaf_nodes() {
238        // When there are multiple leaf nodes, each should be wrapped separately
239        use datafusion_physical_plan::union::UnionExec;
240
241        let scan1 = scan_partitioned(1);
242        let scan2 = scan_partitioned(1);
243        let union = UnionExec::try_new(vec![scan1, scan2]).unwrap();
244
245        let config = ConfigOptions::new();
246        let optimized = EnsureCooperative::new()
247            .optimize(union as Arc<dyn ExecutionPlan>, &config)
248            .unwrap();
249
250        let display = displayable(optimized.as_ref()).indent(true).to_string();
251
252        // Each leaf should have its own CooperativeExec
253        assert_eq!(
254            display.matches("CooperativeExec").count(),
255            2,
256            "Each leaf node should be wrapped separately"
257        );
258        assert_eq!(
259            display.matches("DataSourceExec").count(),
260            2,
261            "Both data sources should be present"
262        );
263    }
264
265    #[tokio::test]
266    async fn test_eager_evaluation_resets_cooperative_context() {
267        // Test that cooperative context is reset when encountering an eager evaluation boundary.
268        use arrow::datatypes::Schema;
269        use datafusion_common::internal_err;
270        use datafusion_common::tree_node::TreeNodeRecursion;
271        use datafusion_execution::TaskContext;
272        use datafusion_physical_expr::EquivalenceProperties;
273        use datafusion_physical_plan::{
274            DisplayAs, DisplayFormatType, Partitioning, PhysicalExpr, PlanProperties,
275            SendableRecordBatchStream,
276            execution_plan::{Boundedness, EmissionType},
277        };
278
279        #[derive(Debug)]
280        struct DummyExec {
281            name: String,
282            input: Arc<dyn ExecutionPlan>,
283            scheduling_type: SchedulingType,
284            evaluation_type: EvaluationType,
285            properties: Arc<PlanProperties>,
286        }
287
288        impl DummyExec {
289            fn new(
290                name: &str,
291                input: Arc<dyn ExecutionPlan>,
292                scheduling_type: SchedulingType,
293                evaluation_type: EvaluationType,
294            ) -> Self {
295                let properties = PlanProperties::new(
296                    EquivalenceProperties::new(Arc::new(Schema::empty())),
297                    Partitioning::UnknownPartitioning(1),
298                    EmissionType::Incremental,
299                    Boundedness::Bounded,
300                )
301                .with_scheduling_type(scheduling_type)
302                .with_evaluation_type(evaluation_type);
303
304                Self {
305                    name: name.to_string(),
306                    input,
307                    scheduling_type,
308                    evaluation_type,
309                    properties: Arc::new(properties),
310                }
311            }
312        }
313
314        impl DisplayAs for DummyExec {
315            fn fmt_as(
316                &self,
317                _: DisplayFormatType,
318                f: &mut Formatter,
319            ) -> std::fmt::Result {
320                write!(f, "{}", self.name)
321            }
322        }
323
324        impl ExecutionPlan for DummyExec {
325            fn name(&self) -> &str {
326                &self.name
327            }
328            fn properties(&self) -> &Arc<PlanProperties> {
329                &self.properties
330            }
331            fn children(&self) -> Vec<&Arc<dyn ExecutionPlan>> {
332                vec![&self.input]
333            }
334            fn replace_children(
335                self: Arc<Self>,
336                children: Vec<Arc<dyn ExecutionPlan>>,
337                _: ReplaceChildrenOptions,
338            ) -> Result<Arc<dyn ExecutionPlan>> {
339                Ok(Arc::new(DummyExec::new(
340                    &self.name,
341                    Arc::clone(&children[0]),
342                    self.scheduling_type,
343                    self.evaluation_type,
344                )))
345            }
346            fn with_new_children(
347                self: Arc<Self>,
348                children: Vec<Arc<dyn ExecutionPlan>>,
349            ) -> Result<Arc<dyn ExecutionPlan>> {
350                self.replace_children(
351                    children,
352                    ReplaceChildrenOptions::new(ChildrenPropertiesMode::Recompute),
353                )
354            }
355            fn execute(
356                &self,
357                _: usize,
358                _: Arc<TaskContext>,
359            ) -> Result<SendableRecordBatchStream> {
360                internal_err!("DummyExec does not support execution")
361            }
362
363            fn apply_expressions(
364                &self,
365                _f: &mut dyn FnMut(&Arc<dyn PhysicalExpr>) -> Result<TreeNodeRecursion>,
366            ) -> Result<TreeNodeRecursion> {
367                Ok(TreeNodeRecursion::Continue)
368            }
369        }
370
371        // Build a plan similar to the original test:
372        // scan -> exch1(NonCoop,Eager) -> CoopExec -> filter -> exch2(Coop,Eager) -> filter
373        let scan = scan_partitioned(1);
374        let exch1 = Arc::new(DummyExec::new(
375            "exch1",
376            scan,
377            SchedulingType::NonCooperative,
378            EvaluationType::Eager,
379        ));
380        let coop = Arc::new(CooperativeExec::new(exch1));
381        let filter1 = Arc::new(DummyExec::new(
382            "filter1",
383            coop,
384            SchedulingType::NonCooperative,
385            EvaluationType::Lazy,
386        ));
387        let exch2 = Arc::new(DummyExec::new(
388            "exch2",
389            filter1,
390            SchedulingType::Cooperative,
391            EvaluationType::Eager,
392        ));
393        let filter2 = Arc::new(DummyExec::new(
394            "filter2",
395            exch2,
396            SchedulingType::NonCooperative,
397            EvaluationType::Lazy,
398        ));
399
400        let config = ConfigOptions::new();
401        let optimized = EnsureCooperative::new().optimize(filter2, &config).unwrap();
402
403        let display = displayable(optimized.as_ref()).indent(true).to_string();
404
405        // Expected wrapping:
406        // - Scan (leaf) gets wrapped
407        // - exch1 (eager+noncoop) keeps its manual CooperativeExec wrapper
408        // - filter1 is protected by exch2's cooperative context, no extra wrap
409        // - exch2 (already Cooperative) does NOT get wrapped
410        // - filter2 (not leaf or eager) does NOT get wrapped
411        assert_eq!(
412            display.matches("CooperativeExec").count(),
413            2,
414            "Should have 2 CooperativeExec: one wrapping scan, one wrapping exch1"
415        );
416
417        assert_snapshot!(display, @r"
418        filter2
419          exch2
420            filter1
421              CooperativeExec
422                exch1
423                  CooperativeExec
424                    DataSourceExec: partitions=1, partition_sizes=[1]
425        ");
426    }
427}