fix: use consistent intersect semantics for UNION field metadata - #2
fix: use consistent intersect semantics for UNION field metadata#2milesrichardson-edb wants to merge 1 commit into
Conversation
888664c to
609954e
Compare
The type coercion analyzer (coerce_union_schema) and the physical planner (union_schema) both merged union field metadata with last-writer-wins semantics, but picked the winning input differently, so the logical and physical union schemas could disagree on conflicting keys and fail the physical planner's aggregate schema check. Union::try_new already intersects field metadata via intersect_metadata_for_union. Use the same helper in both remaining call sites so conflicting keys are dropped everywhere a union schema is derived.
609954e to
5e8d6a1
Compare
|
@gruuya — requesting your sign-off on this before I file anything upstream. This is the fix for the TPC-DS Q71 DirectScan fallback we discussed in Slack (BEAC-411): union field metadata is computed at three sites in DataFusion with two different semantics, and the physical planner's aggregate schema check trips on the divergence. This PR makes The diff and PR body above are in final proposed form (CI green on this fork, including a test pinning cast-metadata-preservation, which is what keeps the logical and physical intersections in agreement when coercion casts fire). Below is the rest of the package: the proposed upstream issue text and the submission workflow. Please review all of it — code, issue text, and choreography — and flag anything you'd change. Proposed upstream issueTitle: Union field metadata is computed differently in type coercion and physical planning, failing the aggregate schema check Describe the bugDataFusion derives a union's schema in three places: The analyzer rewrites every To ReproduceReproducing this needs branches with conflicting non-empty field metadata that Fails on current main (ad7d6ea) and on 53/54: use std::collections::HashMap;
use std::sync::Arc;
use arrow::array::Int64Array;
use arrow::datatypes::{DataType, Field, Schema};
use arrow::record_batch::RecordBatch;
use datafusion::catalog::MemTable;
use datafusion::prelude::*;
#[tokio::main]
async fn main() -> datafusion::error::Result<()> {
let ctx = SessionContext::new();
let field_a = Field::new("a", DataType::Int64, false)
.with_metadata(HashMap::from([("PARQUET:field_id".into(), "1".into())]));
let schema_a = Arc::new(Schema::new(vec![field_a]));
let batch_a = RecordBatch::try_new(
Arc::clone(&schema_a),
vec![Arc::new(Int64Array::from(vec![1, 2, 3]))],
)?;
ctx.register_table("t1", Arc::new(MemTable::try_new(schema_a, vec![vec![batch_a]])?))?;
let field_b = Field::new("b", DataType::Int64, true)
.with_metadata(HashMap::from([("PARQUET:field_id".into(), "2".into())]));
let schema_b = Arc::new(Schema::new(vec![field_b]));
let batch_b = RecordBatch::try_new(
Arc::clone(&schema_b),
vec![Arc::new(Int64Array::from(vec![Some(10), None, Some(20)]))],
)?;
ctx.register_table("t2", Arc::new(MemTable::try_new(schema_b, vec![vec![batch_b]])?))?;
ctx.sql("SELECT sum(v) FROM (SELECT a AS v FROM t1 UNION ALL SELECT b AS v FROM t2) u")
.await?
.collect()
.await?;
Ok(())
}Expected behaviorThe query succeeds, with the logical and physical union schemas agreeing on Additional contextThis is the same kind of two-path divergence as apache#14356 (nullability) and Fixing this by reusing I have a fix ready and will open a PR shortly. Submission workflow (once you sign off)
Notes for your review: the choreography (same-person issue+PR minutes apart, |
gruuya
left a comment
There was a problem hiding this comment.
Union::try_new intersects field metadata across inputs with
intersect_metadata_for_union
I don't think this is true; intersect_metadata_for_union has been applied only at the logical planning level.
In fact, this is what I'd push back against: can we fix the original problem without shoehorning intersect_metadata_for_union at the physical (analyzer) level?
Maybe we can just extend the logic in intersect_metadata_for_union?
I reviewed it, and I don’t think @gruuya’s feedback requires a code change here.
On the underlying design point, extending |
Which issue does this PR close?
Rationale for this change
DataFusion derives a union's schema in three places:
Union::try_newin datafusion-expr,coerce_union_schemain the type coercion analyzer, andunion_schemain datafusion-physical-plan (shared byUnionExecandInterleaveExec). Since apache#17248,Union::try_newintersects field metadata across inputs withintersect_metadata_for_union: a key survives only if every input with non-empty metadata agrees on its value. The other two sites still merge withHashMap::extend, and they pick the surviving value differently:union_schemakeeps the metadata of the field selected byfind_or_first(Field::is_nullable), whilecoerce_union_schemafolds inputs in order, so the last input wins.The analyzer rewrites every
LogicalPlan::Unionwith the schema fromcoerce_union_schema, so for a SQL query the logical schema comes from one merge site and the physical schema from the other. When the union branches carry conflicting field metadata, for example differentPARQUET:field_idvalues from two Parquet files, and the branches differ in nullability, the two sites keep different values and the physical planner's aggregate schema check fails:This is the same kind of two-path divergence as apache#14356 (nullability) and apache#19049 (empty metadata); fixing only one of the two remaining sites would leave it disagreeing with the other.
What changes are included in this PR?
coerce_union_schema_with_schema(datafusion-optimizer) collects each field's metadata from every input and intersects it once withintersect_metadata_for_union, instead of merging in place.union_schema(datafusion-physical-plan, which already depends on datafusion-expr) intersects field metadata with the same helper. Field name, type and nullability selection are unchanged.coerce_union_schemadoc comment from Add documentation for UNION schema handling. apache/datafusion#17248 is updated to describe the intersect semantics.Schema-level metadata is still merged with last-writer-wins on both sites; only field-level metadata changes. This is a known inconsistency with
Union::try_new, which already intersects schema-level metadata, and is left as a follow-up rather than folded into this fix.Are these changes tested?
Yes.
union_schemaandcoerce_union_schema_with_schemacovering the same-value, conflicting, one-input-empty and all-empty cases. The remainingintersect_metadata_for_unioncases from fix: skip empty metadata in intersect_metadata_for_union to prevent s… apache/datafusion#21127 are covered by its own tests in datafusion-expr, since both sites now delegate to it.metadata.sltreproducing the failure above: an aggregate over aUNION ALLof two tables with conflictingPARQUET:field_idmetadata and differing nullability (registered in the test harness, since field metadata cannot be declared in SQL). It fails with the internal error on main and passes with this change.metadata.sltunions two tables that agree on field metadata but differ in type (Int32/Int64), so type coercion inserts a CAST above one branch. It pins the assumption that a coercion cast preserves field metadata on both the logical and physical side, which is what makes the two intersects in this PR agree when a cast is present.We audited existing coverage for a test that pinned the prior last-writer-wins behavior and found none:
datafusion/sqllogictest/test_files/union.slt,union_by_name.slt, andarrow_field.sltcarry no union field-metadata assertions, and the existingmetadata.sltunion coverage (the apache#19049 carve-out) only asserts data values throughNULLbranches, not metadata survival. No existing test was modified or deleted; every change in this PR is an addition. These are the first tests to pin union field-metadata semantics in either direction.Are there any user-facing changes?
Conflicting field-level metadata no longer survives a UNION: a key is kept only if every branch that has non-empty metadata agrees on its value, matching what
Union::try_newalready did; branches with empty metadata (e.g.NULLliterals) are skipped rather than erasing others. Previously one branch's value survived, chosen by input order in the logical schema and by nullability in the physical schema. Because the union result schema is whatCOPY ... TO(parquet/arrow) writes and whatCREATE VIEW,DataFrame::schema(), and the FFI/C-Data exports expose, a conflictingPARQUET:field_idorARROW:extension:namethat used to be written or reported is now dropped from those outputs. Schema-level (table) metadata is unchanged — still merged last-writer-wins.