Skip to main content

alopex_dataframe/physical/
executor.rs

1use arrow::record_batch::RecordBatch;
2
3use crate::physical::budget::{ResourceBudget, ResourceReservation, ResourceScope};
4use crate::physical::operators;
5use crate::physical::plan::{PhysicalPlan, ScanSource};
6use crate::physical::streaming::DataFrameStream;
7use crate::{DataFrame, Result};
8
9/// Executes `PhysicalPlan` trees and returns the resulting `RecordBatch` output.
10pub struct Executor;
11
12impl Executor {
13    /// Execute a physical plan and return the resulting record batches.
14    pub fn execute(plan: PhysicalPlan) -> Result<Vec<RecordBatch>> {
15        execute_plan(&plan)
16    }
17}
18
19/// Bounded materializing collector for callers that explicitly opt into a resource limit.
20///
21/// This collector only takes ownership of a batch after its retained output reservation has
22/// succeeded. Source readers and operators must reserve their own allocations before creating
23/// a batch; the streaming executor introduced later supplies that contract at those boundaries.
24#[derive(Debug, Clone)]
25pub struct BudgetedMaterializedExecutor {
26    budget: ResourceBudget,
27}
28
29impl BudgetedMaterializedExecutor {
30    /// Construct a collector using the supplied shared resource budget.
31    pub fn new(budget: ResourceBudget) -> Self {
32        Self { budget }
33    }
34
35    /// Materialize already-produced batches without exceeding the retained-output reservation.
36    ///
37    /// The returned `DataFrame` transfers batch ownership to the caller, so this collector's
38    /// reservations are released before returning. An error drops all retained batches and their
39    /// reservations.
40    pub fn collect_batches<I>(&self, batches: I) -> Result<DataFrame>
41    where
42        I: IntoIterator<Item = Result<RecordBatch>>,
43    {
44        let mut retained = Vec::new();
45        let mut reservations: Vec<ResourceReservation> = Vec::new();
46
47        for batch in batches {
48            let batch = batch?;
49            let bytes = record_batch_memory_size(&batch);
50            let reservation = self
51                .budget
52                .reserve_batch(ResourceScope::MaterializedOutput, bytes)?;
53
54            // `reserve_batch` precedes the only retained-output ownership transition.
55            retained.push(batch);
56            reservations.push(reservation);
57        }
58
59        let dataframe = DataFrame::from_batches(retained)?;
60        drop(reservations);
61        Ok(dataframe)
62    }
63
64    /// Drain an already-bounded stream while reserving each result batch before retaining it.
65    ///
66    /// Source and operator allocations are owned by `DataFrameStream`; this collector adds only
67    /// the materialized-result ownership needed for an explicit bounded collect. A reservation
68    /// failure closes the stream so the source handle and all transient reservations are released.
69    pub fn collect_stream(&self, stream: &mut DataFrameStream) -> Result<DataFrame> {
70        let mut retained = Vec::new();
71        let mut reservations: Vec<ResourceReservation> = Vec::new();
72
73        loop {
74            let next = stream.next_batch()?;
75            let Some(dataframe) = next else {
76                break;
77            };
78            for batch in dataframe.to_arrow() {
79                let bytes = record_batch_memory_size(&batch);
80                let reservation = match self
81                    .budget
82                    .reserve_batch(ResourceScope::MaterializedOutput, bytes)
83                {
84                    Ok(reservation) => reservation,
85                    Err(error) => {
86                        let _ = stream.close();
87                        return Err(error);
88                    }
89                };
90                retained.push(batch);
91                reservations.push(reservation);
92            }
93        }
94
95        let dataframe = match DataFrame::from_batches(retained) {
96            Ok(dataframe) => dataframe,
97            Err(error) => {
98                let _ = stream.close();
99                return Err(error);
100            }
101        };
102        drop(reservations);
103        Ok(dataframe)
104    }
105
106    /// Return the shared budget used by this collector.
107    pub fn budget(&self) -> &ResourceBudget {
108        &self.budget
109    }
110}
111
112/// Estimate Arrow-owned bytes for an already decoded batch.
113///
114/// Streaming sources use conservative pre-decode bounds. The materializing collector uses the
115/// Arrow-reported size because its responsibility starts when it retains a completed batch.
116pub fn record_batch_memory_size(batch: &RecordBatch) -> u64 {
117    batch
118        .columns()
119        .iter()
120        .map(|column| column.get_array_memory_size() as u64)
121        .sum()
122}
123
124fn execute_plan(plan: &PhysicalPlan) -> Result<Vec<RecordBatch>> {
125    match plan {
126        PhysicalPlan::ScanExec { source } => execute_scan(source),
127        PhysicalPlan::ConcatExec { inputs, schema } => {
128            let mut output = Vec::new();
129            let mut expected_schema = schema.clone();
130            for (input_index, input) in inputs.iter().enumerate() {
131                let batches = execute_plan(input)?;
132                for (batch_index, batch) in batches.into_iter().enumerate() {
133                    if let Some(expected_schema) = &expected_schema {
134                        if batch.schema().as_ref() == expected_schema.as_ref() {
135                            output.push(batch);
136                            continue;
137                        }
138                        return Err(crate::DataFrameError::schema_mismatch(format!(
139                            "concat_schema_mismatch: input {input_index} batch {batch_index} differs from validated schema"
140                        )));
141                    }
142                    expected_schema = Some(batch.schema());
143                    output.push(batch);
144                }
145            }
146            Ok(output)
147        }
148        PhysicalPlan::ProjectionExec { input, exprs, kind } => {
149            let batches = execute_plan(input)?;
150            operators::project_batches(batches, exprs, kind.clone())
151        }
152        PhysicalPlan::FilterExec { input, predicate } => {
153            let batches = execute_plan(input)?;
154            operators::filter_batches(batches, predicate)
155        }
156        PhysicalPlan::AggregateExec {
157            input,
158            group_by,
159            aggs,
160        } => {
161            let batches = execute_plan(input)?;
162            operators::aggregate_batches(batches, group_by, aggs)
163        }
164        PhysicalPlan::JoinExec {
165            left,
166            right,
167            keys,
168            how,
169        } => {
170            let left_batches = execute_plan(left)?;
171            let right_batches = execute_plan(right)?;
172            operators::join_batches(left_batches, right_batches, keys, how)
173        }
174        PhysicalPlan::SortExec { input, options } => {
175            let batches = execute_plan(input)?;
176            operators::sort_batches(batches, options)
177        }
178        PhysicalPlan::SliceExec {
179            input,
180            offset,
181            len,
182            from_end,
183        } => {
184            let batches = execute_plan(input)?;
185            operators::slice_batches(batches, *offset, *len, *from_end)
186        }
187        PhysicalPlan::UniqueExec { input, subset } => {
188            let batches = execute_plan(input)?;
189            operators::unique_batches(batches, subset.as_deref())
190        }
191        PhysicalPlan::FillNullExec { input, fill } => {
192            let batches = execute_plan(input)?;
193            operators::fill_null_batches(batches, fill)
194        }
195        PhysicalPlan::DropNullsExec { input, subset } => {
196            let batches = execute_plan(input)?;
197            operators::drop_nulls_batches(batches, subset.as_deref())
198        }
199        PhysicalPlan::NullCountExec { input } => {
200            let batches = execute_plan(input)?;
201            operators::null_count_batches(batches)
202        }
203        PhysicalPlan::ExplodeExec { input, column } => {
204            let batches = execute_plan(input)?;
205            operators::explode_batches(batches, column)
206        }
207        PhysicalPlan::ImplodeExec { input } => {
208            let batches = execute_plan(input)?;
209            operators::implode_batches(batches)
210        }
211    }
212}
213
214fn execute_scan(source: &ScanSource) -> Result<Vec<RecordBatch>> {
215    operators::scan_source(source)
216}
217
218#[cfg(test)]
219mod tests {
220    use std::num::NonZeroUsize;
221    use std::sync::Arc;
222
223    use arrow::array::{Array, ArrayRef, Int64Array};
224    use arrow::datatypes::{DataType, Field, Schema};
225    use arrow::record_batch::RecordBatch;
226
227    use super::{BudgetedMaterializedExecutor, Executor};
228    use crate::physical::budget::ResourceBudget;
229    use crate::physical::{PhysicalPlan, ScanSource};
230    use crate::{DataFrame, DataFrameError};
231
232    fn batch(values: Vec<i64>) -> RecordBatch {
233        let schema = Arc::new(Schema::new(vec![Field::new(
234            "value",
235            DataType::Int64,
236            true,
237        )]));
238        RecordBatch::try_new(schema, vec![Arc::new(Int64Array::from(values)) as ArrayRef]).unwrap()
239    }
240
241    #[test]
242    fn bounded_materialization_reserves_before_retaining_and_releases_on_error() {
243        let one = batch(vec![1, 2]);
244        let bytes = super::record_batch_memory_size(&one);
245        let budget = ResourceBudget::new(bytes, NonZeroUsize::new(2).unwrap());
246        let collector = BudgetedMaterializedExecutor::new(budget.clone());
247
248        let err = collector.collect_batches(vec![Ok(one), Ok(batch(vec![3, 4]))]);
249        assert!(matches!(
250            err,
251            Err(DataFrameError::ResourceLimitExceeded { .. })
252        ));
253        assert_eq!(budget.usage().reserved_bytes, 0);
254        assert_eq!(budget.usage().reserved_batches, 0);
255    }
256
257    #[test]
258    fn bounded_materialization_transfers_output_and_releases_budget() {
259        let batch = batch(vec![1, 2]);
260        let bytes = super::record_batch_memory_size(&batch);
261        let budget = ResourceBudget::new(bytes, NonZeroUsize::new(1).unwrap());
262        let output = BudgetedMaterializedExecutor::new(budget.clone())
263            .collect_batches(vec![Ok(batch)])
264            .unwrap();
265
266        assert_eq!(output.height(), 2);
267        assert_eq!(budget.usage().reserved_bytes, 0);
268        assert_eq!(budget.usage().reserved_batches, 0);
269    }
270
271    #[test]
272    fn concat_executor_emits_all_batches_of_each_input_in_declared_order() {
273        let first = DataFrame::from_batches(vec![batch(vec![1, 2])]).unwrap();
274        let second = DataFrame::from_batches(vec![batch(vec![3, 4])]).unwrap();
275        let plan = PhysicalPlan::ConcatExec {
276            inputs: vec![
277                PhysicalPlan::ScanExec {
278                    source: ScanSource::DataFrame(first.clone()),
279                },
280                PhysicalPlan::ScanExec {
281                    source: ScanSource::DataFrame(second),
282                },
283            ],
284            schema: Some(first.schema()),
285        };
286
287        let batches = Executor::execute(plan).unwrap();
288        let values = batches
289            .iter()
290            .flat_map(|batch| {
291                batch
292                    .column(0)
293                    .as_any()
294                    .downcast_ref::<Int64Array>()
295                    .unwrap()
296                    .values()
297            })
298            .copied()
299            .collect::<Vec<_>>();
300        assert_eq!(values, vec![1, 2, 3, 4]);
301    }
302
303    #[test]
304    fn concat_executor_rejects_a_runtime_schema_contract_violation_before_output() {
305        let first = DataFrame::from_batches(vec![batch(vec![1])]).unwrap();
306        let other_schema = Arc::new(Schema::new(vec![Field::new(
307            "other",
308            DataType::Int64,
309            true,
310        )]));
311        let second = DataFrame::from_batches(vec![RecordBatch::try_new(
312            other_schema,
313            vec![Arc::new(Int64Array::from(vec![2])) as ArrayRef],
314        )
315        .unwrap()])
316        .unwrap();
317        let plan = PhysicalPlan::ConcatExec {
318            inputs: vec![
319                PhysicalPlan::ScanExec {
320                    source: ScanSource::DataFrame(first.clone()),
321                },
322                PhysicalPlan::ScanExec {
323                    source: ScanSource::DataFrame(second),
324                },
325            ],
326            schema: Some(first.schema()),
327        };
328
329        let err = Executor::execute(plan).unwrap_err();
330        assert!(matches!(err, DataFrameError::SchemaMismatch { message }
331            if message.contains("concat_schema_mismatch")));
332    }
333}