Skip to content

physical-plan: coerce UNION/INTERLEAVE schema mismatches at plan time - #24094

Open
dariocurr wants to merge 4 commits into
apache:mainfrom
dariocurr:feat/union-schema-coercion-plan-time
Open

physical-plan: coerce UNION/INTERLEAVE schema mismatches at plan time#24094
dariocurr wants to merge 4 commits into
apache:mainfrom
dariocurr:feat/union-schema-coercion-plan-time

Conversation

@dariocurr

Copy link
Copy Markdown
Contributor

Which issue does this PR close?

Follow-up to #23861 (issue #15394). Not closing a new issue.

Rationale for this change

#23861 fixed UNION ALL batches carrying the wrong nullability when one leg
is NOT NULL and another isn't, by re-stamping each batch's schema inside
UnionExec/InterleaveExec's own execute(). In review, @alamb noted:

ideally we could coerce the schema at plan time but I don't know how to
coerce nullability

This PR does that: the coercion becomes an explicit node in the plan tree,
inserted when the plan is built, instead of invisible logic inside
execute().

What changes are included in this PR?

  • Adds CoerceSchemaExec, a single-child passthrough ExecutionPlan node.
    UnionExec::try_new/InterleaveExec::try_new insert it above any child
    whose own output schema disagrees with the computed union schema (in
    practice, only nullability differs -- UnionExec::try_new already rejects
    real data-type mismatches via calculate_union).
  • The actual batch re-stamping logic (SchemaConformingStream) is unchanged;
    it just lives under CoerceSchemaExec::execute() now instead of being
    called directly from UnionExec/InterleaveExec::execute().
  • Because it's a real plan node, CoerceSchemaExec implements the full
    ExecutionPlan surface a pure 1:1 passthrough needs to stay transparent
    to the optimizer: statistics passthrough, filter/limit pushdown,
    benefits_from_input_partitioning() -> false (so it doesn't trigger a
    spurious repartition), and proto (de)serialization -- the node erases
    itself on encode and is reconstructed by try_new on decode, so no
    protobuf schema change was needed.
  • A genuine data-type mismatch (as opposed to nullability-only) is now
    rejected eagerly at plan-build time (via EquivalenceProperties:: with_new_schema) rather than lazily at execute().

Are these changes tested?

  • New unit test test_union_partition_statistics_with_mismatched_nullability
    in union.rs, proving statistics aren't poisoned to Absent through the
    new node.
  • Existing union_nullable/union_nullable_spill regression tests from
    fix: UnionExec now conforms each batch to the union's declared schema #23861 continue to pass unchanged.
  • Updated the sqllogictest golden file (union.slt) where EXPLAIN
    output now shows the new node for pre-existing nullability-mismatched
    UNION ALL cases.
  • Benchmarked against the previous (inline) approach: no measurable
    performance difference in either the coerced or matched-schema case
    (differences were within run-to-run noise).

Are there any user-facing changes?

EXPLAIN output for a UNION ALL/interleaved plan with a nullability
mismatch across legs will now show a CoerceSchemaExec node that wasn't
there before. No behavioral or correctness change.

@github-actions github-actions Bot added sqllogictest SQL Logic Tests (.slt) physical-plan Changes to the physical-plan crate core Core DataFusion crate auto detected api change Auto detected API change labels Aug 5, 2026
@codecov-commenter

codecov-commenter commented Aug 5, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 88.32117% with 16 lines in your changes missing coverage. Please review.
✅ Project coverage is 81.02%. Comparing base (c4a539b) to head (bc2f35f).
⚠️ Report is 4 commits behind head on main.

Files with missing lines Patch % Lines
datafusion/physical-plan/src/union.rs 87.38% 2 Missing and 12 partials ⚠️
datafusion/physical-expr/src/projection.rs 92.30% 1 Missing and 1 partial ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main   #24094      +/-   ##
==========================================
+ Coverage   80.91%   81.02%   +0.11%     
==========================================
  Files        1103     1105       +2     
  Lines      377134   379804    +2670     
  Branches   377134   379804    +2670     
==========================================
+ Hits       305155   307749    +2594     
- Misses      53787    53821      +34     
- Partials    18192    18234      +42     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@dariocurr
dariocurr force-pushed the feat/union-schema-coercion-plan-time branch from 8c2409c to eab8fcc Compare August 5, 2026 09:52
Follow-up to apache#23861 (issue apache#15394). Moves the schema re-stamping for
nullability-mismatched UNION ALL / INTERLEAVE inputs out of `execute()`
and into plan construction, via a new `CoerceSchemaExec` node inserted by
`UnionExec::try_new`/`InterleaveExec::try_new` whenever a child's own
schema disagrees with the computed union schema. The node is now visible
in `EXPLAIN` output, is transparent for statistics/pushdown/proto
purposes, and adds no measurable overhead versus inline re-stamping.
@dariocurr
dariocurr force-pushed the feat/union-schema-coercion-plan-time branch from c987b39 to 2bba020 Compare August 5, 2026 09:59

@kosiew kosiew 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.

@dariocurr,

Thanks for working on this. The plan-visible CoerceSchemaExec approach looks good, and I like that it removes the schema restamping from UnionExec::execute() and InterleaveExec::execute() while keeping the coercion explicit in the plan.

I left one non-blocking suggestion for additional protobuf round-trip coverage. Otherwise, this looks good to me.

Comment thread datafusion/physical-plan/src/union.rs Outdated
FilterDescription::from_children(parent_filters, &self.children())
}

#[cfg(feature = "proto")]

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.

Could we add a protobuf round-trip regression test for nullable and non-nullable UNION and INTERLEAVE inputs?

CoerceSchemaExec::try_to_proto intentionally leaves the wrapper out of the serialized plan, while UnionExec::try_from_proto and InterleaveExec::try_from_proto rebuild it through try_new. It would be helpful to verify that the decoded plans contain the expected coercion and that their emitted batches expose the final nullable schema.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Added roundtrip_union_with_mismatched_nullability_executes and roundtrip_interleave_with_mismatched_nullability_executes — one non-nullable + one nullable literal leg, serialize/deserialize, confirm CoerceSchemaExec reappears in the decoded plan, then execute it and check every emitted batch carries the nullable schema.

Addresses review feedback on apache#24094 (kosiew): verify that
`CoerceSchemaExec::try_to_proto`'s wrapper-erasure trick is correctly
undone by `UnionExec`/`InterleaveExec::try_from_proto` re-inserting the
wrapper via `try_new`, and that the decoded plan's emitted batches
actually carry the coerced nullable schema, not just its EXPLAIN string.
@github-actions github-actions Bot added the proto Related to proto crate label Aug 5, 2026
@github-actions github-actions Bot removed the auto detected api change Auto detected API change label Aug 5, 2026
@alamb

alamb commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

Thanks for working on this. The plan-visible CoerceSchemaExec approach looks good, and I like that it removes the schema restamping from UnionExec::execute() and InterleaveExec::execute() while keeping the coercion explicit in the plan.

I wonder if instead of using an entirely new exec, we could use the existing ProjectionExec operator with Cast exprs? In theory it should already be capable of doing the schema tranformation (assuming the physical CastExpr can have a field -- not just a DataType)

Thank you for follow up on this @dariocurr and @kosiew

Per alamb's suggestion on apache#24094: CastExpr::new_with_target_field already
lets a cast carry an explicit target Field (not just a DataType), and the
cast kernel has a same-type fast path (Arc::clone, no data copy), so a
nullability-only coercion is just a same-type cast. UnionExec/InterleaveExec
now build a ProjectionExec with a CastExpr (or a plain Column when a leg's
field already matches exactly) instead of a hand-rolled ExecutionPlan.

This deletes the hand-rolled node's ~150 lines of trait boilerplate
(statistics/pushdown/proto plumbing) and, since ProjectionExec has an
ordinary protobuf message, removes the wrapper-erasure trick entirely --
there's no more invisible reinsertion on proto decode to reason about.
It also gets the existing projection-collapsing optimizer pass for free:
when a coerced leg's own top node is already a ProjectionExec, the two
fuse into one instead of stacking.

Also fixes two narrow gaps this surfaced in ProjectionExec's statistics
propagation (datafusion-physical-expr's project_column_statistics_through_expr):
a CastExpr whose source values are already of the target DataType is a
value-preserving relabeling, so unlike a real type-changing cast, sum_value
and byte_size should carry over unchanged rather than degrading to Absent.
@github-actions github-actions Bot added the physical-expr Changes to the physical-expr crates label Aug 5, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

core Core DataFusion crate physical-expr Changes to the physical-expr crates physical-plan Changes to the physical-plan crate proto Related to proto crate sqllogictest SQL Logic Tests (.slt)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants