fix: preserve projection metadata during optimization - #24670
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.
|
🤔 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.61% 81.60% -0.02%
==========================================
Files 1123 1123
Lines 409392 411751 +2359
Branches 409392 411751 +2359
==========================================
+ Hits 334134 336014 +1880
- Misses 55637 55945 +308
- Partials 19621 19792 +171 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
|
created codec / serialization follow up here: #24695 |
Still working on better fix it, so approach might change
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
|
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 👍 |
timsaucer
left a comment
There was a problem hiding this comment.
In addition to the comment here, mostly discovered via agent evaluation, this PR lacks some testing. I believe if you merge in gene-bordegaray#7 it will resolve the testing angle.
| // Removing a projection with observable metadata can change query results. | ||
| if projection.overrides_metadata()? { | ||
| return Ok(Transformed::no(plan)); | ||
| } |
There was a problem hiding this comment.
After some back and forth with an agent, this now seems like a reasonable issue about this check:
This is a precondition, so it exits before try_swapping_with_projection — it blocks relocation as well as removal. The 20 try_swapping_with_projection impls split into two groups:
- 7 relocate the projection via
make_with_child— which this PR already made metadata-safe - 3 absorb it (
source.rs:525covering allDataSourceimpls,streaming.rs:337) and 1 embeds it (try_embed_projection, covering theFilterExecfallback and all 5 joins) — these re-derive their schema and do drop the metadata
The precondition can't distinguish them, so it pays for the lossy 4 by disabling the safe 7. The catch is who that lands on: overrides_metadata() only fires for projections built through try_new_with_schema_metadata, which after #23169 means embedder-constructed plans, not SQL. So the users who need this fix are exactly the ones who lose the pushdown.
Concretely:
ProjectionExec: a@0 AS a declared output: a -> {"unit": "ms"}
FilterExec: a@0 > 5
DataSourceExec: a, b, c
| metadata | filter reads | |
|---|---|---|
| pre-PR | lost | 1 col |
| this PR | kept | 3 cols |
| suggestion below | kept | 1 col |
Would it work to make it a postcondition instead — check the result rather than deciding up front?
projection
.input()
.try_swapping_with_projection(projection)?
.filter(|swapped| swapped.schema() == projection.schema())Deleting the precondition should be free: the removal path is already covered by the projection.schema() == projection.input().schema() you added to is_projection_removable, so the precondition was only ever guarding the swap. This also means a future try_swapping_with_projection impl is covered without anyone remembering to add a guard — the failure mode becomes a lost optimization rather than lost metadata. And it adds no public API, which I think keeps this patch-eligible.
I tried it against d112760:
| Check | Result |
|---|---|
| Filter relocation (safe path) | swap happens, metadata preserved |
try_embed_projection (lossy path) |
swap rejected, projection kept, metadata intact |
| Full sqllogictest suite, 510 files | zero false rejections |
| slt failures vs. unmodified PR head | identical (same 35 files) |
projection::tests |
24/24 pass, including all 4 you added |
The rejection trace on the lossy path shows the metadata that would have been dropped:
SWAP_REJECTED child=FilterExec
proj_schema= [Field { name: "a", metadata: {"unit": "ms"} }]
swapped_schema= [Field { name: "a" }]
There was a problem hiding this comment.
The cost concern here is solved with the changes that will be mae to overrides_metadata so i think we are good on that frohnt. It will now read the cached bool
For the condition removal, I don't believe we can do this. There are two checks that I think are being confused as checking the same thing:
- In
is_projection_removablewe check if the schemas of the projuection and its input are a match and if they are we can remove it. - Then we check if we can swap the projection to be below its child, like in the filter example above. This is a differnt check and the guard is still needed.
Say you had this:
Projection: a metadata={unit: ms}
Filter: arrow_metadata(a, "unit") IS NULL
Input: a,b metadata={}
After the swap:
Filter: arrow_metadata(a, "unit") IS NULL
Projection: a metadata={unit: ms}
Input
The filter outputs the schema it receives so it now receives the schema with the metadata. Meaning that the proposed check will pass, but the filter now also evaluates its predicate using that metadata. Before this it saw rows with no metadata thus returns NULL and the rows pass. Then after it is inspecting it with metadata so the rows dont pass.
I don't know if this is accessible via SQL, but it defnitely can via public APIs. I added a test that checks for this regression.
|
also so I understand, what are the testing concerns that gene-bordegaray#7 addresses? I dont see these tests exposing any of the behavior changes in this PR, more testing #23169 behavior. |
d112760 to
aac9704
Compare
1cdd033 to
64e9b97
Compare
timsaucer
left a comment
There was a problem hiding this comment.
I think this is very close to ready. You're right to push back on my testing questions, they were directed at the earlier PR.
| let output_schema = projector.output_schema(); | ||
| if input_schema.metadata() != output_schema.metadata() { | ||
| return Ok(true); | ||
| } | ||
| for (projection, output_field) in | ||
| projector.projection().iter().zip(output_schema.fields()) | ||
| { | ||
| let derived_field = projection.expr.return_field(input_schema)?; | ||
| if derived_field.metadata() != output_field.metadata() { | ||
| return Ok(true); | ||
| } | ||
| } | ||
| Ok(false) |
There was a problem hiding this comment.
Can we instead just reuse projection.project_schema(input_schema) and compare. the metadata?
There was a problem hiding this comment.
yes we could. The only thing is project_schema does a ton of extra stuff we don't really need. So with the past wide schema perf concerns I think just deriving exactly what we need is gonna give us better perf here
`compute_overrides_metadata` compares schema-level metadata separately from per-field metadata, but nothing exercised that first comparison: deleting it left every test passing. The gap was reachable. A projection that overrides only schema-level metadata has field metadata matching what its expressions derive, so the field loop never flags it, and `is_projection_removable` only declines to remove it. With the schema-level comparison gone it would be handed to `try_swapping_with_projection` and silently lose its metadata. Add a test that pins it. The identity shape does not narrow the schema, so `FilterExec::try_swapping_with_projection` falls through to `try_embed_projection`, which rebuilds the plan from the projection expressions alone and drops the schema metadata. Extending the existing `test_schema_metadata_projection_is_not_removable` would not have worked: `TestMemoryExec` has no `try_swapping_with_projection` impl, so no swap is ever attempted there. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`try_collapse_projection_chain` bailed out when the outer projection overrode metadata. That branch cannot be taken. The function's only production caller is `ProjectionExec::try_swapping_with_projection`, which is reached exclusively from `remove_unnecessary_projections` after it has already returned early on an overriding projection. Deleting the guard leaves every test in the workspace passing, including the sqllogictest suite. The check was also redundant on its own terms. An inner projection that overrides metadata breaks the loop, so every collapsed projection derives the same metadata its expressions would, and the unified projection is built with the outer projection's output schema regardless. Replace it with a doc comment recording why the caller's guard is sufficient, so the invariant is stated rather than re-implemented. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
@timsaucer caught a guard on an outer metadata guard that couldn't be reached, merged those in. Thank you 👍 |
timsaucer
left a comment
There was a problem hiding this comment.
Thank you for all the hard work on this!
|
@gabotechs @adriangb Any final comments or thoughts on this PR before we merge? |
|
lgtm from my end. thanks @timsaucer and @gene-bordegaray for driving this |
|
awesome thanks for all the detailed feedback on this 👍 |
|
@gene-bordegaray Can you create a branch off |
yes, done: #24992 |
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