This is an automated email from the ASF dual-hosted git repository.

alamb pushed a commit to branch branch-55
in repository https://gitbox.apache.org/repos/asf/datafusion.git


The following commit(s) were added to refs/heads/branch-55 by this push:
     new a323585e84 [branch-55] fix: make UnnestExec respect 
datafusion.execution.batch_size (#24384) (#24529)
a323585e84 is described below

commit a323585e8411250c1c05afc7c81be2b6b592e0df
Author: Andrew Lamb <[email protected]>
AuthorDate: Thu Aug 20 15:53:11 2026 -0400

    [branch-55] fix: make UnnestExec respect datafusion.execution.batch_size 
(#24384) (#24529)
    
    ## Which issue does this PR close?
    
    - Part of https://github.com/apache/datafusion/issues/24462
    - Backport of #24384 to `branch-55` (for 55.1.0, tracked in #24462).
    - Fixes #24383
    
    ## Rationale for this change
    
    `UnnestExec` emitted exactly one output batch per input batch, however
    many rows the unnesting produced, never consulting
    `datafusion.execution.batch_size`. This let downstream operators receive
    arbitrarily large batches and made peak memory scale with input batch
    size times list length instead of `batch_size`. No API change, so it
    fits the backport criteria.
    
    ## What changes are included in this PR?
    
    Clean cherry-pick of #24384 (commit
    6187d47ec5658190e729d4d3cad2cc10805aecdb). No adaptation required.
    
    ## Are these changes tested?
    
    Yes. Carries the original regression coverage, all tests pass.
    
    ## Are there any user-facing changes?
    
    `UnnestExec` output batches now respect
    `datafusion.execution.batch_size` as an upper bound. No API changes.
    
    Co-authored-by: Andy Grove <[email protected]>
---
 datafusion/physical-plan/src/unnest.rs        | 472 ++++++++++++++++++++++++--
 datafusion/sqllogictest/test_files/unnest.slt |  40 +++
 2 files changed, 476 insertions(+), 36 deletions(-)

diff --git a/datafusion/physical-plan/src/unnest.rs 
b/datafusion/physical-plan/src/unnest.rs
index 3fa274b27a..6dfc2a0e53 100644
--- a/datafusion/physical-plan/src/unnest.rs
+++ b/datafusion/physical-plan/src/unnest.rs
@@ -23,10 +23,10 @@ use std::task::{Poll, ready};
 
 use super::metrics::{
     self, BaselineMetrics, ExecutionPlanMetricsSet, MetricBuilder, 
MetricCategory,
-    MetricsSet, RecordOutput,
+    MetricsSet, RecordOutput, SplitMetrics,
 };
 use super::{DisplayAs, ExecutionPlanProperties, PlanProperties};
-use crate::stream::EmptyRecordBatchStream;
+use crate::stream::{BatchSplitStream, EmptyRecordBatchStream};
 use crate::{
     ChildrenPropertiesMode, DisplayFormatType, Distribution, ExecutionPlan,
     RecordBatchStream, ReplaceChildrenOptions, SendableRecordBatchStream,
@@ -293,17 +293,29 @@ impl ExecutionPlan for UnnestExec {
         partition: usize,
         context: Arc<TaskContext>,
     ) -> Result<SendableRecordBatchStream> {
+        let batch_size = context.session_config().batch_size();
         let input = self.input.execute(partition, context)?;
         let metrics = UnnestMetrics::new(partition, &self.metrics);
 
-        Ok(Box::pin(UnnestStream {
+        let stream = Box::pin(UnnestStream {
             input,
             schema: Arc::clone(&self.schema),
             list_type_columns: self.list_column_indices.clone(),
             struct_column_indices: 
self.struct_column_indices.iter().copied().collect(),
             options: self.options.clone(),
             metrics,
-        }))
+            batch_size,
+            pending_input: None,
+        });
+
+        // Chunking the input bounds each build to roughly `batch_size` rows, 
but two cases
+        // can still produce an oversized batch (see `predict_output_lens`), 
so the output
+        // goes through the shared splitter to make the bound unconditional.
+        Ok(Box::pin(BatchSplitStream::new(
+            stream,
+            batch_size,
+            SplitMetrics::new(&self.metrics, partition),
+        )))
     }
 
     fn metrics(&self) -> Option<MetricsSet> {
@@ -518,6 +530,67 @@ struct UnnestStream {
     options: UnnestOptions,
     /// Metrics
     metrics: UnnestMetrics,
+    /// Target number of rows per output batch, from 
`datafusion.execution.batch_size`.
+    batch_size: usize,
+    /// Rows of the current input batch that have not been unnested yet. 
Unnesting one
+    /// input batch can produce arbitrarily many output rows, so the input is 
consumed in
+    /// chunks small enough that each chunk's output stays near `batch_size`.
+    ///
+    /// Note the scope of the memory bound this buys: chunking removes the 
input batch size
+    /// from the peak, but not the length of an individual list. A single row 
whose list is
+    /// longer than `batch_size`, and recursive unnesting (where the expansion 
cannot be
+    /// predicted up front), both still materialize their full expansion in 
one build.
+    pending_input: Option<PendingInput>,
+}
+
+/// An input batch being unnested incrementally, a chunk of rows at a time.
+struct PendingInput {
+    /// The full input batch. Rows before `row_offset` have already been 
unnested.
+    batch: RecordBatch,
+    /// Index of the next input row to unnest.
+    row_offset: usize,
+    /// How many output rows each input row expands into, indexed by input row.
+    ///
+    /// `None` when the expansion cannot be predicted from the input alone, in 
which case
+    /// the whole remaining input is unnested in one call and only the output 
is split.
+    /// See [`UnnestStream::predict_output_lens`].
+    output_lens: Option<PrimitiveArray<Int64Type>>,
+}
+
+impl PendingInput {
+    fn remaining_rows(&self) -> usize {
+        self.batch.num_rows() - self.row_offset
+    }
+
+    /// How many input rows to unnest next so the resulting batch holds at most
+    /// `batch_size` rows.
+    fn next_chunk_rows(&self, batch_size: usize) -> usize {
+        let Some(output_lens) = &self.output_lens else {
+            return self.remaining_rows();
+        };
+
+        let lens = &output_lens.values()[self.row_offset..];
+        let batch_size = batch_size as i64;
+        let mut output_rows = 0i64;
+        for (rows, len) in lens.iter().enumerate() {
+            // The first row is always taken, even if it alone overshoots 
`batch_size`: an
+            // input row is never split across builds, so this is what 
guarantees progress.
+            // An oversized build is sliced down by `BatchSplitStream` on the 
way out.
+            if rows > 0 && output_rows + len > batch_size {
+                return rows;
+            }
+            output_rows += len;
+        }
+        lens.len()
+    }
+
+    /// The per-row output lengths covering the next `rows` input rows, so the 
unnesting
+    /// does not have to recompute what `predict_output_lens` already derived.
+    fn chunk_lengths(&self, rows: usize) -> Option<PrimitiveArray<Int64Type>> {
+        self.output_lens
+            .as_ref()
+            .map(|lens| lens.slice(self.row_offset, rows))
+    }
 }
 
 impl RecordBatchStream for UnnestStream {
@@ -546,30 +619,60 @@ impl UnnestStream {
         cx: &mut std::task::Context<'_>,
     ) -> Poll<Option<Result<RecordBatch>>> {
         loop {
-            return Poll::Ready(match ready!(self.input.poll_next_unpin(cx)) {
+            // Unnest the next chunk of the input batch already in hand.
+            if let Some(pending) = self.pending_input.as_mut() {
+                // `PendingInput` is only built from a non-empty batch and 
`next_chunk_rows`
+                // always consumes at least one row, so it is dropped the 
moment it drains.
+                debug_assert!(pending.remaining_rows() > 0);
+
+                let rows = pending.next_chunk_rows(self.batch_size);
+                let chunk = pending.batch.slice(pending.row_offset, rows);
+                let chunk_lengths = pending.chunk_lengths(rows);
+                pending.row_offset += rows;
+                let drained = pending.remaining_rows() == 0;
+
+                let timer = 
self.metrics.baseline_metrics.elapsed_compute().timer();
+                let result = build_batch(
+                    &chunk,
+                    &self.schema,
+                    &self.list_type_columns,
+                    &self.struct_column_indices,
+                    &self.options,
+                    chunk_lengths.as_ref(),
+                );
+                timer.done();
+
+                if drained {
+                    self.pending_input = None;
+                }
+
+                // A chunk can legitimately produce no rows at all, for 
example when every
+                // list in it is empty under `NullHandling::Drop`; 
`build_batch` signals
+                // that with `None` rather than an empty batch.
+                if let Some(batch) = result? {
+                    debug_assert!(batch.num_rows() > 0);
+                    (&batch).record_output(&self.metrics.baseline_metrics);
+                    return Poll::Ready(Some(Ok(batch)));
+                }
+                continue;
+            }
+
+            // Otherwise pull the next input batch.
+            match ready!(self.input.poll_next_unpin(cx)) {
                 Some(Ok(batch)) => {
-                    let elapsed_compute =
-                        
self.metrics.baseline_metrics.elapsed_compute().clone();
-                    let timer = elapsed_compute.timer();
                     self.metrics.input_batches.add(1);
                     self.metrics.input_rows.add(batch.num_rows());
-                    let result = build_batch(
-                        &batch,
-                        &self.schema,
-                        &self.list_type_columns,
-                        &self.struct_column_indices,
-                        &self.options,
-                    )?;
-                    timer.done();
-                    let Some(result_batch) = result else {
-                        continue;
-                    };
-                    
(&result_batch).record_output(&self.metrics.baseline_metrics);
-
-                    // Empty record batches should not be emitted.
-                    // They need to be treated as  [`Option<RecordBatch>`]es 
and handled separately
-                    debug_assert!(result_batch.num_rows() > 0);
-                    Some(Ok(result_batch))
+                    if batch.num_rows() > 0 {
+                        let timer =
+                            
self.metrics.baseline_metrics.elapsed_compute().timer();
+                        let output_lens = self.predict_output_lens(&batch);
+                        timer.done();
+                        self.pending_input = Some(PendingInput {
+                            batch,
+                            row_offset: 0,
+                            output_lens: output_lens?,
+                        });
+                    }
                 }
                 // If the stream is depleted or returned an error, log the 
finish message:
                 other => {
@@ -590,11 +693,48 @@ impl UnnestStream {
                         self.input = 
Box::pin(EmptyRecordBatchStream::new(input_schema));
                     }
 
-                    other
+                    return Poll::Ready(other);
                 }
-            });
+            }
         }
     }
+
+    /// Compute how many output rows each input row of `batch` will expand 
into, so the
+    /// input can be chunked to keep each build bounded.
+    ///
+    /// Returns `None` when the count cannot be derived from the input alone, 
which is the
+    /// signal to unnest the whole batch in one call:
+    ///
+    /// * With no list columns, unnesting only widens structs and leaves the 
row count
+    ///   alone, so the output is already bounded by the input batch size.
+    /// * With recursion (`depth > 1`), a row's expansion depends on the 
lengths of inner
+    ///   lists that only exist after the outer levels have been unnested, so 
it cannot be
+    ///   predicted up front.
+    fn predict_output_lens(
+        &self,
+        batch: &RecordBatch,
+    ) -> Result<Option<PrimitiveArray<Int64Type>>> {
+        if self.list_type_columns.is_empty()
+            || self
+                .list_type_columns
+                .iter()
+                .any(|unnest| unnest.depth != 1)
+        {
+            return Ok(None);
+        }
+
+        let list_arrays: Vec<ArrayRef> = self
+            .list_type_columns
+            .iter()
+            .map(|unnest| 
Arc::clone(batch.column(unnest.index_in_input_schema)))
+            .collect();
+
+        // This is exactly the per-row length that `list_unnest_at_level` 
derives when it
+        // actually unnests, so the chunk boundaries are exact rather than 
estimated, and
+        // each chunk's slice of it is handed back to `build_batch` instead of 
recomputed.
+        let longest_length = find_longest_length(&list_arrays, &self.options)?;
+        Ok(Some(longest_length.as_primitive::<Int64Type>().clone()))
+    }
 }
 
 /// Given a set of struct column indices to flatten
@@ -686,6 +826,7 @@ fn list_unnest_at_level(
     temp_unnested_arrs: &mut HashMap<ListUnnest, ArrayRef>,
     level_to_unnest: usize,
     options: &UnnestOptions,
+    precomputed_lengths: Option<&PrimitiveArray<Int64Type>>,
 ) -> Result<Option<Vec<ArrayRef>>> {
     // Extract unnestable columns at this level
     let (arrs_to_unnest, list_unnest_specs): (Vec<Arc<dyn Array>>, Vec<_>) =
@@ -712,8 +853,17 @@ fn list_unnest_at_level(
 
     // Filter out so that list_arrays only contain column with the highest 
depth
     // at the same time, during iteration remove this depth so next time we 
don't have to unnest them again
-    let longest_length = find_longest_length(&arrs_to_unnest, options)?;
-    let unnested_length = longest_length.as_primitive::<Int64Type>();
+    //
+    // The caller may already have computed these lengths to decide how many 
input rows to
+    // feed us; reusing them avoids running the kernel chain twice over the 
same rows.
+    // Cloning is an `Arc` bump on the underlying buffer, not a copy.
+    let longest_length = match precomputed_lengths {
+        Some(lengths) => lengths.clone(),
+        None => find_longest_length(&arrs_to_unnest, options)?
+            .as_primitive::<Int64Type>()
+            .clone(),
+    };
+    let unnested_length = &longest_length;
     let total_length = if unnested_length.is_empty() {
         0
     } else {
@@ -830,6 +980,7 @@ fn build_batch(
     list_type_columns: &[ListUnnest],
     struct_column_indices: &HashSet<usize>,
     options: &UnnestOptions,
+    precomputed_lengths: Option<&PrimitiveArray<Int64Type>>,
 ) -> Result<Option<RecordBatch>> {
     let transformed = match list_type_columns.len() {
         0 => flatten_struct_cols(batch.columns(), schema, 
struct_column_indices),
@@ -851,12 +1002,21 @@ fn build_batch(
                     true => batch.columns(),
                     false => &flatten_arrs,
                 };
+                // Only sound for a single non-recursive level: with recursion 
the deeper
+                // levels' lengths depend on arrays that do not exist yet, 
which is also why
+                // the caller does not predict lengths in that case.
+                let level_lengths = if max_recursion == 1 {
+                    precomputed_lengths
+                } else {
+                    None
+                };
                 let Some(temp_result) = list_unnest_at_level(
                     input,
                     list_type_columns,
                     &mut temp_unnested_result,
                     depth,
                     options,
+                    level_lengths,
                 )?
                 else {
                     return Ok(None);
@@ -1269,7 +1429,7 @@ fn repeat_arrs_from_indices(
 mod tests {
     use super::*;
     use arrow::array::{
-        GenericListArray, NullBufferBuilder, OffsetSizeTrait, StringArray,
+        GenericListArray, Int32Array, NullBufferBuilder, OffsetSizeTrait, 
StringArray,
     };
     use arrow::buffer::{NullBuffer, OffsetBuffer};
     use arrow::datatypes::{Field, Int32Type};
@@ -1459,6 +1619,7 @@ mod tests {
                 null_handling: NullHandling::Preserve,
                 recursions: vec![],
             },
+            None,
         )?
         .unwrap();
 
@@ -1503,8 +1664,7 @@ mod tests {
         //   NULL      -> one  row  with c2 = 5 and unnested value NULL
         //   [NULL, F] -> two  rows with c2 = 6, 6
         let list_array = Arc::new(make_generic_array::<i32>()) as ArrayRef;
-        let other =
-            Arc::new(arrow::array::Int32Array::from(vec![1, 2, 3, 4, 5, 6])) 
as ArrayRef;
+        let other = Arc::new(Int32Array::from(vec![1, 2, 3, 4, 5, 6])) as 
ArrayRef;
         let in_schema = Arc::new(Schema::new(vec![
             Field::new(
                 "c1",
@@ -1535,6 +1695,7 @@ mod tests {
                 null_handling: NullHandling::PreserveAndExpandEmpty,
                 recursions: vec![],
             },
+            None,
         )?
         .unwrap();
 
@@ -1562,8 +1723,7 @@ mod tests {
     #[test]
     fn test_build_batch_preserve_and_expand_empty_largelist() -> Result<()> {
         let list_array = Arc::new(make_generic_array::<i64>()) as ArrayRef;
-        let other =
-            Arc::new(arrow::array::Int32Array::from(vec![1, 2, 3, 4, 5, 6])) 
as ArrayRef;
+        let other = Arc::new(Int32Array::from(vec![1, 2, 3, 4, 5, 6])) as 
ArrayRef;
         let in_schema = Arc::new(Schema::new(vec![
             Field::new(
                 "c1",
@@ -1597,6 +1757,7 @@ mod tests {
                 null_handling: NullHandling::PreserveAndExpandEmpty,
                 recursions: vec![],
             },
+            None,
         )?
         .unwrap();
 
@@ -1647,8 +1808,7 @@ mod tests {
             b.append(false);
             b.finish()
         };
-        let id =
-            Arc::new(arrow::array::Int32Array::from(vec![10, 20, 30, 40])) as 
ArrayRef;
+        let id = Arc::new(Int32Array::from(vec![10, 20, 30, 40])) as ArrayRef;
 
         let in_schema = Arc::new(Schema::new(vec![
             Field::new(
@@ -1696,6 +1856,7 @@ mod tests {
                 null_handling: NullHandling::PreserveAndExpandEmpty,
                 recursions: vec![],
             },
+            None,
         )?
         .unwrap();
 
@@ -1817,6 +1978,7 @@ mod tests {
                 null_handling: NullHandling::PreserveAndExpandEmpty,
                 recursions: vec![],
             },
+            None,
         )?
         .unwrap();
 
@@ -2005,4 +2167,242 @@ mod tests {
         assert_eq!(take_indices, expected);
         Ok(())
     }
+
+    /// Build a single-column `List<Int32>` batch where row `i` holds 
`lens[i]` elements,
+    /// numbered consecutively from 0 across the whole batch. A `None` length 
is a NULL
+    /// list.
+    fn list_batch(lens: &[Option<usize>]) -> RecordBatch {
+        let mut next = 0i32;
+        let rows: Vec<Option<Vec<Option<i32>>>> = lens
+            .iter()
+            .map(|len| {
+                len.map(|len| {
+                    (0..len)
+                        .map(|_| {
+                            next += 1;
+                            Some(next - 1)
+                        })
+                        .collect()
+                })
+            })
+            .collect();
+        let list = ListArray::from_iter_primitive::<Int32Type, _, _>(rows);
+        let schema = Arc::new(Schema::new(vec![Field::new(
+            "l",
+            list.data_type().clone(),
+            true,
+        )]));
+        RecordBatch::try_new(schema, vec![Arc::new(list)]).unwrap()
+    }
+
+    /// Run a depth-1 unnest of column "l" over `input`, with the given
+    /// `datafusion.execution.batch_size`, and return the output batches.
+    async fn unnest_with_batch_size(
+        input: Vec<RecordBatch>,
+        batch_size: usize,
+        options: UnnestOptions,
+    ) -> Result<Vec<RecordBatch>> {
+        unnest_at_depth(input, batch_size, options, 1).await
+    }
+
+    /// Unnest column "l" of `input` to `depth`, with the given
+    /// `datafusion.execution.batch_size`, and return the output batches.
+    async fn unnest_at_depth(
+        input: Vec<RecordBatch>,
+        batch_size: usize,
+        options: UnnestOptions,
+        depth: usize,
+    ) -> Result<Vec<RecordBatch>> {
+        let input_schema = input[0].schema();
+        let output_schema =
+            Arc::new(Schema::new(vec![Field::new("l", DataType::Int32, 
true)]));
+        let source =
+            crate::test::TestMemoryExec::try_new_exec(&[input], input_schema, 
None)?;
+        let unnest = UnnestExec::new(
+            source,
+            vec![ListUnnest {
+                index_in_input_schema: 0,
+                depth,
+            }],
+            vec![],
+            output_schema,
+            options,
+        )?;
+        let task_ctx = Arc::new(
+            TaskContext::default().with_session_config(
+                datafusion_execution::config::SessionConfig::new()
+                    .with_batch_size(batch_size),
+            ),
+        );
+        crate::common::collect(unnest.execute(0, task_ctx)?).await
+    }
+
+    /// The values an unnest produces, flattened across all output batches.
+    fn output_values(batches: &[RecordBatch]) -> Vec<Option<i32>> {
+        batches
+            .iter()
+            .flat_map(|batch| {
+                batch
+                    .column(0)
+                    .as_primitive::<Int32Type>()
+                    .iter()
+                    .collect::<Vec<_>>()
+            })
+            .collect()
+    }
+
+    /// Output batch sizes are fully determined by the input lengths and 
`batch_size`, so
+    /// assert the exact shapes rather than just the `<= batch_size` bound. 
Each case pins a
+    /// distinct path through `next_chunk_rows`.
+    #[tokio::test]
+    async fn test_unnest_stream_output_batch_shapes() -> Result<()> {
+        struct Case {
+            /// One inner slice per input batch, each of that batch's per-row 
list lengths.
+            lens_per_batch: &'static [&'static [Option<usize>]],
+            batch_size: usize,
+            expected_sizes: &'static [usize],
+        }
+        let cases: &[Case] = &[
+            // Chunks pack several input rows. This is the case that 
distinguishes chunking
+            // the input from building everything and slicing: slicing a 
single 30-row build
+            // would give [8, 8, 8, 6].
+            Case {
+                lens_per_batch: &[&[Some(3); 10]],
+                batch_size: 8,
+                expected_sizes: &[6, 6, 6, 6, 6],
+            },
+            // Output smaller than batch_size comes back as one batch.
+            Case {
+                lens_per_batch: &[&[Some(3), Some(2)]],
+                batch_size: 1024,
+                expected_sizes: &[5],
+            },
+            // One row expanding past batch_size cannot be chunked on the 
input side, so the
+            // oversized build is sliced on the way out instead.
+            Case {
+                lens_per_batch: &[&[Some(25)]],
+                batch_size: 10,
+                expected_sizes: &[10, 10, 5],
+            },
+            // Chunk boundaries are per input batch, so each batch contributes 
a short tail.
+            Case {
+                lens_per_batch: &[&[Some(5), Some(5)], &[Some(1)], &[Some(7), 
Some(2)]],
+                batch_size: 4,
+                expected_sizes: &[4, 1, 4, 1, 1, 4, 3, 2],
+            },
+        ];
+
+        for case in cases {
+            let input: Vec<RecordBatch> = case
+                .lens_per_batch
+                .iter()
+                .map(|lens| list_batch(lens))
+                .collect();
+            let batches =
+                unnest_with_batch_size(input, case.batch_size, 
UnnestOptions::default())
+                    .await?;
+
+            let sizes: Vec<usize> = batches.iter().map(|b| 
b.num_rows()).collect();
+            assert_eq!(
+                sizes, case.expected_sizes,
+                "lens={:?} batch_size={}",
+                case.lens_per_batch, case.batch_size
+            );
+
+            // `list_batch` numbers each batch's elements from 0, so the 
expected values are
+            // one run per input batch. Splitting must not perturb values or 
their order.
+            let expected_values: Vec<Option<i32>> = case
+                .lens_per_batch
+                .iter()
+                .flat_map(|lens| {
+                    (0..lens.iter().flatten().sum::<usize>() as i32).map(Some)
+                })
+                .collect();
+            assert_eq!(
+                output_values(&batches),
+                expected_values,
+                "lens={:?} batch_size={}",
+                case.lens_per_batch,
+                case.batch_size
+            );
+        }
+        Ok(())
+    }
+
+    #[tokio::test]
+    async fn test_unnest_stream_chunking_preserves_null_handling() -> 
Result<()> {
+        // NULL and empty lists each contribute one NULL output row under
+        // PreserveAndExpandEmpty, and the per-row output counts that drive 
chunking must
+        // agree with that or chunk boundaries would drift out of step with 
the unnesting.
+        let lens = &[Some(3), Some(0), None, Some(2), None, Some(0)];
+        let options =
+            
UnnestOptions::new().with_null_handling(NullHandling::PreserveAndExpandEmpty);
+
+        let chunked =
+            unnest_with_batch_size(vec![list_batch(lens)], 2, 
options.clone()).await?;
+        let whole = unnest_with_batch_size(vec![list_batch(lens)], 1024, 
options).await?;
+
+        assert!(chunked.iter().all(|b| b.num_rows() <= 2));
+        // 3 + 1 + 1 + 2 + 1 + 1
+        assert_eq!(chunked.iter().map(|b| b.num_rows()).sum::<usize>(), 9);
+        assert_eq!(output_values(&chunked), output_values(&whole));
+        Ok(())
+    }
+
+    #[tokio::test]
+    async fn test_unnest_stream_drop_null_handling() -> Result<()> {
+        // Under Drop, NULL and empty lists produce nothing. Chunks made up 
entirely of
+        // such rows yield no batch at all, and must not stall the stream or 
leak an
+        // empty batch into the output.
+        let lens = &[None, Some(0), None, Some(4), Some(0), None];
+        let options = 
UnnestOptions::new().with_null_handling(NullHandling::Drop);
+
+        let batches = unnest_with_batch_size(vec![list_batch(lens)], 2, 
options).await?;
+
+        assert!(batches.iter().all(|b| b.num_rows() > 0));
+        assert_eq!(batches.iter().map(|b| b.num_rows()).sum::<usize>(), 4);
+        Ok(())
+    }
+
+    #[tokio::test]
+    async fn test_unnest_stream_recursive_respects_batch_size() -> Result<()> {
+        // Recursive unnest cannot have its expansion predicted from the 
input, so it falls
+        // back to unnesting a whole input batch and slicing the output. The 
batch_size
+        // guarantee has to hold on that path too.
+        let inner = Field::new_list_field(DataType::Int32, true);
+        let outer =
+            Field::new_list_field(DataType::new_list(DataType::Int32, true), 
true);
+        let values = Int32Array::from((0..24).collect::<Vec<_>>());
+        // 12 inner lists of 2 elements each...
+        let inner_list = ListArray::new(
+            Arc::new(inner),
+            OffsetBuffer::new((0..=12).map(|i| i * 
2).collect::<Vec<i32>>().into()),
+            Arc::new(values),
+            None,
+        );
+        // ...grouped 3 to a row, so 4 input rows expand to 24 output rows at 
depth 2.
+        let outer_list = ListArray::new(
+            Arc::new(outer),
+            OffsetBuffer::new((0..=4).map(|i| i * 
3).collect::<Vec<i32>>().into()),
+            Arc::new(inner_list),
+            None,
+        );
+        let input_schema = Arc::new(Schema::new(vec![Field::new(
+            "l",
+            outer_list.data_type().clone(),
+            true,
+        )]));
+        let input = RecordBatch::try_new(input_schema, 
vec![Arc::new(outer_list)])?;
+
+        let batches =
+            unnest_at_depth(vec![input], 7, UnnestOptions::default(), 
2).await?;
+
+        let sizes: Vec<usize> = batches.iter().map(|b| b.num_rows()).collect();
+        assert_eq!(sizes, vec![7, 7, 7, 3]);
+        assert_eq!(
+            output_values(&batches),
+            (0..24).map(Some).collect::<Vec<_>>()
+        );
+        Ok(())
+    }
 }
diff --git a/datafusion/sqllogictest/test_files/unnest.slt 
b/datafusion/sqllogictest/test_files/unnest.slt
index a3385b81d7..8c6cc3fa49 100644
--- a/datafusion/sqllogictest/test_files/unnest.slt
+++ b/datafusion/sqllogictest/test_files/unnest.slt
@@ -1646,3 +1646,43 @@ DROP TABLE varied_lists;
 
 statement ok
 DROP TABLE dense_lists;
+
+## Unnest respects `datafusion.execution.batch_size` 
(apache/datafusion#24383). Chunking
+## the input must not perturb row order or drop rows; batch shapes are 
asserted in the
+## Rust unit tests, which is where they are actually observable.
+
+statement ok
+CREATE TABLE fanout AS
+  SELECT i AS id, range(0, i % 7) AS xs FROM (SELECT unnest(range(1, 40)) AS 
i);
+
+statement ok
+set datafusion.execution.batch_size = 3;
+
+query II
+SELECT id, unnest(xs) AS x FROM fanout WHERE id IN (5, 6, 7, 8) ORDER BY id, x;
+----
+5 0
+5 1
+5 2
+5 3
+5 4
+6 0
+6 1
+6 2
+6 3
+6 4
+6 5
+8 0
+
+# Recursive unnest cannot have its expansion predicted from the input, so it 
takes the
+# output-slicing path instead of input chunking.
+query I
+SELECT count(*) FROM (SELECT unnest(unnest(make_array(range(0, 20), range(0, 
20)))) AS x);
+----
+40
+
+statement ok
+set datafusion.execution.batch_size = 8192;
+
+statement ok
+DROP TABLE fanout;


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to