This is an automated email from the ASF dual-hosted git repository.
github-merge-queue[bot] pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/datafusion.git
The following commit(s) were added to refs/heads/main by this push:
new 7c5a81bfaa fix: count UnnestExec output batches after splitting
(#24482)
7c5a81bfaa is described below
commit 7c5a81bfaae4e1a431cc46013fabdb80114212fb
Author: Goutam Adwant <[email protected]>
AuthorDate: Thu Aug 20 18:03:15 2026 +0000
fix: count UnnestExec output batches after splitting (#24482)
## Which issue does this PR close?
- Closes #24469.
## Rationale for this change
`UnnestExec` currently records its baseline output metrics before
`BatchSplitStream` divides an oversized result. For example, unnesting
25 rows with a batch size of 10 emits batches containing `[10, 10, 5]`
rows, but reports `output_batches=1` instead of `3`.
This makes the metric disagree with the batches observed by downstream
operators and by users inspecting execution metrics.
## What changes are included in this PR?
- Move baseline output observation to the final stream boundary by
wrapping `BatchSplitStream` with the existing `ObservedStream`.
- Keep `elapsed_compute` measurement inside `UnnestStream`, where the
unnest work is performed.
- Add a regression test that verifies the emitted batch sizes and the
corresponding `output_batches` and `output_rows` metrics.
As a result, baseline output metrics, including `output_bytes`, are
recorded from the batches emitted by the final stream. As documented for
`BaselineMetrics`, `output_bytes` may overestimate memory when sliced
batches share underlying buffers.
## Are these changes tested?
Yes.
- The regression test fails before the production change with
`output_batches=1` and passes afterward with `output_batches=3`.
- All 13 `unnest` tests pass.
- All 1,760 `datafusion-physical-plan` unit tests and its documentation
tests pass.
- The extended workspace test command from `AGENTS.md` passes, including
all 503 SQL logic test files.
- All 107 `datafusion-cli` unit and integration tests pass.
- `cargo fmt --all -- --check` passes.
- `cargo clippy --all-targets --all-features -- -D warnings` passes.
- Rust documentation builds with warnings denied.
## Are there any user-facing changes?
Yes. `UnnestExec` now reports baseline output metrics at the
consumer-visible output boundary, so `output_batches` matches the number
of emitted batches. Query results, row ordering, batch boundaries,
plans, and public APIs are unchanged.
Co-authored-by: Andrew Lamb <[email protected]>
---
datafusion/physical-plan/src/unnest.rs | 65 +++++++++++++++++++++++++++++++---
1 file changed, 60 insertions(+), 5 deletions(-)
diff --git a/datafusion/physical-plan/src/unnest.rs
b/datafusion/physical-plan/src/unnest.rs
index 6dfc2a0e53..964225d71c 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, SplitMetrics,
+ MetricsSet, SplitMetrics,
};
use super::{DisplayAs, ExecutionPlanProperties, PlanProperties};
-use crate::stream::{BatchSplitStream, EmptyRecordBatchStream};
+use crate::stream::{BatchSplitStream, EmptyRecordBatchStream, ObservedStream};
use crate::{
ChildrenPropertiesMode, DisplayFormatType, Distribution, ExecutionPlan,
RecordBatchStream, ReplaceChildrenOptions, SendableRecordBatchStream,
@@ -296,6 +296,7 @@ impl ExecutionPlan for UnnestExec {
let batch_size = context.session_config().batch_size();
let input = self.input.execute(partition, context)?;
let metrics = UnnestMetrics::new(partition, &self.metrics);
+ let baseline_metrics = metrics.baseline_metrics.clone();
let stream = Box::pin(UnnestStream {
input,
@@ -311,10 +312,15 @@ impl ExecutionPlan for UnnestExec {
// 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(
+ let stream = Box::pin(BatchSplitStream::new(
stream,
batch_size,
SplitMetrics::new(&self.metrics, partition),
+ ));
+ Ok(Box::pin(ObservedStream::new(
+ stream,
+ baseline_metrics,
+ None,
)))
}
@@ -651,7 +657,6 @@ impl UnnestStream {
// 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;
@@ -1435,6 +1440,7 @@ mod tests {
use arrow::datatypes::{Field, Int32Type};
use datafusion_common::NullHandling;
use datafusion_common::test_util::batches_to_string;
+ use datafusion_physical_expr_common::metrics::MetricValue;
use insta::assert_snapshot;
// Create a GenericListArray with the following list values:
@@ -2213,6 +2219,19 @@ mod tests {
options: UnnestOptions,
depth: usize,
) -> Result<Vec<RecordBatch>> {
+ Ok(
+ unnest_at_depth_with_metrics(input, batch_size, options, depth)
+ .await?
+ .0,
+ )
+ }
+
+ async fn unnest_at_depth_with_metrics(
+ input: Vec<RecordBatch>,
+ batch_size: usize,
+ options: UnnestOptions,
+ depth: usize,
+ ) -> Result<(Vec<RecordBatch>, MetricsSet)> {
let input_schema = input[0].schema();
let output_schema =
Arc::new(Schema::new(vec![Field::new("l", DataType::Int32,
true)]));
@@ -2234,7 +2253,9 @@ mod tests {
.with_batch_size(batch_size),
),
);
- crate::common::collect(unnest.execute(0, task_ctx)?).await
+ let batches = crate::common::collect(unnest.execute(0,
task_ctx)?).await?;
+ let metrics = unnest.metrics().expect("UnnestExec exposes metrics");
+ Ok((batches, metrics))
}
/// The values an unnest produces, flattened across all output batches.
@@ -2329,6 +2350,40 @@ mod tests {
Ok(())
}
+ #[tokio::test]
+ async fn test_unnest_stream_output_metrics_after_split() -> Result<()> {
+ let (batches, metrics) = unnest_at_depth_with_metrics(
+ vec![list_batch(&[Some(25)])],
+ 10,
+ UnnestOptions::default(),
+ 1,
+ )
+ .await?;
+
+ assert_eq!(
+ batches
+ .iter()
+ .map(RecordBatch::num_rows)
+ .collect::<Vec<_>>(),
+ vec![10, 10, 5]
+ );
+ let output_batches = metrics
+ .sum(|metric| matches!(metric.value(),
MetricValue::OutputBatches(_)))
+ .expect("output_batches metric exists")
+ .as_usize();
+ assert_eq!(output_batches, batches.len());
+ let output_rows = metrics
+ .sum(|metric| matches!(metric.value(), MetricValue::OutputRows(_)))
+ .expect("output_rows metric exists")
+ .as_usize();
+ assert_eq!(
+ output_rows,
+ batches.iter().map(RecordBatch::num_rows).sum::<usize>()
+ );
+
+ Ok(())
+ }
+
#[tokio::test]
async fn test_unnest_stream_chunking_preserves_null_handling() ->
Result<()> {
// NULL and empty lists each contribute one NULL output row under
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]