Skip to main content

datafusion_physical_plan/
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//! Utilities for improved cooperative scheduling.
19//!
20//! # Cooperative scheduling
21//!
22//! A single call to `poll_next` on a top-level [`Stream`] may potentially perform a lot of work
23//! before it returns a `Poll::Pending`. Think for instance of calculating an aggregation over a
24//! large dataset.
25//!
26//! If a `Stream` runs for a long period of time without yielding back to the Tokio executor,
27//! it can starve other tasks waiting on that executor to execute them.
28//! Additionally, this prevents the query execution from being cancelled.
29//!
30//! For more background, please also see the [Using Rust async for Query Execution and Cancelling Long-Running Queries blog]
31//!
32//! [Using Rust async for Query Execution and Cancelling Long-Running Queries blog]: https://datafusion.apache.org/blog/2025/06/30/cancellation
33//!
34//! To ensure that `Stream` implementations yield regularly, operators can insert explicit yield
35//! points using the utilities in this module. For most operators this is **not** necessary. The
36//! `Stream`s of the built-in DataFusion operators that generate (rather than manipulate)
37//! `RecordBatch`es such as `DataSourceExec` and those that eagerly consume `RecordBatch`es
38//! (for instance, `RepartitionExec`) contain yield points that will make most query `Stream`s yield
39//! periodically.
40//!
41//! There are a couple of types of operators that _should_ insert yield points:
42//! - New source operators that do not make use of Tokio resources
43//! - Exchange like operators that do not use Tokio's `Channel` implementation to pass data between
44//!   tasks
45//!
46//! ## Adding yield points
47//!
48//! Yield points can be inserted manually using the facilities provided by the
49//! [Tokio coop module](https://docs.rs/tokio/latest/tokio/task/coop/index.html) such as
50//! [`tokio::task::coop::consume_budget`](https://docs.rs/tokio/latest/tokio/task/coop/fn.consume_budget.html).
51//!
52//! Another option is to use the wrapper `Stream` implementation provided by this module which will
53//! consume a unit of task budget every time a `RecordBatch` is produced.
54//! Wrapper `Stream`s can be created using the [`cooperative`] and [`make_cooperative`] functions.
55//!
56//! [`cooperative`] is a generic function that takes ownership of the wrapped [`RecordBatchStream`].
57//! This function has the benefit of not requiring an additional heap allocation and can avoid
58//! dynamic dispatch.
59//!
60//! [`make_cooperative`] is a non-generic function that wraps a [`SendableRecordBatchStream`]. This
61//! can be used to wrap dynamically typed, heap allocated [`RecordBatchStream`]s.
62//!
63//! ## Automatic cooperation
64//!
65//! The `EnsureCooperative` physical optimizer rule, which is included in the default set of
66//! optimizer rules, inspects query plans for potential cooperative scheduling issues.
67//! It injects the [`CooperativeExec`] wrapper `ExecutionPlan` into the query plan where necessary.
68//! This `ExecutionPlan` uses [`make_cooperative`] to wrap the `Stream` of its input.
69//!
70//! The optimizer rule currently checks the plan for exchange-like operators and leave operators
71//! that report [`SchedulingType::NonCooperative`] in their [plan properties](ExecutionPlan::properties).
72
73use datafusion_common::config::ConfigOptions;
74use datafusion_common::tree_node::TreeNodeRecursion;
75use datafusion_physical_expr::PhysicalExpr;
76#[cfg(datafusion_coop = "tokio_fallback")]
77use futures::Future;
78use std::pin::Pin;
79use std::sync::Arc;
80use std::task::{Context, Poll};
81
82use crate::execution_plan::CardinalityEffect::{self, Equal};
83use crate::filter_pushdown::{
84    ChildPushdownResult, FilterDescription, FilterPushdownPhase,
85    FilterPushdownPropagation,
86};
87use crate::projection::ProjectionExec;
88use crate::statistics::{ChildStats, StatisticsArgs};
89use crate::{
90    ChildrenPropertiesMode, DisplayAs, DisplayFormatType, ExecutionPlan, PlanProperties,
91    RecordBatchStream, ReplaceChildrenOptions, SendableRecordBatchStream,
92    SortOrderPushdownResult, validate_child_count,
93};
94use arrow::record_batch::RecordBatch;
95use arrow_schema::Schema;
96use datafusion_common::{Result, Statistics};
97use datafusion_execution::TaskContext;
98
99use crate::execution_plan::{SchedulingType, replace_children_if_necessary};
100use crate::stream::RecordBatchStreamAdapter;
101use datafusion_physical_expr_common::sort_expr::PhysicalSortExpr;
102use futures::{Stream, StreamExt};
103
104/// A stream that passes record batches through unchanged while cooperating with the Tokio runtime.
105/// It consumes cooperative scheduling budget for each returned [`RecordBatch`],
106/// allowing other tasks to execute when the budget is exhausted.
107///
108/// See the [module level documentation](crate::coop) for an in-depth discussion.
109pub struct CooperativeStream<T>
110where
111    T: RecordBatchStream + Unpin,
112{
113    inner: T,
114    #[cfg(datafusion_coop = "per_stream")]
115    budget: u8,
116}
117
118#[cfg(datafusion_coop = "per_stream")]
119// Magic value that matches Tokio's task budget value
120const YIELD_FREQUENCY: u8 = 128;
121
122impl<T> CooperativeStream<T>
123where
124    T: RecordBatchStream + Unpin,
125{
126    /// Creates a new `CooperativeStream` that wraps the provided stream.
127    /// The resulting stream will cooperate with the Tokio scheduler by consuming a unit of
128    /// scheduling budget when the wrapped `Stream` returns a record batch.
129    pub fn new(inner: T) -> Self {
130        Self {
131            inner,
132            #[cfg(datafusion_coop = "per_stream")]
133            budget: YIELD_FREQUENCY,
134        }
135    }
136}
137
138impl<T> Stream for CooperativeStream<T>
139where
140    T: RecordBatchStream + Unpin,
141{
142    type Item = Result<RecordBatch>;
143
144    fn poll_next(
145        mut self: Pin<&mut Self>,
146        cx: &mut Context<'_>,
147    ) -> Poll<Option<Self::Item>> {
148        #[cfg(any(
149            datafusion_coop = "tokio",
150            not(any(
151                datafusion_coop = "tokio_fallback",
152                datafusion_coop = "per_stream"
153            ))
154        ))]
155        {
156            let coop = std::task::ready!(tokio::task::coop::poll_proceed(cx));
157            let value = self.inner.poll_next_unpin(cx);
158            if value.is_ready() {
159                coop.made_progress();
160            }
161            value
162        }
163
164        #[cfg(datafusion_coop = "tokio_fallback")]
165        {
166            // This is a temporary placeholder implementation that may have slightly
167            // worse performance compared to `poll_proceed`
168            if !tokio::task::coop::has_budget_remaining() {
169                cx.waker().wake_by_ref();
170                return Poll::Pending;
171            }
172
173            let value = self.inner.poll_next_unpin(cx);
174            if value.is_ready() {
175                // In contrast to `poll_proceed` we are not able to consume
176                // budget before proceeding to do work. Instead, we try to consume budget
177                // after the work has been done and just assume that that succeeded.
178                // The poll result is ignored because we don't want to discard
179                // or buffer the Ready result we got from the inner stream.
180                let consume = tokio::task::coop::consume_budget();
181                let consume_ref = std::pin::pin!(consume);
182                let _ = consume_ref.poll(cx);
183            }
184            value
185        }
186
187        #[cfg(datafusion_coop = "per_stream")]
188        {
189            if self.budget == 0 {
190                self.budget = YIELD_FREQUENCY;
191                cx.waker().wake_by_ref();
192                return Poll::Pending;
193            }
194
195            let value = { self.inner.poll_next_unpin(cx) };
196
197            if value.is_ready() {
198                self.budget -= 1;
199            } else {
200                self.budget = YIELD_FREQUENCY;
201            }
202            value
203        }
204    }
205}
206
207impl<T> RecordBatchStream for CooperativeStream<T>
208where
209    T: RecordBatchStream + Unpin,
210{
211    fn schema(&self) -> Arc<Schema> {
212        self.inner.schema()
213    }
214}
215
216/// An execution plan decorator that enables cooperative multitasking.
217/// It wraps the streams produced by its input execution plan using the [`make_cooperative`] function,
218/// which makes the stream participate in Tokio cooperative scheduling.
219#[derive(Debug, Clone)]
220pub struct CooperativeExec {
221    input: Arc<dyn ExecutionPlan>,
222    properties: Arc<PlanProperties>,
223}
224
225impl CooperativeExec {
226    /// Creates a new `CooperativeExec` operator that wraps the given input execution plan.
227    pub fn new(input: Arc<dyn ExecutionPlan>) -> Self {
228        let properties = PlanProperties::clone(input.properties())
229            .with_scheduling_type(SchedulingType::Cooperative)
230            .into();
231
232        Self { input, properties }
233    }
234
235    /// Returns a reference to the wrapped input execution plan.
236    pub fn input(&self) -> &Arc<dyn ExecutionPlan> {
237        &self.input
238    }
239}
240
241impl DisplayAs for CooperativeExec {
242    fn fmt_as(
243        &self,
244        _t: DisplayFormatType,
245        f: &mut std::fmt::Formatter<'_>,
246    ) -> std::fmt::Result {
247        write!(f, "CooperativeExec")
248    }
249}
250
251impl ExecutionPlan for CooperativeExec {
252    fn name(&self) -> &str {
253        "CooperativeExec"
254    }
255
256    fn schema(&self) -> Arc<Schema> {
257        self.input.schema()
258    }
259
260    fn properties(&self) -> &Arc<PlanProperties> {
261        &self.properties
262    }
263
264    fn maintains_input_order(&self) -> Vec<bool> {
265        vec![true; self.children().len()]
266    }
267
268    fn children(&self) -> Vec<&Arc<dyn ExecutionPlan>> {
269        vec![&self.input]
270    }
271
272    fn apply_expressions(
273        &self,
274        _f: &mut dyn FnMut(&Arc<dyn PhysicalExpr>) -> Result<TreeNodeRecursion>,
275    ) -> Result<TreeNodeRecursion> {
276        Ok(TreeNodeRecursion::Continue)
277    }
278
279    fn replace_children(
280        self: Arc<Self>,
281        mut children: Vec<Arc<dyn ExecutionPlan>>,
282        options: ReplaceChildrenOptions,
283    ) -> Result<Arc<dyn ExecutionPlan>> {
284        validate_child_count!(self, children);
285        match options.children_properties {
286            ChildrenPropertiesMode::Keep => Ok(Arc::new(Self {
287                input: children.swap_remove(0),
288                ..Self::clone(&*self)
289            })),
290            ChildrenPropertiesMode::Recompute => {
291                Ok(Arc::new(CooperativeExec::new(children.swap_remove(0))))
292            }
293        }
294    }
295
296    fn with_new_children(
297        self: Arc<Self>,
298        children: Vec<Arc<dyn ExecutionPlan>>,
299    ) -> Result<Arc<dyn ExecutionPlan>> {
300        self.replace_children(
301            children,
302            ReplaceChildrenOptions::new(ChildrenPropertiesMode::Recompute),
303        )
304    }
305
306    fn with_new_children_and_same_properties(
307        self: Arc<Self>,
308        children: Vec<Arc<dyn ExecutionPlan>>,
309    ) -> Result<Arc<dyn ExecutionPlan>> {
310        self.replace_children(
311            children,
312            ReplaceChildrenOptions::new(ChildrenPropertiesMode::Keep),
313        )
314    }
315
316    fn execute(
317        &self,
318        partition: usize,
319        task_ctx: Arc<TaskContext>,
320    ) -> Result<SendableRecordBatchStream> {
321        let child_stream = self.input.execute(partition, task_ctx)?;
322        Ok(make_cooperative(child_stream))
323    }
324
325    fn child_stats_requests(&self, partition: Option<usize>) -> Vec<ChildStats> {
326        vec![ChildStats::At(partition)]
327    }
328
329    fn statistics_from_inputs(
330        &self,
331        input_stats: &[Arc<Statistics>],
332        _args: &StatisticsArgs,
333    ) -> Result<Arc<Statistics>> {
334        Ok(Arc::clone(&input_stats[0]))
335    }
336
337    fn supports_limit_pushdown(&self) -> bool {
338        true
339    }
340
341    fn cardinality_effect(&self) -> CardinalityEffect {
342        Equal
343    }
344
345    fn try_swapping_with_projection(
346        &self,
347        projection: &ProjectionExec,
348    ) -> Result<Option<Arc<dyn ExecutionPlan>>> {
349        match self.input.try_swapping_with_projection(projection)? {
350            Some(new_input) => Ok(Some(replace_children_if_necessary(
351                Arc::new(self.clone()),
352                vec![new_input],
353            )?)),
354            None => Ok(None),
355        }
356    }
357
358    fn gather_filters_for_pushdown(
359        &self,
360        _phase: FilterPushdownPhase,
361        parent_filters: Vec<Arc<dyn PhysicalExpr>>,
362        _config: &ConfigOptions,
363    ) -> Result<FilterDescription> {
364        FilterDescription::from_children(parent_filters, &self.children())
365    }
366
367    fn handle_child_pushdown_result(
368        &self,
369        _phase: FilterPushdownPhase,
370        child_pushdown_result: ChildPushdownResult,
371        _config: &ConfigOptions,
372    ) -> Result<FilterPushdownPropagation<Arc<dyn ExecutionPlan>>> {
373        Ok(FilterPushdownPropagation::if_all(child_pushdown_result))
374    }
375
376    fn try_pushdown_sort(
377        &self,
378        order: &[PhysicalSortExpr],
379    ) -> Result<SortOrderPushdownResult<Arc<dyn ExecutionPlan>>> {
380        let child = self.input();
381
382        match child.try_pushdown_sort(order)? {
383            SortOrderPushdownResult::Exact { inner } => {
384                let new_exec =
385                    replace_children_if_necessary(Arc::new(self.clone()), vec![inner])?;
386                Ok(SortOrderPushdownResult::Exact { inner: new_exec })
387            }
388            SortOrderPushdownResult::Inexact { inner } => {
389                let new_exec =
390                    replace_children_if_necessary(Arc::new(self.clone()), vec![inner])?;
391                Ok(SortOrderPushdownResult::Inexact { inner: new_exec })
392            }
393            SortOrderPushdownResult::Unsupported => {
394                Ok(SortOrderPushdownResult::Unsupported)
395            }
396        }
397    }
398
399    #[cfg(feature = "proto")]
400    fn try_to_proto(
401        &self,
402        ctx: &crate::proto::ExecutionPlanEncodeCtx<'_>,
403    ) -> Result<Option<datafusion_proto_models::protobuf::PhysicalPlanNode>> {
404        use datafusion_proto_models::protobuf;
405        let input = ctx.encode_child(self.input())?;
406        Ok(Some(protobuf::PhysicalPlanNode {
407            physical_plan_type: Some(
408                protobuf::physical_plan_node::PhysicalPlanType::Cooperative(Box::new(
409                    protobuf::CooperativeExecNode {
410                        input: Some(Box::new(input)),
411                    },
412                )),
413            ),
414        }))
415    }
416}
417
418#[cfg(feature = "proto")]
419impl CooperativeExec {
420    /// Reconstruct a [`CooperativeExec`] from its protobuf representation.
421    ///
422    /// The exact inverse of [`ExecutionPlan::try_to_proto`].
423    ///
424    /// [`ExecutionPlan::try_to_proto`]: crate::ExecutionPlan::try_to_proto
425    pub fn try_from_proto(
426        node: &datafusion_proto_models::protobuf::PhysicalPlanNode,
427        ctx: &crate::proto::ExecutionPlanDecodeCtx<'_>,
428    ) -> Result<Arc<dyn ExecutionPlan>> {
429        use datafusion_proto_models::protobuf;
430        let cooperative = crate::expect_plan_variant!(
431            node,
432            protobuf::physical_plan_node::PhysicalPlanType::Cooperative,
433            "CooperativeExec",
434        );
435        let input = ctx.decode_required_child(
436            cooperative.input.as_deref(),
437            "CooperativeExec",
438            "input",
439        )?;
440        Ok(Arc::new(CooperativeExec::new(input)))
441    }
442}
443
444/// Creates a [`CooperativeStream`] wrapper around the given [`RecordBatchStream`].
445/// This wrapper collaborates with the Tokio cooperative scheduler by consuming a unit of
446/// scheduling budget for each returned record batch.
447pub fn cooperative<T>(stream: T) -> CooperativeStream<T>
448where
449    T: RecordBatchStream + Unpin + Send + 'static,
450{
451    CooperativeStream::new(stream)
452}
453
454/// Wraps a `SendableRecordBatchStream` inside a [`CooperativeStream`] to enable cooperative multitasking.
455/// Since `SendableRecordBatchStream` is a `dyn RecordBatchStream` this requires the use of dynamic
456/// method dispatch.
457/// When the stream type is statically known, consider use the generic [`cooperative`] function
458/// to allow static method dispatch.
459pub fn make_cooperative(stream: SendableRecordBatchStream) -> SendableRecordBatchStream {
460    // TODO is there a more elegant way to overload cooperative
461    Box::pin(cooperative(RecordBatchStreamAdapter::new(
462        stream.schema(),
463        stream,
464    )))
465}
466
467#[cfg(test)]
468mod tests {
469    use super::*;
470
471    use arrow_schema::SchemaRef;
472
473    use futures::stream;
474
475    // This is the hardcoded value Tokio uses
476    const TASK_BUDGET: usize = 128;
477
478    /// Helper: construct a SendableRecordBatchStream containing `n` empty batches
479    fn make_empty_batches(n: usize) -> SendableRecordBatchStream {
480        let schema: SchemaRef = Arc::new(Schema::empty());
481        let schema_for_stream = Arc::clone(&schema);
482
483        let s =
484            stream::iter((0..n).map(move |_| {
485                Ok(RecordBatch::new_empty(Arc::clone(&schema_for_stream)))
486            }));
487
488        Box::pin(RecordBatchStreamAdapter::new(schema, s))
489    }
490
491    #[tokio::test]
492    async fn yield_less_than_threshold() -> Result<()> {
493        let count = TASK_BUDGET - 10;
494        let inner = make_empty_batches(count);
495        let out = make_cooperative(inner).collect::<Vec<_>>().await;
496        assert_eq!(out.len(), count);
497        Ok(())
498    }
499
500    #[tokio::test]
501    async fn yield_equal_to_threshold() -> Result<()> {
502        let count = TASK_BUDGET;
503        let inner = make_empty_batches(count);
504        let out = make_cooperative(inner).collect::<Vec<_>>().await;
505        assert_eq!(out.len(), count);
506        Ok(())
507    }
508
509    #[tokio::test]
510    async fn yield_more_than_threshold() -> Result<()> {
511        let count = TASK_BUDGET + 20;
512        let inner = make_empty_batches(count);
513        let out = make_cooperative(inner).collect::<Vec<_>>().await;
514        assert_eq!(out.len(), count);
515        Ok(())
516    }
517}