Skip to content

Fix spurious failure in convert_batches test helper - #16627

Merged
findepi merged 1 commit into
apache:mainfrom
findepi:findepi/fix-spurious-failure-in-convert-batches-test-helper-aff883
Jul 1, 2025
Merged

Fix spurious failure in convert_batches test helper#16627
findepi merged 1 commit into
apache:mainfrom
findepi:findepi/fix-spurious-failure-in-convert-batches-test-helper-aff883

Conversation

@findepi

@findepifindepi commented Jun 30, 2025

Copy link
Copy Markdown
Member

Which issue does this PR close?

Rationale for this change

When query involves e.g. UNION ALL, it may produces record batches with incompatible schema. For example, one union branch may produce a nullable field while the order may produce a non-null field. Before the change, convert_batches success or failure depended on ordering of record batches returned by the query, and thus could lead (and did lead) to test flakiness.

What changes are included in this PR?

This changes schema source used by the convert_batches. Instead of peaking the first schema, it used the declared schema from the data frame, which should be correct with respect to nullability.

Are these changes tested?

yes

Are there any user-facing changes?

no

@github-actionsgithub-actionsBot added the sqllogictest SQL Logic Tests (.slt) label Jun 30, 2025
When query involves e.g. UNION ALL, it may produces record batches with
incompatible schema. For example, one union branch may produce a
nullable field while the order may produce a non-null field. Before the
change, `convert_batches` success or failure depended on ordering of
record batches returned by the query, and thus could lead (and did lead)
to test flakiness. This changes schema source used by the
`convert_batches`. Instead of peaking the first schema, it used the
declared schema from the data frame, which should be correct with
respect to nullability.
@findepi
findepiforce-pushed the findepi/fix-spurious-failure-in-convert-batches-test-helper-aff883 branch from 8fa74c0 to 36f21cfCompareJune 30, 2025 14:41
@alamb

Copy link
Copy Markdown
Contributor

THANK YOU

@alambalamb left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thank you @findepi -- this makes sense to me. I have hit the random failure in datafusion/sqllogictest/test_files/union_by_name.slt before so this is a nice welcome addition

@findepi
findepi merged commit df9f096 into apache:mainJul 1, 2025
27 checks passed
@findepi
findepi deleted the findepi/fix-spurious-failure-in-convert-batches-test-helper-aff883 branch July 1, 2025 07:44
avantgardnerio added a commit to coralogix/arrow-datafusion that referenced this pull request Mar 18, 2026
avantgardnerio added a commit to coralogix/arrow-datafusion that referenced this pull request Mar 18, 2026
yliang412 pushed a commit to yliang412/datafusion that referenced this pull request Aug 4, 2026
…apache#23861)
## Which issue does this PR close?
- Closesapache#23862.
- Related to apache#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 `apache#16627` had to make the sqllogictest
`convert_batches` helper tolerant of this exact mismatch instead of
failing, and why `apache#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 apache#23862 / apache#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.
kosiew pushed a commit to kosiew/datafusion that referenced this pull request Aug 12, 2026
…apache#23861)
## Which issue does this PR close?
- Closesapache#23862.
- Related to apache#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 `apache#16627` had to make the sqllogictest
`convert_batches` helper tolerant of this exact mismatch instead of
failing, and why `apache#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 apache#23862 / apache#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.
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

sqllogictestSQL Logic Tests (.slt)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Flaky SLT test union_by_name.slt:343

2 participants

@findepi@alamb