datafusion_physical_optimizer/
ensure_coop.rs1use 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
35pub 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 |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 |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 *scheduling_type == SchedulingType::Cooperative {
98 is_under_cooperative_context = true;
99 break;
100 } else if *evaluation_type == EvaluationType::Eager {
102 is_under_cooperative_context = false;
103 break;
104 }
105 }
106
107 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 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 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 let config = ConfigOptions::new();
164 let rule = EnsureCooperative::new();
165
166 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 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 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 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 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 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 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 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 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 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}