Describe the bug
UnnestExec emits exactly one output batch per input batch, however many rows the unnesting produces. datafusion.execution.batch_size is never consulted.
UnnestStream::poll_next_impl calls build_batch once per input batch and returns the whole result:
let result = build_batch(
&batch,
&self.schema,
&self.list_type_columns,
&self.struct_column_indices,
&self.options,
)?;
...
Some(Ok(result_batch))
There is no batch_size anywhere in unnest.rs, so input_batches always equals output_batches in the operator's metrics.
Two consequences:
- Downstream operators receive arbitrarily large batches. An 8192-row batch of 100-element lists comes back as a single 819,200-row batch.
- Peak memory scales with input batch size times list length, rather than with
batch_size. The entire unnested result for an input batch is materialized at once.
This is likely a contributing factor in #20788, though the discussion there centers on ordered array_agg buffering rather than on UnnestExec itself.
To Reproduce
-- 40 rows, each holding a 1000-element list
CREATE TABLE t AS
SELECT i AS id, range(0, 1000) AS xs
FROM (SELECT unnest(range(1, 41)) AS i);
SET datafusion.execution.batch_size = 8192;
EXPLAIN ANALYZE SELECT unnest(xs) FROM t;
UnnestExec reports one output batch per input batch, with output batches far larger than 8192 rows.
Expected behavior
Output batches should respect datafusion.execution.batch_size, and unnesting should not materialize the full expansion of an input batch at once.
Note that an exact size cannot be guaranteed in every case: a single input row whose list is longer than batch_size cannot be split across output batches without splitting within a row. batch_size should be an upper bound.
Additional context
find_longest_length already computes how many output rows each input row expands into, with all three NullHandling modes accounted for. Prefix-summing it gives exact chunk boundaries for depth-1 unnesting, which is enough to bound both output size and peak memory by consuming each input batch in chunks.
Recursive unnest (depth > 1) is not predictable that way, since a row's expansion depends on inner list lengths that only exist after the outer levels have been unnested. That case, and the oversized-single-row case, can be handled by slicing the built batch instead. Struct-only unnesting does not change the row count, so it is already bounded by the input batch size.
Describe the bug
UnnestExecemits exactly one output batch per input batch, however many rows the unnesting produces.datafusion.execution.batch_sizeis never consulted.UnnestStream::poll_next_implcallsbuild_batchonce per input batch and returns the whole result:There is no
batch_sizeanywhere inunnest.rs, soinput_batchesalways equalsoutput_batchesin the operator's metrics.Two consequences:
batch_size. The entire unnested result for an input batch is materialized at once.This is likely a contributing factor in #20788, though the discussion there centers on ordered
array_aggbuffering rather than onUnnestExecitself.To Reproduce
UnnestExecreports one output batch per input batch, with output batches far larger than 8192 rows.Expected behavior
Output batches should respect
datafusion.execution.batch_size, and unnesting should not materialize the full expansion of an input batch at once.Note that an exact size cannot be guaranteed in every case: a single input row whose list is longer than
batch_sizecannot be split across output batches without splitting within a row.batch_sizeshould be an upper bound.Additional context
find_longest_lengthalready computes how many output rows each input row expands into, with all threeNullHandlingmodes accounted for. Prefix-summing it gives exact chunk boundaries for depth-1 unnesting, which is enough to bound both output size and peak memory by consuming each input batch in chunks.Recursive unnest (
depth > 1) is not predictable that way, since a row's expansion depends on inner list lengths that only exist after the outer levels have been unnested. That case, and the oversized-single-row case, can be handled by slicing the built batch instead. Struct-only unnesting does not change the row count, so it is already bounded by the input batch size.