fix: preserve projection metadata during optimization - #24670
fix: preserve projection metadata during optimization#24670gene-bordegaray wants to merge 24 commits into
Conversation
gabotechs
left a comment
There was a problem hiding this comment.
Good catch @gene-bordegaray! just to give more context, we were bitten by this in our system while upgrading.
Just left a suggestion for relaxing the requirements, but otherwise LGTM.
| }) && exprs.len() == projection.input().schema().fields().len() | ||
| && projection.schema() == projection.input().schema() |
There was a problem hiding this comment.
This might be putting more restrictions than just metadata equality. It might be fine, but if we want to play it safe it could be better to just do && projection.schema().metadata() == projection.input().schema().metadata()
There was a problem hiding this comment.
We need to check full schema because even if the schema metadata is equal things like the field metadata might not be thus we nee to check this as well.
I don't see anything in the schema which would be overestricting this. I may be missing something though
There was a problem hiding this comment.
Pretty much the order of columns. I bet that's why the current checks are like they are right now.
I think it's fine though, if this becomes too restrictive it will start popping up in tests
|
🤔 there seems to be a CI failure: Do you think it's related to this change? |
looking into |
Found issues, this is a bit more involved than I was hoping. Will the variants with the fix |
|
@gabotechs ok I figured out what was going on and documented it in the PR description. There is also another bug in the codec / serialization where we need to serialize metadata. I am not solving that in this PR to keep scoped / tracked. I will crete issue for this tmrw or you can if you would like 👍 |
42888f7 to
822b3f9
Compare
|
this is also a correctenss issue / regression in 55 so I can note this in the minor version bump |
822b3f9 to
f64100d
Compare
Codecov Report❌ Patch coverage is Additional details and impacted files@@ Coverage Diff @@
## main #24670 +/- ##
==========================================
+ Coverage 81.58% 81.59% +0.01%
==========================================
Files 1123 1123
Lines 406610 407226 +616
Branches 406610 407226 +616
==========================================
+ Hits 331719 332281 +562
- Misses 55453 55477 +24
- Partials 19438 19468 +30 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
|
created codec / serialization follow up here: #24695 |
f64100d to
beed4cd
Compare
|
ok I pushed a change that introduces an enum to differentiation between data type and explicit field casts. It is a larger and public api change but it is what I see as properly tracking this information, not an ad hoc check |
beed4cd to
c30f5be
Compare
# Conflicts: # datafusion/physical-expr/src/expressions/cast.rs # datafusion/physical-expr/src/expressions/mod.rs # datafusion/physical-expr/src/planner.rs
…-metadata-cleanup
| /// The `DataType` the expression will yield | ||
| pub field: FieldRef, | ||
| /// The target type and metadata policy. | ||
| pub field: CastTarget, |
There was a problem hiding this comment.
This is a breaking change of a public type, so if we go down this route this PR will not be eligible for a backport to 55. https://datafusion.apache.org/contributor-guide/release_management.html#backport-criteria
| #[prost(message, optional, tag = "5")] | ||
| pub target_field: ::core::option::Option<super::datafusion_common::Field>, |
There was a problem hiding this comment.
This and other places in this file are adding pub fields also making this a breaking change.
|
hey @timsaucer yes, this PR was originally meant to be stacked on #24725 but because of the breaking changes we are gong to take #23169 approach which avoids this for now. Then I will rebase this on that PR and will not hve these breaking change 👍 |
Restore the two public signatures on `CastExpr` that this branch changed,
so the metadata fixes can be backported to a patch release:
* `CastExpr::new_with_target_field` takes `FieldRef` by value again
* `CastExpr::target_field()` returns `&FieldRef` again
Both are achieved by storing the target `FieldRef` as before and adding a
private `explicit_target` flag that records whether the field was supplied
by the caller or synthesized from a `DataType`. That flag carries exactly
the information the `Option<HashMap<..>>` / `Option<bool>` pair carried, so
`target_metadata()`, `target_nullable()`, `has_explicit_metadata()` and
`has_explicit_nullability()` keep their meaning and are derived from it.
`Hash`/`PartialEq` compare the same components as before, so the field name
still does not participate.
`TryCastExpr` is given the same shape for symmetry. Its metadata-aware API
is new on this branch, so nothing there is a compatibility constraint.
Cast semantics are unchanged: explicit target fields still supply their
metadata and nullability verbatim, type-only casts still pass source
metadata through with the extension type keys stripped, and the output
field name still comes from the source expression.
Comparing the public signatures against the merge base with main, the only
remaining deltas are additions:
+ CastExpr::target_metadata / target_nullable
+ CastExpr::has_explicit_metadata / has_explicit_nullability
+ TryCastExpr::new_with_target_field / target_metadata / target_field
+ expressions::try_cast_with_target_field
`cast_with_target_field` keeps its `&FieldRef` parameter: `mod cast` is
private and it is only re-exported as `pub(crate)`, so it is not part of
the public API.
Verified: `cargo clippy --workspace --all-targets -- -D warnings` clean,
504/504 sqllogictest files pass, and the unit tests for physical-expr,
physical-expr-adapter, physical-plan, pruning, expr, functions and the
datafusion core lib all pass.
Keep cast metadata changes non-breaking
| /// Derives the output field for a cast expression from the source field, using | ||
| /// explicit target metadata when supplied. | ||
| /// For `TryCast`, `force_nullable` is `true` since a failed cast returns NULL. | ||
| fn cast_output_field( | ||
| source_field: &FieldRef, | ||
| target_type: &DataType, | ||
| target: &CastTarget, | ||
| force_nullable: bool, | ||
| ) -> Arc<Field> { | ||
| let metadata = target | ||
| .metadata() | ||
| .cloned() | ||
| .unwrap_or_else(|| source_field.metadata().clone()); | ||
| let mut f = source_field | ||
| .as_ref() | ||
| .clone() | ||
| .with_data_type(target_type.clone()) | ||
| .with_metadata(source_field.metadata().clone()); | ||
| .with_data_type(target.data_type().clone()) | ||
| .with_metadata(metadata); |
There was a problem hiding this comment.
This is the only change I needed on top of projection.rs to make the UUID test pass (counterfactual confirmed: reverting just this reproduces the CI failure).
Note your callsite changes below — cast_output_field(&src, field, false) / (&src, field, true) — work unchanged with this, since Cast.field would go back to being a FieldRef. So this suggestion plus dropping CastTarget is the whole edit; the other ~20 files in the stack come out with it.
| /// Derives the output field for a cast expression from the source field, using | |
| /// explicit target metadata when supplied. | |
| /// For `TryCast`, `force_nullable` is `true` since a failed cast returns NULL. | |
| fn cast_output_field( | |
| source_field: &FieldRef, | |
| target_type: &DataType, | |
| target: &CastTarget, | |
| force_nullable: bool, | |
| ) -> Arc<Field> { | |
| let metadata = target | |
| .metadata() | |
| .cloned() | |
| .unwrap_or_else(|| source_field.metadata().clone()); | |
| let mut f = source_field | |
| .as_ref() | |
| .clone() | |
| .with_data_type(target_type.clone()) | |
| .with_metadata(source_field.metadata().clone()); | |
| .with_data_type(target.data_type().clone()) | |
| .with_metadata(metadata); | |
| /// Derives the output field for a cast expression from the source field. | |
| /// | |
| /// The cast target's metadata is authoritative when it carries any; otherwise the | |
| /// source's metadata is inherited. This mirrors the physical `CastExpr`, whose | |
| /// target field is already authoritative when it is not the synthesized type-only | |
| /// field. | |
| /// | |
| /// For `TryCast`, `force_nullable` is `true` since a failed cast returns NULL. | |
| fn cast_output_field( | |
| source_field: &FieldRef, | |
| target_field: &FieldRef, | |
| force_nullable: bool, | |
| ) -> Arc<Field> { | |
| let metadata = if target_field.metadata().is_empty() { | |
| source_field.metadata().clone() | |
| } else { | |
| target_field.metadata().clone() | |
| }; | |
| let mut f = source_field | |
| .as_ref() | |
| .clone() | |
| .with_data_type(target_field.data_type().clone()) | |
| .with_metadata(metadata); |
There was a problem hiding this comment.
yes sorry about the confusion I should've temporarily made this a draft, the diff for this PR is much smaller. Allthat the CastTarget and protobuf changes are mixed into this PR because I had stacked it on #24725 but they shouldn’t be reviewed as part of the projection optimizer fix. I’m going to rebase this onto #23169 after some discussion to go with that approach for the minor relese.
as far as this particular comment. I originally thought this too and @gabotechs also asked about this, its a subtle one. Let me know if that clarifies 👍
There was a problem hiding this comment.
I will rebase now and the PR should clean up after #23169 merges
c30f5be to
0603df9
Compare
| 00010203040506070809000102030506 NULL | ||
|
|
||
| # arrow_cast to a different type strips extension metadata (type-only cast semantics) | ||
| query ?T |
There was a problem hiding this comment.
Yes, it is for a different bug but when i rooginally made the minimal fix (my top commit now on this brnach) it fails some sqllogictests because of #23169 not being merged. Specifically:
SELECT CAST(raw AS UUID), arrow_metadata(CAST(raw AS UUID), 'ARROW:extension:name');Expected on main:
00010203040506070809000102030506 arrow.uuid
But with the projection fix alone the projection starts to actually preserver logical schema then ignores the UUID target metadata, so the result:
- arrow.uuid
+ NULL#24831 handles this with this check:
let metadata = if target_field.metadata().is_empty() {
source_field.metadata().clone()
} else {
target_field.metadata().clone()
};as prposed in your brnach #24831 but it has the issue that I talk about here which is hwy I opted into stacking on #23169 to handle that case while not allowing another edge case to creep in.
So this is getting a bit tricky to handle. That particular test is from another PR, but solving just the bug at at the surface level unveils more underlying issues with casting and metadata that this relies on. I would think that getting this in the minor patch with #23169 would be the good short term solution, Then I read your comment and I think this could be a viable breaking change after some more discussion regarding how we want these semantics to behave.
Thanks for taking time to investigate all this @adriangb 🙇
There were four ways metadata could disappear.
1. Removing a metadata-only identity projection
Consider:
The check to remove the projection asked:
All answers yes so optimizer removed projection:
Metadata lost.
2 Collapsing across a metadata boundary
Consider:
The correct result is
true.The previous projection colapse logic would substitute the outer expression through the inner projection:
Now the func sees the scan field instead of the inner projection field giving use result as
NULLnow.3 Rebuilding a projection with a new child
Some optimizer paths replace the child of a projection:
The previous
make_with_childimplementation did this:where try_new derives the output schema from the expressions and new child so it woudlnt retain metadata from the original projection.
4 Cast target metadata lost before optimization
This one was a little confusing because main passed the UUID metadata test, but the first version of this PR did not (@gabotechs this is what you called out)
Basically a cast can have an explicit target field with metadata. For example, the UUID type planner produces:
But logical cast schema only used the target data type when deriving and kept th source metadata:
This appeared in CI when common sub-expr elimination extracts a repeated cast into
its own projection:
The inner projection was initially created with incorrect empty metadata, so the physical optimizer rebuilt that projection and rederived its schema so isthe was accidentally repairing the logical schema bug.
Once this PR started preserving projection metadata correctly had this pop up this other bug.
So then I solve the optimizer bugs in this PR