Skip to main content

alopex_dataframe/physical/
streaming.rs

1//! Incremental stream ownership contracts.
2//!
3//! Source implementations live in `io` and are added independently. This module owns no eager
4//! reader fallback: a factory either opens an owned batch source or the compiler reports a
5//! structured preflight outcome before the source is opened.
6
7use std::sync::Arc;
8
9use arrow::array::{Array, StringArray};
10use arrow::datatypes::{DataType, Field, Schema, SchemaRef};
11use arrow::record_batch::RecordBatch;
12
13use crate::expr::{Expr as E, Operator, Scalar};
14use crate::lazy::ProjectionKind;
15use crate::physical::budget::{
16    ResourceBudget, ResourceReservation, ResourceScope, StreamFailure, StreamFailureClass,
17    StreamOptions, StreamTerminal, StreamTerminalState,
18};
19use crate::physical::plan::{
20    ScanSource, SourceLimit, StreamingOperator, StreamingPhysicalPlan, StreamingSource,
21};
22use crate::physical::{operators, record_batch_memory_size};
23use crate::{DataFrame, DataFrameError, Expr, Result};
24
25/// Context supplied to a newly opened source. It shares one budget with the stream.
26#[derive(Debug, Clone)]
27pub struct BatchOpenContext {
28    /// The validated resource options selected by the caller.
29    pub options: StreamOptions,
30    /// Shared resource budget for source/decode/operator/output reservations.
31    pub budget: ResourceBudget,
32}
33
34impl BatchOpenContext {
35    /// Construct source-open context and its shared budget.
36    pub fn new(options: StreamOptions) -> Self {
37        Self {
38            options,
39            budget: ResourceBudget::from_options(options),
40        }
41    }
42}
43
44/// A source-owned batch whose reservation remains held until consumer ownership transfer.
45#[derive(Debug)]
46pub struct StreamBatch {
47    batch: RecordBatch,
48    reservation: ResourceReservation,
49}
50
51impl StreamBatch {
52    /// Attach the reservation acquired before the source published this batch.
53    pub fn new(batch: RecordBatch, reservation: ResourceReservation) -> Self {
54        Self { batch, reservation }
55    }
56
57    fn into_parts(self) -> (RecordBatch, ResourceReservation) {
58        (self.batch, self.reservation)
59    }
60}
61
62/// Owned incremental reader for one logical source execution.
63pub trait BatchSource: Send {
64    /// Return the source schema known at open time.
65    fn schema(&self) -> SchemaRef;
66
67    /// Produce at most one reserved batch. `None` denotes normal end-of-stream.
68    fn next_batch(&mut self) -> Result<Option<StreamBatch>>;
69
70    /// Release source handles and source-owned reservations. It is called at most once.
71    fn close(&mut self) -> Result<()> {
72        Ok(())
73    }
74}
75
76/// Re-consumable factory for a source; a factory must not perform source I/O until `open`.
77pub trait BatchSourceFactory {
78    /// Stable source identifier for diagnostics.
79    fn source_name(&self) -> &'static str;
80
81    /// Return the source schema known before the source produces batches.
82    fn schema(&self) -> Result<SchemaRef>;
83
84    /// Return static capability limitations visible before opening the source.
85    fn source_limits(&self) -> Vec<SourceLimit> {
86        Vec::new()
87    }
88
89    /// Open an independently owned source cursor.
90    fn open(&self, context: BatchOpenContext) -> Result<Box<dyn BatchSource>>;
91}
92
93/// Pre-decode ownership estimate for the next V08 columnar row group.
94#[derive(Debug, Clone, Copy, PartialEq, Eq)]
95pub struct ColumnarSegmentBatchPlan {
96    /// Bytes retained by the core column decoder while the Arrow batch is built.
97    pub decoded_bytes: u64,
98    /// Declared upper bound for the completed Arrow-compatible batch.
99    pub arrow_allocation_upper_bound: u64,
100}
101
102impl ColumnarSegmentBatchPlan {
103    /// Total peak ownership that must be reserved before the row group is fetched.
104    pub const fn reservation_upper_bound(self) -> u64 {
105        self.decoded_bytes
106            .saturating_add(self.arrow_allocation_upper_bound)
107    }
108}
109
110/// One owned V08 columnar segment cursor supplied by an adapter crate.
111///
112/// The cursor exposes its next row-group bound before the DataFrame source
113/// invokes `next_batch`.  This preserves the allocation-before-reservation
114/// contract without making `alopex-dataframe` depend on `alopex-embedded`.
115pub trait ColumnarSegmentCursor: Send {
116    /// Schema fixed during bounded source open.
117    fn schema(&self) -> SchemaRef;
118
119    /// Bytes still retained by source metadata after the cursor has opened.
120    fn metadata_footprint_upper_bound(&self) -> u64;
121
122    /// Return the next row-group bound without fetching its chunks.
123    fn next_batch_plan(&self) -> Result<Option<ColumnarSegmentBatchPlan>>;
124
125    /// Fetch and decode exactly the row group described by `next_batch_plan`.
126    fn next_batch(&mut self) -> Result<Option<RecordBatch>>;
127
128    /// Release adapter-owned source handles. It is called at most once.
129    fn close(&mut self) -> Result<()> {
130        Ok(())
131    }
132}
133
134/// Re-consumable V08 columnar source provider supplied by an adapter crate.
135pub trait ColumnarSegmentProvider: Send + Sync {
136    /// Stable source identifier for diagnostics.
137    fn source_name(&self) -> &'static str {
138        "columnar_segment"
139    }
140
141    /// Static limitations visible before a V08 cursor is opened.
142    fn source_limits(&self) -> Vec<SourceLimit>;
143
144    /// Open a V08 cursor, bounded by the caller's metadata allocation limit.
145    fn open(&self, metadata_limit_bytes: u64) -> Result<Box<dyn ColumnarSegmentCursor>>;
146}
147
148/// Open a compiler-produced source tree without ever entering the eager executor or an eager
149/// reader. This is crate-visible because `LazyFrame` owns physical compilation while this module
150/// owns the source and reservation lifecycle.
151pub(crate) fn open_streaming_plan(
152    plan: StreamingPhysicalPlan,
153    context: BatchOpenContext,
154) -> Result<Box<dyn BatchSource>> {
155    Ok(Box::new(StreamingPlanBatchSource::open(plan, context)?))
156}
157
158/// A streaming physical plan with one currently-open source and a per-batch operator pipeline.
159struct StreamingPlanBatchSource {
160    runner: StreamingSourceRunner,
161    operators: Vec<RuntimeStreamingOperator>,
162    schema: SchemaRef,
163    budget: ResourceBudget,
164    closed: bool,
165}
166
167impl StreamingPlanBatchSource {
168    fn open(plan: StreamingPhysicalPlan, context: BatchOpenContext) -> Result<Self> {
169        let schema = preflight_streaming_schema(&plan, &context)?;
170        let runner = StreamingSourceRunner::open(plan.source, context.clone(), schema.clone())?;
171        Ok(Self {
172            runner,
173            operators: plan
174                .operators
175                .into_iter()
176                .map(RuntimeStreamingOperator::from)
177                .collect(),
178            schema,
179            budget: context.budget,
180            closed: false,
181        })
182    }
183
184    fn next_transformed_batch(&mut self) -> Result<Option<StreamBatch>> {
185        loop {
186            let Some(batch) = self.runner.next_batch()? else {
187                return Ok(None);
188            };
189            let mut batch = Some(batch);
190            for operator in &mut self.operators {
191                let Some(input) = batch.take() else {
192                    break;
193                };
194                batch = operator.apply(input, &self.budget)?;
195            }
196            if let Some(batch) = batch {
197                return Ok(Some(batch));
198            }
199        }
200    }
201
202    fn close_once(&mut self) -> Result<()> {
203        if self.closed {
204            return Ok(());
205        }
206        self.closed = true;
207        self.runner.close()
208    }
209}
210
211impl BatchSource for StreamingPlanBatchSource {
212    fn schema(&self) -> SchemaRef {
213        self.schema.clone()
214    }
215
216    fn next_batch(&mut self) -> Result<Option<StreamBatch>> {
217        self.next_transformed_batch()
218    }
219
220    fn close(&mut self) -> Result<()> {
221        self.close_once()
222    }
223}
224
225/// Only one child source of a concat is open at a time. Child schemas are preflighted before the
226/// first result, while handles are opened/released in declared input order during consumption.
227enum StreamingSourceRunner {
228    Leaf(Box<dyn BatchSource>),
229    Concat {
230        inputs: Vec<StreamingPhysicalPlan>,
231        schema: SchemaRef,
232        context: BatchOpenContext,
233        next_input: usize,
234        active: Option<Box<StreamingPlanBatchSource>>,
235    },
236}
237
238impl StreamingSourceRunner {
239    fn open(
240        source: StreamingSource,
241        context: BatchOpenContext,
242        expected_schema: SchemaRef,
243    ) -> Result<Self> {
244        match source {
245            StreamingSource::Scan(source) => Ok(Self::Leaf(open_scan_source(source, context)?)),
246            StreamingSource::Concat { inputs, schema } => Ok(Self::Concat {
247                inputs,
248                schema: match schema {
249                    Some(schema) if schema.as_ref() != expected_schema.as_ref() => {
250                        return Err(DataFrameError::schema_mismatch(
251                            "concat_schema_mismatch: declared schema differs from bounded preflight schema",
252                        ));
253                    }
254                    Some(schema) => schema,
255                    None => expected_schema,
256                },
257                context,
258                next_input: 0,
259                active: None,
260            }),
261        }
262    }
263
264    fn next_batch(&mut self) -> Result<Option<StreamBatch>> {
265        match self {
266            Self::Leaf(source) => source.next_batch(),
267            Self::Concat {
268                inputs,
269                schema,
270                context,
271                next_input,
272                active,
273            } => loop {
274                if let Some(child) = active.as_mut() {
275                    match child.next_batch()? {
276                        Some(batch) => {
277                            if batch.batch.schema().as_ref() != schema.as_ref() {
278                                return Err(DataFrameError::schema_mismatch(
279                                    "concat_schema_mismatch: child emitted a schema different from the validated concat schema",
280                                ));
281                            }
282                            return Ok(Some(batch));
283                        }
284                        None => {
285                            child.close()?;
286                            active.take();
287                            *next_input += 1;
288                        }
289                    }
290                    continue;
291                }
292                let Some(plan) = inputs.get(*next_input).cloned() else {
293                    return Ok(None);
294                };
295                *active = Some(Box::new(StreamingPlanBatchSource::open(
296                    plan,
297                    context.clone(),
298                )?));
299            },
300        }
301    }
302
303    fn close(&mut self) -> Result<()> {
304        match self {
305            Self::Leaf(source) => source.close(),
306            Self::Concat { active, .. } => {
307                if let Some(mut child) = active.take() {
308                    child.close()?;
309                }
310                Ok(())
311            }
312        }
313    }
314}
315
316fn open_scan_source(source: ScanSource, context: BatchOpenContext) -> Result<Box<dyn BatchSource>> {
317    match source {
318        ScanSource::Csv {
319            path,
320            predicate,
321            projection,
322        } => {
323            if predicate.is_some() {
324                return Err(DataFrameError::streaming_unsupported(
325                    "csv_scan",
326                    "predicate_was_not_lowered_to_batch_operator",
327                ));
328            }
329            let factory = crate::io::CsvBatchSourceFactory::from_scan(path, None, projection);
330            factory.open(context)
331        }
332        ScanSource::Parquet {
333            path,
334            predicate,
335            projection,
336        } => {
337            if predicate.is_some() {
338                return Err(DataFrameError::streaming_unsupported(
339                    "parquet_scan",
340                    "predicate_was_not_lowered_to_batch_operator",
341                ));
342            }
343            let factory = crate::io::ParquetBatchSourceFactory::from_scan(path, None, projection);
344            factory.open(context)
345        }
346        ScanSource::DataFrame(_) => Err(DataFrameError::streaming_unsupported(
347            "dataframe_scan",
348            "legacy_materialized_source",
349        )),
350    }
351}
352
353fn preflight_streaming_schema(
354    plan: &StreamingPhysicalPlan,
355    context: &BatchOpenContext,
356) -> Result<SchemaRef> {
357    let source_schema = match &plan.source {
358        StreamingSource::Scan(source) => {
359            let mut source = open_scan_source(source.clone(), context.clone())?;
360            let schema = source.schema();
361            source.close()?;
362            schema
363        }
364        StreamingSource::Concat { inputs, schema } => {
365            let mut expected = schema.clone();
366            for (index, input) in inputs.iter().enumerate() {
367                let input_schema = preflight_streaming_schema(input, context)?;
368                if let Some(expected_schema) = &expected {
369                    if input_schema.as_ref() != expected_schema.as_ref() {
370                        return Err(DataFrameError::schema_mismatch(format!(
371                            "concat_schema_mismatch: input {index} differs from the validated concat schema"
372                        )));
373                    }
374                } else {
375                    expected = Some(input_schema);
376                }
377            }
378            expected.ok_or_else(|| {
379                DataFrameError::invalid_operation("concat requires at least two inputs")
380            })?
381        }
382    };
383    infer_streaming_output_schema(&source_schema, &plan.operators)
384}
385
386fn infer_streaming_output_schema(
387    source_schema: &SchemaRef,
388    operators: &[StreamingOperator],
389) -> Result<SchemaRef> {
390    let mut schema = source_schema.clone();
391    for operator in operators {
392        schema = match operator {
393            StreamingOperator::Projection { exprs, kind } => {
394                infer_projection_schema(&schema, exprs, kind)?
395            }
396            StreamingOperator::Filter { predicate } => {
397                let dtype = infer_expression_dtype(predicate, &schema)?;
398                if dtype != DataType::Boolean {
399                    return Err(DataFrameError::type_mismatch(
400                        None::<String>,
401                        DataType::Boolean.to_string(),
402                        dtype.to_string(),
403                    ));
404                }
405                schema
406            }
407            StreamingOperator::ForwardSlice { .. } => schema,
408        };
409    }
410    Ok(schema)
411}
412
413fn infer_projection_schema(
414    schema: &SchemaRef,
415    expressions: &[Expr],
416    kind: &ProjectionKind,
417) -> Result<SchemaRef> {
418    match kind {
419        ProjectionKind::Select => {
420            let mut fields = Vec::new();
421            for expression in expressions {
422                match expression {
423                    E::Wildcard => fields.extend(schema.fields().iter().cloned()),
424                    E::Column(name) => fields.push(schema_field(schema, name)?),
425                    E::Alias { expr, name } => fields.push(Arc::new(Field::new(
426                        name,
427                        infer_expression_dtype(expr, schema)?,
428                        true,
429                    ))),
430                    expression => fields.push(Arc::new(Field::new(
431                        format!("{expression:?}"),
432                        infer_expression_dtype(expression, schema)?,
433                        true,
434                    ))),
435                }
436            }
437            Ok(Arc::new(Schema::new(fields)))
438        }
439        ProjectionKind::WithColumns => {
440            let mut fields = schema
441                .fields()
442                .iter()
443                .map(|field| field.as_ref().clone())
444                .collect::<Vec<_>>();
445            for expression in expressions {
446                let (name, expression) = match expression {
447                    E::Alias { expr, name } => (name, expr.as_ref()),
448                    E::Column(name) => (name, expression),
449                    _ => {
450                        return Err(DataFrameError::invalid_operation(
451                            "with_columns requires alias for non-column expressions",
452                        ))
453                    }
454                };
455                let field = Field::new(name, infer_expression_dtype(expression, schema)?, true);
456                if let Some(index) = fields.iter().position(|existing| existing.name() == name) {
457                    fields[index] = field;
458                } else {
459                    fields.push(field);
460                }
461            }
462            Ok(Arc::new(Schema::new(fields)))
463        }
464    }
465}
466
467fn infer_expression_dtype(expression: &Expr, schema: &SchemaRef) -> Result<DataType> {
468    match expression {
469        E::Column(name) => Ok(schema_field(schema, name)?.data_type().clone()),
470        E::Literal(Scalar::Null) => Ok(DataType::Null),
471        E::Literal(Scalar::Boolean(_)) => Ok(DataType::Boolean),
472        E::Literal(Scalar::Int64(_)) => Ok(DataType::Int64),
473        E::Literal(Scalar::Float64(_)) => Ok(DataType::Float64),
474        E::Literal(Scalar::Utf8(_)) => Ok(DataType::Utf8),
475        E::Alias { expr, .. } => infer_expression_dtype(expr, schema),
476        E::UnaryOp { expr, .. } => {
477            let dtype = infer_expression_dtype(expr, schema)?;
478            if dtype != DataType::Boolean {
479                return Err(DataFrameError::type_mismatch(
480                    None::<String>,
481                    DataType::Boolean.to_string(),
482                    dtype.to_string(),
483                ));
484            }
485            Ok(DataType::Boolean)
486        }
487        E::BinaryOp { left, op, right } => {
488            let left = infer_expression_dtype(left, schema)?;
489            let right = infer_expression_dtype(right, schema)?;
490            match op {
491                Operator::And | Operator::Or => {
492                    if left != DataType::Boolean || right != DataType::Boolean {
493                        return Err(DataFrameError::type_mismatch(
494                            None::<String>,
495                            DataType::Boolean.to_string(),
496                            format!("{left} and {right}"),
497                        ));
498                    }
499                    Ok(DataType::Boolean)
500                }
501                Operator::Eq
502                | Operator::Neq
503                | Operator::Gt
504                | Operator::Lt
505                | Operator::Ge
506                | Operator::Le => {
507                    if left != right {
508                        return Err(DataFrameError::type_mismatch(
509                            None::<String>,
510                            left.to_string(),
511                            right.to_string(),
512                        ));
513                    }
514                    Ok(DataType::Boolean)
515                }
516                Operator::Add | Operator::Sub | Operator::Mul | Operator::Div => {
517                    if left != right || !is_numeric_dtype(&left) {
518                        return Err(DataFrameError::type_mismatch(
519                            None::<String>,
520                            "matching numeric operands".to_string(),
521                            format!("{left} and {right}"),
522                        ));
523                    }
524                    Ok(left)
525                }
526            }
527        }
528        E::ConcatStr { inputs, .. } => {
529            if inputs.len() < 2 {
530                return Err(DataFrameError::invalid_operation(
531                    "concat_str requires at least two input expressions",
532                ));
533            }
534            for input in inputs {
535                let dtype = infer_expression_dtype(input, schema)?;
536                if dtype != DataType::Utf8 {
537                    return Err(DataFrameError::type_mismatch(
538                        None::<String>,
539                        DataType::Utf8.to_string(),
540                        dtype.to_string(),
541                    ));
542                }
543            }
544            Ok(DataType::Utf8)
545        }
546        E::Wildcard => Err(DataFrameError::invalid_operation(
547            "wildcard cannot be evaluated as a standalone expression",
548        )),
549        E::Agg { .. } | E::Function { .. } => Err(DataFrameError::streaming_unsupported(
550            "expression",
551            "expression_allocation_bound_not_installed",
552        )),
553    }
554}
555
556fn schema_field(schema: &SchemaRef, name: &str) -> Result<Arc<Field>> {
557    schema
558        .fields()
559        .iter()
560        .find(|field| field.name() == name)
561        .cloned()
562        .ok_or_else(|| DataFrameError::column_not_found(name.to_string()))
563}
564
565fn is_numeric_dtype(dtype: &DataType) -> bool {
566    matches!(
567        dtype,
568        DataType::Int8
569            | DataType::Int16
570            | DataType::Int32
571            | DataType::Int64
572            | DataType::UInt8
573            | DataType::UInt16
574            | DataType::UInt32
575            | DataType::UInt64
576            | DataType::Float32
577            | DataType::Float64
578    )
579}
580
581/// Stateful, batch-at-a-time transforms selected by the streaming compiler.
582enum RuntimeStreamingOperator {
583    Projection {
584        exprs: Vec<Expr>,
585        kind: ProjectionKind,
586    },
587    Filter {
588        predicate: Expr,
589    },
590    ForwardSlice {
591        remaining_offset: usize,
592        remaining_len: usize,
593    },
594}
595
596impl From<StreamingOperator> for RuntimeStreamingOperator {
597    fn from(operator: StreamingOperator) -> Self {
598        match operator {
599            StreamingOperator::Projection { exprs, kind } => Self::Projection { exprs, kind },
600            StreamingOperator::Filter { predicate } => Self::Filter { predicate },
601            StreamingOperator::ForwardSlice { offset, len } => Self::ForwardSlice {
602                remaining_offset: offset,
603                remaining_len: len,
604            },
605        }
606    }
607}
608
609impl RuntimeStreamingOperator {
610    fn apply(
611        &mut self,
612        input: StreamBatch,
613        budget: &ResourceBudget,
614    ) -> Result<Option<StreamBatch>> {
615        match self {
616            Self::Projection { exprs, kind } => {
617                let bound = projection_upper_bound(&input.batch, exprs)?;
618                let output = replace_with_operator_output(input, budget, bound, |batch| {
619                    let mut output = operators::project_batches(vec![batch], exprs, kind.clone())?;
620                    output.pop().ok_or_else(|| {
621                        DataFrameError::invalid_operation("projection produced no batch")
622                    })
623                })?;
624                Ok((output.batch.num_rows() > 0).then_some(output))
625            }
626            Self::Filter { predicate } => {
627                let bound = filter_upper_bound(&input.batch, predicate)?;
628                let output = replace_with_operator_output(input, budget, bound, |batch| {
629                    let mut output = operators::filter_batches(vec![batch], predicate)?;
630                    output.pop().ok_or_else(|| {
631                        DataFrameError::invalid_operation("filter produced no batch")
632                    })
633                })?;
634                Ok((output.batch.num_rows() > 0).then_some(output))
635            }
636            Self::ForwardSlice {
637                remaining_offset,
638                remaining_len,
639            } => {
640                if *remaining_len == 0 {
641                    return Ok(None);
642                }
643                let (batch, mut reservation) = input.into_parts();
644                let rows = batch.num_rows();
645                let skip = (*remaining_offset).min(rows);
646                *remaining_offset -= skip;
647                let available = rows.saturating_sub(skip);
648                let take = (*remaining_len).min(available);
649                *remaining_len -= take;
650                if take == 0 {
651                    reservation.release();
652                    return Ok(None);
653                }
654                let output = batch.slice(skip, take);
655                reservation.shrink_to(record_batch_memory_size(&output));
656                Ok(Some(StreamBatch::new(output, reservation)))
657            }
658        }
659    }
660}
661
662/// Reserve the complete transform/output upper bound before creating an Arrow output batch.
663/// The source reservation stays live while its input is read. Once no input ownership remains,
664/// its batch slot is released and the result reservation becomes the sole published batch slot.
665fn replace_with_operator_output<F>(
666    input: StreamBatch,
667    budget: &ResourceBudget,
668    upper_bound: u64,
669    transform: F,
670) -> Result<StreamBatch>
671where
672    F: FnOnce(RecordBatch) -> Result<RecordBatch>,
673{
674    let (batch, mut source_reservation) = input.into_parts();
675    let mut output_reservation = budget.reserve(ResourceScope::Operator, upper_bound)?;
676    let output = transform(batch)?;
677
678    // `transform` consumed the input `RecordBatch`; only the result remains live now.
679    source_reservation.release();
680    let actual = record_batch_memory_size(&output);
681    if actual > output_reservation.bytes() {
682        return Err(DataFrameError::resource_limit_exceeded(
683            budget.memory_limit_bytes(),
684            budget
685                .usage()
686                .reserved_bytes
687                .saturating_add(actual.saturating_sub(output_reservation.bytes())),
688            budget.max_in_flight_batches(),
689            budget.usage().reserved_batches,
690            ResourceScope::Operator,
691        ));
692    }
693    output_reservation.shrink_to(actual);
694    output_reservation.promote_to_batch(ResourceScope::Output)?;
695    Ok(StreamBatch::new(output, output_reservation))
696}
697
698fn projection_upper_bound(batch: &RecordBatch, expressions: &[Expr]) -> Result<u64> {
699    expressions
700        .iter()
701        .try_fold(record_batch_memory_size(batch), |bound, expression| {
702            Ok(bound.saturating_add(expression_upper_bound(expression, batch)?))
703        })
704}
705
706fn filter_upper_bound(batch: &RecordBatch, predicate: &Expr) -> Result<u64> {
707    // Arrow filtering can retain an input-sized temporary while it builds the
708    // selected output. Reserve both the output-sized allocation and that
709    // temporary in addition to predicate evaluation before invoking the
710    // kernel; otherwise a valid filter can exceed its reservation after the
711    // allocation has already happened.
712    let batch_bytes = record_batch_memory_size(batch);
713    Ok(batch_bytes
714        .saturating_mul(2)
715        .saturating_add(expression_upper_bound(predicate, batch)?))
716}
717
718/// Conservative peak allocation upper bound for a supported expression tree. Source columns are
719/// borrowed from the input batch and therefore add no new allocation; every constructed array and
720/// temporary child value is included.
721fn expression_upper_bound(expression: &Expr, batch: &RecordBatch) -> Result<u64> {
722    let rows = u64::try_from(batch.num_rows()).unwrap_or(u64::MAX);
723    match expression {
724        E::Column(_) | E::Wildcard => Ok(0),
725        E::Alias { expr, .. } => expression_upper_bound(expr, batch),
726        E::Literal(scalar) => Ok(literal_upper_bound(scalar, rows)),
727        E::UnaryOp { expr, .. } => Ok(expression_upper_bound(expr, batch)?
728            .saturating_add(fixed_width_upper_bound(&DataType::Boolean, rows))),
729        E::BinaryOp { left, op, right } => {
730            let dtype = infer_expression_dtype(expression, &batch.schema())?;
731            let output = match op {
732                Operator::And
733                | Operator::Or
734                | Operator::Eq
735                | Operator::Neq
736                | Operator::Gt
737                | Operator::Lt
738                | Operator::Ge
739                | Operator::Le => fixed_width_upper_bound(&DataType::Boolean, rows),
740                Operator::Add | Operator::Sub | Operator::Mul | Operator::Div => {
741                    fixed_width_upper_bound(&dtype, rows)
742                }
743            };
744            Ok(expression_upper_bound(left, batch)?
745                .saturating_add(expression_upper_bound(right, batch)?)
746                .saturating_add(output))
747        }
748        E::ConcatStr {
749            inputs,
750            separator,
751            null_behavior,
752        } => {
753            let children = inputs.iter().try_fold(0_u64, |bound, input| {
754                Ok(bound.saturating_add(expression_upper_bound(input, batch)?))
755            })?;
756            let values = concat_string_value_upper_bound(inputs, separator, null_behavior, batch)?;
757            Ok(children.saturating_add(utf8_array_upper_bound(values, rows)))
758        }
759        E::Agg { .. } | E::Function { .. } => Err(DataFrameError::streaming_unsupported(
760            "expression",
761            "expression_allocation_bound_not_installed",
762        )),
763    }
764}
765
766fn literal_upper_bound(value: &Scalar, rows: u64) -> u64 {
767    match value {
768        Scalar::Null => 128,
769        Scalar::Boolean(_) => fixed_width_upper_bound(&DataType::Boolean, rows),
770        Scalar::Int64(_) => fixed_width_upper_bound(&DataType::Int64, rows),
771        Scalar::Float64(_) => fixed_width_upper_bound(&DataType::Float64, rows),
772        Scalar::Utf8(value) => utf8_array_upper_bound(
773            rows.saturating_mul(u64::try_from(value.len()).unwrap_or(u64::MAX)),
774            rows,
775        ),
776    }
777}
778
779/// Conservative Arrow buffer baseline for one constructed output array.
780const ARROW_ARRAY_ALLOCATION_OVERHEAD: u64 = 2 * 1024;
781
782fn fixed_width_upper_bound(dtype: &DataType, rows: u64) -> u64 {
783    let value_bytes = match dtype {
784        DataType::Boolean => rows.saturating_add(7) / 8,
785        DataType::Int8 | DataType::UInt8 => rows,
786        DataType::Int16 | DataType::UInt16 => rows.saturating_mul(2),
787        DataType::Int32 | DataType::UInt32 | DataType::Float32 => rows.saturating_mul(4),
788        DataType::Int64 | DataType::UInt64 | DataType::Float64 => rows.saturating_mul(8),
789        DataType::Null => 0,
790        _ => rows.saturating_mul(16),
791    };
792    value_bytes
793        .saturating_add(rows.saturating_add(7) / 8)
794        // Arrow's small-array buffers retain an allocator-sized baseline even
795        // when their logical value count is tiny. Include that ownership in
796        // the pre-allocation bound rather than discovering it afterward.
797        .saturating_add(ARROW_ARRAY_ALLOCATION_OVERHEAD)
798}
799
800fn utf8_array_upper_bound(value_bytes: u64, rows: u64) -> u64 {
801    value_bytes
802        .saturating_add(rows.saturating_add(1).saturating_mul(4))
803        .saturating_add(rows.saturating_add(7) / 8)
804        .saturating_add(ARROW_ARRAY_ALLOCATION_OVERHEAD)
805}
806
807fn concat_string_value_upper_bound(
808    inputs: &[Expr],
809    separator: &str,
810    null_behavior: &crate::expr::ConcatStrNullBehavior,
811    batch: &RecordBatch,
812) -> Result<u64> {
813    let rows = u64::try_from(batch.num_rows()).unwrap_or(u64::MAX);
814    let input_values = inputs.iter().try_fold(0_u64, |bound, input| {
815        Ok(bound.saturating_add(string_value_upper_bound(input, batch)?))
816    })?;
817    let separators = rows
818        .saturating_mul(u64::try_from(inputs.len().saturating_sub(1)).unwrap_or(u64::MAX))
819        .saturating_mul(u64::try_from(separator.len()).unwrap_or(u64::MAX));
820    let replacements = match null_behavior {
821        crate::expr::ConcatStrNullBehavior::Replace(value) => rows
822            .saturating_mul(u64::try_from(inputs.len()).unwrap_or(u64::MAX))
823            .saturating_mul(u64::try_from(value.len()).unwrap_or(u64::MAX)),
824        crate::expr::ConcatStrNullBehavior::Propagate
825        | crate::expr::ConcatStrNullBehavior::Ignore => 0,
826    };
827    Ok(input_values
828        .saturating_add(separators)
829        .saturating_add(replacements))
830}
831
832fn string_value_upper_bound(expression: &Expr, batch: &RecordBatch) -> Result<u64> {
833    match expression {
834        E::Column(name) => {
835            let field = schema_field(&batch.schema(), name)?;
836            if field.data_type() != &DataType::Utf8 {
837                return Err(DataFrameError::type_mismatch(
838                    None::<String>,
839                    DataType::Utf8.to_string(),
840                    field.data_type().to_string(),
841                ));
842            }
843            let index = batch
844                .schema()
845                .fields()
846                .iter()
847                .position(|field| field.name() == name)
848                .expect("schema_field found the column");
849            let array = batch
850                .column(index)
851                .as_any()
852                .downcast_ref::<StringArray>()
853                .ok_or_else(|| {
854                    DataFrameError::type_mismatch(
855                        None::<String>,
856                        DataType::Utf8.to_string(),
857                        batch.column(index).data_type().to_string(),
858                    )
859                })?;
860            Ok((0..array.len()).fold(0_u64, |bytes, index| {
861                if array.is_null(index) {
862                    bytes
863                } else {
864                    bytes
865                        .saturating_add(u64::try_from(array.value(index).len()).unwrap_or(u64::MAX))
866                }
867            }))
868        }
869        E::Literal(Scalar::Utf8(value)) => Ok(u64::try_from(batch.num_rows())
870            .unwrap_or(u64::MAX)
871            .saturating_mul(u64::try_from(value.len()).unwrap_or(u64::MAX))),
872        E::Literal(Scalar::Null) => Ok(0),
873        E::Alias { expr, .. } => string_value_upper_bound(expr, batch),
874        E::ConcatStr {
875            inputs,
876            separator,
877            null_behavior,
878        } => concat_string_value_upper_bound(inputs, separator, null_behavior, batch),
879        _ => {
880            let dtype = infer_expression_dtype(expression, &batch.schema())?;
881            Err(DataFrameError::type_mismatch(
882                None::<String>,
883                DataType::Utf8.to_string(),
884                dtype.to_string(),
885            ))
886        }
887    }
888}
889
890/// Incremental `DataFrame` result stream with one-way, repeatable terminal outcomes.
891pub struct DataFrameStream {
892    source: Option<Box<dyn BatchSource>>,
893    schema: SchemaRef,
894    terminal: StreamTerminalState,
895    budget: ResourceBudget,
896}
897
898impl DataFrameStream {
899    /// Open a source factory after a compiler has already reported `Supported` eligibility.
900    pub fn from_factory(factory: &dyn BatchSourceFactory, options: StreamOptions) -> Result<Self> {
901        let context = BatchOpenContext::new(options);
902        let source = factory.open(context.clone())?;
903        Ok(Self::from_opened_source(source, context.budget))
904    }
905
906    /// Construct a stream from a source tree that has already completed bounded preflight.
907    pub(crate) fn from_opened_source(source: Box<dyn BatchSource>, budget: ResourceBudget) -> Self {
908        let schema = source.schema();
909        Self {
910            source: Some(source),
911            schema,
912            terminal: StreamTerminalState::new(),
913            budget,
914        }
915    }
916
917    /// Return the source schema while the stream remains open or terminal.
918    pub fn schema(&self) -> SchemaRef {
919        self.schema.clone()
920    }
921
922    /// Return the current lifecycle state.
923    pub fn status(&self) -> StreamTerminal {
924        self.terminal.status()
925    }
926
927    /// Return the shared budget used by this stream.
928    pub fn budget(&self) -> &ResourceBudget {
929        &self.budget
930    }
931
932    /// Yield the next finite `DataFrame` batch, or `None` after normal exhaustion.
933    pub fn next_batch(&mut self) -> Result<Option<DataFrame>> {
934        match self.terminal.status() {
935            StreamTerminal::Open => {}
936            StreamTerminal::Exhausted => return Ok(None),
937            terminal => return Err(terminal.as_error().expect("terminal has stable error")),
938        }
939
940        let next = self
941            .source
942            .as_mut()
943            .expect("open stream always owns a source")
944            .next_batch();
945
946        match next {
947            Ok(Some(batch)) => match self.transfer_to_consumer(batch) {
948                Ok(batch) => Ok(Some(batch)),
949                Err(error) => Err(self.fail(error)),
950            },
951            Ok(None) => {
952                if let Err(error) = self.close_source() {
953                    return Err(self.fail(error));
954                }
955                self.terminal.finish(StreamTerminal::Exhausted);
956                Ok(None)
957            }
958            Err(error) => Err(self.fail(error)),
959        }
960    }
961
962    /// Close an open stream. Repeated close calls are successful and never reopen a source.
963    pub fn close(&mut self) -> Result<()> {
964        match self.terminal.status() {
965            StreamTerminal::Open => {
966                if let Err(error) = self.close_source() {
967                    return Err(self.fail(error));
968                }
969                self.terminal.finish(StreamTerminal::Closed);
970                Ok(())
971            }
972            StreamTerminal::Exhausted | StreamTerminal::Closed => Ok(()),
973            terminal => Err(terminal.as_error().expect("terminal has stable error")),
974        }
975    }
976
977    /// Cancel an open stream. Repeated cancellation is successful and never reopens a source.
978    pub fn cancel(&mut self) -> Result<()> {
979        match self.terminal.status() {
980            StreamTerminal::Open => {
981                if let Err(error) = self.close_source() {
982                    return Err(self.fail(error));
983                }
984                self.terminal.finish(StreamTerminal::Cancelled);
985                Ok(())
986            }
987            StreamTerminal::Cancelled => Ok(()),
988            StreamTerminal::Exhausted => Ok(()),
989            terminal => Err(terminal.as_error().expect("terminal has stable error")),
990        }
991    }
992
993    fn transfer_to_consumer(&self, batch: StreamBatch) -> Result<DataFrame> {
994        let (batch, reservation) = batch.into_parts();
995        // This one-batch Vec is the public DataFrame wrapper, never a preloaded source/result.
996        let dataframe = DataFrame::from_batches(vec![batch]);
997        drop(reservation);
998        dataframe
999    }
1000
1001    fn close_source(&mut self) -> Result<()> {
1002        if let Some(mut source) = self.source.take() {
1003            source.close()?;
1004        }
1005        Ok(())
1006    }
1007
1008    fn fail(&mut self, error: DataFrameError) -> DataFrameError {
1009        let failure = failure_for(&error);
1010        let _ = self.close_source();
1011        let terminal = self.terminal.finish(StreamTerminal::Failed(failure));
1012        terminal
1013            .as_error()
1014            .expect("failed terminal has a stable error")
1015    }
1016}
1017
1018impl Drop for DataFrameStream {
1019    fn drop(&mut self) {
1020        if matches!(self.terminal.status(), StreamTerminal::Open) {
1021            let _ = self.close_source();
1022            self.terminal.finish(StreamTerminal::Closed);
1023        }
1024    }
1025}
1026
1027fn failure_for(error: &DataFrameError) -> StreamFailure {
1028    match error {
1029        DataFrameError::ResourceLimitExceeded { .. } => {
1030            StreamFailure::new("resource_limit_exceeded", StreamFailureClass::ResourceLimit)
1031        }
1032        DataFrameError::Io { .. } => StreamFailure::new("source_error", StreamFailureClass::Source),
1033        DataFrameError::Arrow { .. } | DataFrameError::Parquet { .. } => {
1034            StreamFailure::new("decode_error", StreamFailureClass::Decode)
1035        }
1036        DataFrameError::SchemaMismatch { .. } => {
1037            StreamFailure::new("schema_error", StreamFailureClass::Schema)
1038        }
1039        DataFrameError::StreamingUnsupported { .. }
1040        | DataFrameError::StreamingRequiresMaterialization { .. } => {
1041            StreamFailure::new("streaming_unsupported", StreamFailureClass::Unsupported)
1042        }
1043        DataFrameError::StreamClosed => {
1044            StreamFailure::new("stream_closed", StreamFailureClass::Closed)
1045        }
1046        DataFrameError::StreamCancelled => {
1047            StreamFailure::new("stream_cancelled", StreamFailureClass::Cancelled)
1048        }
1049        DataFrameError::StreamFailed {
1050            code,
1051            classification,
1052        } => StreamFailure::new(code, *classification),
1053        _ => StreamFailure::new("stream_internal", StreamFailureClass::Internal),
1054    }
1055}
1056
1057#[cfg(test)]
1058mod tests {
1059    use std::cell::Cell;
1060    use std::num::NonZeroUsize;
1061    use std::sync::atomic::{AtomicUsize, Ordering};
1062    use std::sync::Arc;
1063
1064    use arrow::array::{ArrayRef, Int64Array};
1065    use arrow::datatypes::{DataType, Field, Schema, SchemaRef};
1066    use arrow::record_batch::RecordBatch;
1067
1068    use super::{BatchOpenContext, BatchSource, BatchSourceFactory, DataFrameStream, StreamBatch};
1069    use crate::physical::budget::{ResourceScope, StreamOptions, StreamTerminal};
1070    use crate::{DataFrameError, Result};
1071
1072    struct TestFactory {
1073        opened: Cell<usize>,
1074        closed: Arc<AtomicUsize>,
1075        batches: usize,
1076    }
1077
1078    impl BatchSourceFactory for TestFactory {
1079        fn source_name(&self) -> &'static str {
1080            "test"
1081        }
1082
1083        fn schema(&self) -> Result<SchemaRef> {
1084            Ok(schema())
1085        }
1086
1087        fn open(&self, context: BatchOpenContext) -> Result<Box<dyn BatchSource>> {
1088            self.opened.set(self.opened.get() + 1);
1089            Ok(Box::new(TestSource {
1090                context,
1091                closed: self.closed.clone(),
1092                remaining: self.batches,
1093            }))
1094        }
1095    }
1096
1097    struct TestSource {
1098        context: BatchOpenContext,
1099        closed: Arc<AtomicUsize>,
1100        remaining: usize,
1101    }
1102
1103    impl BatchSource for TestSource {
1104        fn schema(&self) -> SchemaRef {
1105            schema()
1106        }
1107
1108        fn next_batch(&mut self) -> Result<Option<StreamBatch>> {
1109            if self.remaining == 0 {
1110                return Ok(None);
1111            }
1112            self.remaining -= 1;
1113            let batch = batch();
1114            let reservation = self.context.budget.reserve_batch(
1115                ResourceScope::Decode,
1116                super::super::executor::record_batch_memory_size(&batch),
1117            )?;
1118            Ok(Some(StreamBatch::new(batch, reservation)))
1119        }
1120
1121        fn close(&mut self) -> Result<()> {
1122            self.closed.fetch_add(1, Ordering::SeqCst);
1123            Ok(())
1124        }
1125    }
1126
1127    fn schema() -> SchemaRef {
1128        Arc::new(Schema::new(vec![Field::new(
1129            "value",
1130            DataType::Int64,
1131            true,
1132        )]))
1133    }
1134
1135    fn batch() -> RecordBatch {
1136        RecordBatch::try_new(
1137            schema(),
1138            vec![Arc::new(Int64Array::from(vec![1_i64])) as ArrayRef],
1139        )
1140        .unwrap()
1141    }
1142
1143    fn options() -> StreamOptions {
1144        StreamOptions::new(
1145            1024,
1146            NonZeroUsize::new(1).unwrap(),
1147            NonZeroUsize::new(1).unwrap(),
1148        )
1149    }
1150
1151    #[test]
1152    fn stream_exhaustion_is_repeatable_and_releases_source() {
1153        let closed = Arc::new(AtomicUsize::new(0));
1154        let factory = TestFactory {
1155            opened: Cell::new(0),
1156            closed: closed.clone(),
1157            batches: 1,
1158        };
1159        let mut stream = DataFrameStream::from_factory(&factory, options()).unwrap();
1160
1161        assert_eq!(factory.opened.get(), 1);
1162        assert_eq!(stream.next_batch().unwrap().unwrap().height(), 1);
1163        assert!(stream.next_batch().unwrap().is_none());
1164        assert!(stream.next_batch().unwrap().is_none());
1165        assert_eq!(stream.status(), StreamTerminal::Exhausted);
1166        assert_eq!(stream.schema().fields()[0].name(), "value");
1167        assert_eq!(closed.load(Ordering::SeqCst), 1);
1168        assert_eq!(stream.budget().usage().reserved_bytes, 0);
1169    }
1170
1171    #[test]
1172    fn cancel_is_repeatable_and_prevents_extra_batches() {
1173        let closed = Arc::new(AtomicUsize::new(0));
1174        let factory = TestFactory {
1175            opened: Cell::new(0),
1176            closed: closed.clone(),
1177            batches: 2,
1178        };
1179        let mut stream = DataFrameStream::from_factory(&factory, options()).unwrap();
1180
1181        stream.cancel().unwrap();
1182        stream.cancel().unwrap();
1183        assert!(matches!(
1184            stream.next_batch(),
1185            Err(DataFrameError::StreamCancelled)
1186        ));
1187        assert_eq!(closed.load(Ordering::SeqCst), 1);
1188    }
1189}