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 e948f17dbf fix: UnionExec now conforms each batch to the union's 
declared schema (#23861)
e948f17dbf is described below

commit e948f17dbf7c67f753a41639a143f993dc945fe6
Author: dario curreri <[email protected]>
AuthorDate: Tue Aug 4 22:18:57 2026 +0200

    fix: UnionExec now conforms each batch to the union's declared schema 
(#23861)
    
    ## Which issue does this PR close?
    
    - Closes #23862.
    - Related to #15394.
    
    ## Rationale for this change
    
    `UNION ALL` between an input whose column is `NOT NULL` and an input
    where the same column is nullable produces a valid, correctly-typed
    logical plan — the analyzer already OR's nullability across legs in
    `coerce_union_schema`
    (`datafusion/optimizer/src/analyzer/type_coercion.rs`), so the union's
    *declared* schema correctly reports the field as nullable.
    
    The bug is at execution time: `UnionExec::execute()` hands out each
    child's `RecordBatch`es completely unchanged. A leg whose column was
    already `NOT NULL` (and therefore needed no `CAST` from the analyzer)
    keeps emitting batches with a `NOT NULL` field, contradicting the
    union's own declared (nullable) schema. DataFusion's own execution
    tolerates this silently, but any consumer that checks schema equality
    across batches from the same stream — most notably
    `pyarrow.Table.from_batches` via the Arrow C Stream FFI used by the
    `datafusion` Python bindings — rejects the stream with `ArrowInvalid:
    Schema at index N was different`, even though every individual `SELECT`
    runs fine on its own.
    
    ### Minimal reproducible example (Python)
    
    ```python
    import pyarrow as pa
    from datafusion import SessionContext
    
    ctx = SessionContext()
    ctx.register_record_batch(
        "table_a",
        pa.record_batch(
            {"id": [1, 2], "status": ["ok", "ok"]},
            schema=pa.schema([("id", pa.int64()), ("status", pa.string())]),  # 
NOT NULL
        ),
    )
    ctx.register_record_batch(
        "table_b",
        pa.record_batch(
            {"id": [3, 4], "status": ["done", None]},
            schema=pa.schema([("id", pa.int64()), ("status", pa.string(), 
True)]),  # nullable
        ),
    )
    
    df = ctx.sql("SELECT id, status FROM table_a UNION ALL SELECT id, status 
FROM table_b")
    print(df.schema())  # status: string, nullable -- correct
    df.to_pandas()      # raises pyarrow.lib.ArrowInvalid: Schema at index 1 
was different
    ```
    
    The same root cause is why `#16627` had to make the sqllogictest
    `convert_batches` helper tolerant of this exact mismatch instead of
    failing, and why `#15603` (stale, closed for inactivity) attempted a
    similar fix at the physical-execution layer but didn't land.
    
    ## What changes are included in this PR?
    
    - `datafusion/physical-plan/src/union.rs`: `UnionExec::execute()` now
    compares each child stream's schema against `UnionExec`'s own declared
    schema, and if they disagree, wraps the child stream in a small new
    `SchemaConformingStream` that re-stamps every batch with the union's
    schema before yielding it. This is always safe: the union's schema can
    only be *more* permissive than any single input's (nullability is
    combined with logical OR, never narrowed — see the existing
    `coerce_union_schema` docs), and only the `Field::nullable` metadata
    changes; the underlying array data and data type are untouched.
    - `datafusion/core/tests/sql/union_nullable.rs` (new): regression tests
    covering same-type nullable/non-nullable mismatches in both leg orders,
    the "both legs NOT NULL" case (schema should stay `NOT NULL`), and a
    case where one leg also needs a real `CAST` (`Int32` -> `Int64`) in
    addition to the nullability fix.
    
    `InterleaveExec` (used for sorted unions) may have an analogous issue,
    but I kept this PR scoped to plain `UnionExec`, which is what's reported
    in #23862 / #15394 and reproduces the Python-binding failure above.
    
    ## Are these changes tested?
    
    Yes — added `datafusion/core/tests/sql/union_nullable.rs` with 4 new
    tests. I verified each one fails with a clear schema-mismatch assertion
    on `main` (i.e. before this fix) and passes with it applied. Also ran
    the full `datafusion-physical-plan` and `datafusion-optimizer` unit
    suites, `union.slt`/`union_by_name.slt` sqllogictests, and `cargo
    fmt`/`clippy` (`--no-deps`, since an unrelated pre-existing dead-code
    lint in `datafusion-physical-expr` fails `-D warnings` on `main` even
    without this change).
    
    ## Are there any user-facing changes?
    
    `UNION ALL` results now consistently report the analyzer's declared
    nullability on every batch, regardless of which leg produced it. No
    public API changes.
---
 .../tests/memory_limit/union_nullable_spill.rs     |  13 +-
 datafusion/core/tests/sql/mod.rs                   |   1 +
 datafusion/core/tests/sql/union_nullable.rs        | 204 +++++++++++++++++++++
 datafusion/physical-plan/src/union.rs              | 130 ++++++++++++-
 4 files changed, 342 insertions(+), 6 deletions(-)

diff --git a/datafusion/core/tests/memory_limit/union_nullable_spill.rs 
b/datafusion/core/tests/memory_limit/union_nullable_spill.rs
index c5ef2387d3..d04273bc7f 100644
--- a/datafusion/core/tests/memory_limit/union_nullable_spill.rs
+++ b/datafusion/core/tests/memory_limit/union_nullable_spill.rs
@@ -103,10 +103,15 @@ fn build_task_ctx(pool_size: usize) -> 
Arc<datafusion_execution::TaskContext> {
 /// have mismatched nullability (one child's `val` is non-nullable, the other's
 /// is nullable with NULLs). A tiny FairSpillPool forces all batches to spill.
 ///
-/// UnionExec returns child streams without schema coercion, so batches from
-/// different children carry different per-field nullability into the shared
-/// SpillPool. The IPC writer must use the SpillManager's canonical (nullable)
-/// schema — not the first batch's schema — so readback batches are valid.
+/// `UnionExec` now re-stamps every child batch with its own declared 
(nullable)
+/// schema before they reach `RepartitionExec` (see
+/// <https://github.com/apache/datafusion/issues/15394>), so this no longer
+/// exercises mismatched-nullability batches arriving at the SpillManager via
+/// `UnionExec` specifically. It's kept as a regression test for the
+/// SpillManager fix itself: the IPC writer must use the SpillManager's
+/// canonical schema -- not the first batch's schema -- so readback batches
+/// stay valid for any caller that does hand it batches with differing
+/// nullability. See <https://github.com/apache/datafusion/issues/21292>.
 ///
 /// Otherwise, sort_batch will panic with
 /// `Column 'val' is declared as non-nullable but contains null values`
diff --git a/datafusion/core/tests/sql/mod.rs b/datafusion/core/tests/sql/mod.rs
index 33f9d3c02c..afed2f82d5 100644
--- a/datafusion/core/tests/sql/mod.rs
+++ b/datafusion/core/tests/sql/mod.rs
@@ -71,6 +71,7 @@ mod runtime_config;
 pub mod select;
 mod sql_api;
 mod union_comparison;
+mod union_nullable;
 mod unparser;
 
 async fn register_aggregate_csv_by_sql(ctx: &SessionContext) {
diff --git a/datafusion/core/tests/sql/union_nullable.rs 
b/datafusion/core/tests/sql/union_nullable.rs
new file mode 100644
index 0000000000..d2dc663366
--- /dev/null
+++ b/datafusion/core/tests/sql/union_nullable.rs
@@ -0,0 +1,204 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+//! Regression tests asserting that every batch yielded by a `UNION ALL`
+//! reports the union's own declared schema, even when the same column is
+//! `NOT NULL` on one leg and nullable on another. See
+//! <https://github.com/apache/datafusion/issues/15394>.
+
+use std::sync::Arc;
+
+use arrow::array::{Int64Array, RecordBatch, StringArray};
+use arrow::datatypes::{DataType, Field, Schema};
+use datafusion::prelude::*;
+use datafusion_common::Result;
+
+/// Builds two single-partition tables that agree on `id`/`status` types but
+/// disagree on whether `status` is nullable, then runs `UNION ALL` over them.
+async fn union_all_mismatched_nullable(
+    left_nullable: bool,
+    right_nullable: bool,
+) -> Result<DataFrame> {
+    let ctx = SessionContext::new();
+
+    let schema_a = Arc::new(Schema::new(vec![
+        Field::new("id", DataType::Int64, false),
+        Field::new("status", DataType::Utf8, left_nullable),
+    ]));
+    let batch_a = RecordBatch::try_new(
+        Arc::clone(&schema_a),
+        vec![
+            Arc::new(Int64Array::from(vec![1, 2])),
+            Arc::new(StringArray::from(vec!["ok", "ok"])),
+        ],
+    )?;
+    ctx.register_batch("table_a", batch_a)?;
+
+    let schema_b = Arc::new(Schema::new(vec![
+        Field::new("id", DataType::Int64, false),
+        Field::new("status", DataType::Utf8, right_nullable),
+    ]));
+    let status_values: Vec<Option<&str>> = if right_nullable {
+        vec![Some("done"), None]
+    } else {
+        vec![Some("done"), Some("also-done")]
+    };
+    let batch_b = RecordBatch::try_new(
+        Arc::clone(&schema_b),
+        vec![
+            Arc::new(Int64Array::from(vec![3, 4])),
+            Arc::new(StringArray::from(status_values)),
+        ],
+    )?;
+    ctx.register_batch("table_b", batch_b)?;
+
+    ctx.sql(
+        "SELECT id, status FROM table_a \
+         UNION ALL \
+         SELECT id, status FROM table_b",
+    )
+    .await
+}
+
+/// The schema DataFusion actually commits to for a query: the logical plan
+/// after the `Analyzer` (which includes the `UNION` nullability/type
+/// coercion this test targets) and `Optimizer` have run. `DataFrame::schema`
+/// alone is not enough here -- it reflects the raw, pre-`Analyzer` plan (see
+/// `SessionState::create_logical_plan`), which for a `UNION` still has the
+/// first leg's un-coerced type.
+fn analyzed_schema(df: &DataFrame) -> Result<Schema> {
+    Ok(df
+        .clone()
+        .into_optimized_plan()?
+        .schema()
+        .as_arrow()
+        .clone())
+}
+
+/// Every `RecordBatch` actually produced by a `UNION ALL` must match the
+/// query's analyzed output schema field-for-field -- including
+/// nullability -- no matter which leg it came from.
+async fn assert_every_batch_matches_declared_schema(df: DataFrame) -> 
Result<()> {
+    let declared_schema = analyzed_schema(&df)?;
+
+    let batches = df.collect().await?;
+    assert!(!batches.is_empty());
+    for batch in &batches {
+        assert_eq!(
+            batch.schema().as_ref(),
+            &declared_schema,
+            "a UNION ALL leg produced a RecordBatch whose schema disagrees \
+             with the union's declared output schema (commonly a dropped \
+             nullable flag) -- this is what downstream consumers that check \
+             schema equality across batches (e.g. pyarrow) reject with \
+             `ArrowInvalid: Schema at index N was different`"
+        );
+    }
+    Ok(())
+}
+
+#[tokio::test]
+async fn union_all_same_type_left_not_null_right_nullable() -> Result<()> {
+    let df = union_all_mismatched_nullable(false, true).await?;
+    assert!(
+        analyzed_schema(&df)?
+            .field_with_name("status")?
+            .is_nullable()
+    );
+    assert_every_batch_matches_declared_schema(df).await
+}
+
+#[tokio::test]
+async fn union_all_same_type_left_nullable_right_not_null() -> Result<()> {
+    let df = union_all_mismatched_nullable(true, false).await?;
+    assert!(
+        analyzed_schema(&df)?
+            .field_with_name("status")?
+            .is_nullable()
+    );
+    assert_every_batch_matches_declared_schema(df).await
+}
+
+#[tokio::test]
+async fn union_all_same_type_both_not_null_stays_not_null() -> Result<()> {
+    let df = union_all_mismatched_nullable(false, false).await?;
+    let declared_schema = analyzed_schema(&df)?;
+    assert!(
+        !declared_schema.field_with_name("status")?.is_nullable(),
+        "status should remain NOT NULL when neither leg is nullable"
+    );
+    assert_every_batch_matches_declared_schema(df).await
+}
+
+/// Same bug, but the coercion also has to widen the *type* (Int32 -> Int64)
+/// on one leg. The leg that already matched the target type still needed
+/// its nullability reconciled at execution time, independent of whichever
+/// legs needed a `CAST`.
+#[tokio::test]
+async fn union_all_widening_cast_also_fixes_nullable() -> Result<()> {
+    use arrow::array::Int32Array;
+
+    let ctx = SessionContext::new();
+
+    let schema_a = Arc::new(Schema::new(vec![
+        Field::new("id", DataType::Int64, false),
+        Field::new("val", DataType::Int32, false),
+    ]));
+    let batch_a = RecordBatch::try_new(
+        Arc::clone(&schema_a),
+        vec![
+            Arc::new(Int64Array::from(vec![1, 2])),
+            Arc::new(Int32Array::from(vec![10, 20])),
+        ],
+    )?;
+    ctx.register_batch("table_a", batch_a)?;
+
+    let schema_b = Arc::new(Schema::new(vec![
+        Field::new("id", DataType::Int64, false),
+        Field::new("val", DataType::Int64, true),
+    ]));
+    let batch_b = RecordBatch::try_new(
+        Arc::clone(&schema_b),
+        vec![
+            Arc::new(Int64Array::from(vec![3, 4])),
+            Arc::new(Int64Array::from(vec![Some(30), None])),
+        ],
+    )?;
+    ctx.register_batch("table_b", batch_b)?;
+
+    let df = ctx
+        .sql(
+            "SELECT id, val FROM table_a \
+             UNION ALL \
+             SELECT id, val FROM table_b",
+        )
+        .await?;
+
+    let declared_schema = analyzed_schema(&df)?;
+    assert_eq!(
+        declared_schema.field_with_name("val")?.data_type(),
+        &DataType::Int64
+    );
+    assert!(declared_schema.field_with_name("val")?.is_nullable());
+
+    let batches = df.collect().await?;
+    assert!(!batches.is_empty());
+    for batch in &batches {
+        assert_eq!(batch.schema().as_ref(), &declared_schema);
+    }
+    Ok(())
+}
diff --git a/datafusion/physical-plan/src/union.rs 
b/datafusion/physical-plan/src/union.rs
index 4722329ea5..8d77556509 100644
--- a/datafusion/physical-plan/src/union.rs
+++ b/datafusion/physical-plan/src/union.rs
@@ -46,6 +46,7 @@ use crate::projection::{ProjectionExec, make_with_child};
 use crate::statistics::{ChildStats, StatisticsArgs};
 use crate::stream::ObservedStream;
 
+use arrow::array::RecordBatchOptions;
 use arrow::datatypes::{Field, Schema, SchemaRef};
 use arrow::record_batch::RecordBatch;
 use datafusion_common::config::ConfigOptions;
@@ -58,11 +59,88 @@ use datafusion_physical_expr::{
     EquivalenceProperties, PhysicalExpr, calculate_union, conjunction,
 };
 
-use futures::Stream;
+use futures::{Stream, StreamExt};
 use itertools::Itertools;
 use log::{debug, trace, warn};
 use tokio::macros::support::thread_rng_n;
 
+/// Wraps a child stream so that every batch it yields is re-stamped with
+/// `schema` instead of the child's own schema.
+///
+/// This is used by both [`UnionExec`] and [`InterleaveExec`] when a child's
+/// output schema disagrees with the operator's declared output schema --
+/// in practice this only happens for nullability (the declared schema is
+/// nullable wherever *any* input's field is, but casts are only inserted
+/// between inputs when the *type* differs, not when only nullability
+/// does). For [`UnionExec`], [`UnionExec::try_new`] guarantees this: it
+/// calls `calculate_union`, which rejects any input whose field data types
+/// don't match the computed union schema. [`InterleaveExec::try_new`] does
+/// not repeat that check -- its inputs are only ever produced by the
+/// optimizer rewriting an already-validated `UnionExec`, whose children's
+/// types are therefore already known to agree -- but if this wrapper ever
+/// did see a genuine data type mismatch (e.g. from a hand-built
+/// `InterleaveExec`), `RecordBatch::try_new_with_options` below reports it
+/// as an error rather than silently yielding a corrupt batch.
+struct SchemaConformingStream {
+    schema: SchemaRef,
+    inner: SendableRecordBatchStream,
+}
+
+impl SchemaConformingStream {
+    fn new(schema: SchemaRef, inner: SendableRecordBatchStream) -> Self {
+        Self { schema, inner }
+    }
+}
+
+impl RecordBatchStream for SchemaConformingStream {
+    fn schema(&self) -> SchemaRef {
+        Arc::clone(&self.schema)
+    }
+}
+
+impl Stream for SchemaConformingStream {
+    type Item = Result<RecordBatch>;
+
+    fn poll_next(
+        mut self: Pin<&mut Self>,
+        cx: &mut Context<'_>,
+    ) -> Poll<Option<Self::Item>> {
+        self.inner.poll_next_unpin(cx).map(|opt| {
+            opt.map(|batch_result| {
+                batch_result.and_then(|batch| {
+                    let options =
+                        
RecordBatchOptions::new().with_row_count(Some(batch.num_rows()));
+                    RecordBatch::try_new_with_options(
+                        Arc::clone(&self.schema),
+                        batch.columns().to_vec(),
+                        &options,
+                    )
+                    .map_err(Into::into)
+                })
+            })
+        })
+    }
+
+    fn size_hint(&self) -> (usize, Option<usize>) {
+        self.inner.size_hint()
+    }
+}
+
+/// Wraps `stream` in a [`SchemaConformingStream`] if its schema disagrees
+/// with `schema`, otherwise returns it unchanged. See
+/// [`SchemaConformingStream`] and
+/// <https://github.com/apache/datafusion/issues/15394>.
+fn conform_stream_schema(
+    schema: SchemaRef,
+    stream: SendableRecordBatchStream,
+) -> SendableRecordBatchStream {
+    if stream.schema() == schema {
+        stream
+    } else {
+        Box::pin(SchemaConformingStream::new(schema, stream))
+    }
+}
+
 /// `UnionExec`: `UNION ALL` execution plan.
 ///
 /// `UnionExec` combines multiple inputs with the same schema by
@@ -294,6 +372,7 @@ impl ExecutionPlan for UnionExec {
             if partition < input.output_partitioning().partition_count() {
                 let stream = input.execute(partition, context)?;
                 debug!("Found a Union partition to execute");
+                let stream = conform_stream_schema(self.schema(), stream);
                 return Ok(Box::pin(ObservedStream::new(
                     stream,
                     baseline_metrics,
@@ -668,7 +747,8 @@ impl ExecutionPlan for InterleaveExec {
         let mut input_stream_vec = vec![];
         for input in self.inputs.iter() {
             if partition < input.output_partitioning().partition_count() {
-                input_stream_vec.push(input.execute(partition, 
Arc::clone(&context))?);
+                let stream = input.execute(partition, Arc::clone(&context))?;
+                input_stream_vec.push(conform_stream_schema(self.schema(), 
stream));
             } else {
                 // Do not find a partition to execute
                 break;
@@ -982,6 +1062,52 @@ mod tests {
         Ok(())
     }
 
+    #[tokio::test]
+    async fn test_interleave_conforms_batch_schema() -> Result<()> {
+        // Two inputs agree on the column's type but disagree on nullability;
+        // InterleaveExec's declared schema ORs nullability across inputs, so
+        // every yielded batch must be re-stamped with that schema. See
+        // <https://github.com/apache/datafusion/issues/15394>.
+        let task_ctx = Arc::new(TaskContext::default());
+
+        let schema_not_null =
+            Arc::new(Schema::new(vec![Field::new("a", DataType::Int32, 
false)]));
+        let batch_not_null = RecordBatch::try_new(
+            Arc::clone(&schema_not_null),
+            vec![Arc::new(arrow::array::Int32Array::from(vec![1, 2]))],
+        )?;
+
+        let schema_nullable =
+            Arc::new(Schema::new(vec![Field::new("a", DataType::Int32, 
true)]));
+        let batch_nullable = RecordBatch::try_new(
+            Arc::clone(&schema_nullable),
+            vec![Arc::new(arrow::array::Int32Array::from(vec![3, 4]))],
+        )?;
+
+        let hash_expr = vec![col("a", schema_not_null.as_ref())?];
+        let left: Arc<dyn ExecutionPlan> = Arc::new(RepartitionExec::try_new(
+            TestMemoryExec::try_new_exec(&[vec![batch_not_null]], 
schema_not_null, None)?,
+            Partitioning::Hash(hash_expr.clone(), 1),
+        )?);
+        let right: Arc<dyn ExecutionPlan> = Arc::new(RepartitionExec::try_new(
+            TestMemoryExec::try_new_exec(&[vec![batch_nullable]], 
schema_nullable, None)?,
+            Partitioning::Hash(hash_expr, 1),
+        )?);
+
+        let interleave: Arc<dyn ExecutionPlan> =
+            Arc::new(InterleaveExec::try_new(vec![left, right])?);
+        let interleave_schema = interleave.schema();
+        assert!(interleave_schema.field(0).is_nullable());
+
+        let batches = collect(interleave, task_ctx).await?;
+        assert!(!batches.is_empty());
+        for batch in &batches {
+            assert_eq!(batch.schema(), interleave_schema);
+        }
+
+        Ok(())
+    }
+
     fn stats_merge_inputs() -> (SchemaRef, Statistics, Statistics, Statistics) 
{
         let schema = Arc::new(Schema::new(vec![Field::new("a", 
DataType::UInt32, true)]));
 


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

Reply via email to