Skip to content

fix: preserve ProjectionExec metadata during serialization - #25009

Open
goutamadwant wants to merge 5 commits into
apache:mainfrom
goutamadwant:fix-projection-proto-metadata-24695
Open

goutamadwant wants to merge 5 commits into
apache:mainfrom
goutamadwant:fix-projection-proto-metadata-24695

Conversation

@goutamadwant

@goutamadwant goutamadwant commented Sep 7, 2026

Copy link
Copy Markdown
Contributor

Which issue does this PR close?

Rationale for this change

Physical-plan serialization can lose projection field and schema metadata. The projection payload should preserve its output metadata, including inherited metadata, without relying on the child to reconstruct it.

What changes are included in this PR?

  • Add an optional schema to ProjectionExecNode, emitted when the projection has schema or output-field metadata, or explicitly clears input metadata.
  • Restore metadata through try_new_with_schema_metadata, while deriving field names, data types, and nullability from the expressions.
  • Keep older payloads without the optional field readable.
  • Use explicit metadata maps in tests for compatibility with both Arrow 59 and Arrow 60.

This preserves the projection's metadata independently of child metadata. It does not make the entire execution plan independent of its children. Omitting duplicated inherited metadata is left for a follow-up once the serialization contract is agreed.

What is the testing strategy for this PR?

Binary and JSON round trips cover inherited and overridden metadata, schema-only and field-only metadata, extension metadata, explicit clearing, metadata-free projections, and older payloads. Child-independent reconstruction and field-count validation remain covered.

  • Reproduced the Arrow 60 metadata type-inference compilation failure before the test fix. All 272 protobuf integration tests pass with the fix applied to the current-main integration.
  • The exact PR branch passes formatting, all-target/all-feature Clippy with warnings denied, and ./dev/rust_lint.sh.
  • The exact PR branch passes the extended workspace suite and all 518 SQL logic-test files.

Are there any user-facing changes?

Projection schema and output-field metadata survive physical-plan serialization. Nested field definitions remain part of the expression-derived data types. The generated ProjectionExecNode Rust struct gains an optional schema field, affecting exhaustive struct literals. New readers accept older payloads. This targets main, not a 55.1 backport.

@github-actions github-actions Bot added proto Related to proto crate physical-plan Changes to the physical-plan crate labels Sep 7, 2026
@github-actions

github-actions Bot commented Sep 7, 2026

Copy link
Copy Markdown

Thank you for opening this pull request!

Reviewer note: cargo-semver-checks reported the current version number is not SemVer-compatible with the changes in this pull request (compared against the base branch).

Details
     Cloning apache/main
    Building datafusion-physical-plan v55.1.0 (current)
       Built [  46.767s] (current)
     Parsing datafusion-physical-plan v55.1.0 (current)
      Parsed [   0.168s] (current)
    Building datafusion-physical-plan v55.1.0 (baseline)
       Built [  40.618s] (baseline)
     Parsing datafusion-physical-plan v55.1.0 (baseline)
      Parsed [   0.174s] (baseline)
    Checking datafusion-physical-plan v55.1.0 -> v55.1.0 (no change; assume patch)
     Checked [   0.728s] 223 checks: 223 pass, 31 skip
     Summary no semver update required
    Finished [  89.838s] datafusion-physical-plan
    Building datafusion-proto v55.1.0 (current)
       Built [  58.489s] (current)
     Parsing datafusion-proto v55.1.0 (current)
      Parsed [   0.018s] (current)
    Building datafusion-proto v55.1.0 (baseline)
       Built [  57.285s] (baseline)
     Parsing datafusion-proto v55.1.0 (baseline)
      Parsed [   0.019s] (baseline)
    Checking datafusion-proto v55.1.0 -> v55.1.0 (no change; assume patch)
     Checked [   0.117s] 223 checks: 223 pass, 31 skip
     Summary no semver update required
    Finished [ 117.768s] datafusion-proto
    Building datafusion-proto-models v55.1.0 (current)
       Built [  26.561s] (current)
     Parsing datafusion-proto-models v55.1.0 (current)
      Parsed [   0.136s] (current)
    Building datafusion-proto-models v55.1.0 (baseline)
       Built [  26.639s] (baseline)
     Parsing datafusion-proto-models v55.1.0 (baseline)
      Parsed [   0.136s] (baseline)
    Checking datafusion-proto-models v55.1.0 -> v55.1.0 (no change; assume patch)
     Checked [   1.893s] 223 checks: 222 pass, 1 fail, 0 warn, 31 skip

--- failure constructible_struct_adds_field: struct exhaustively constructible through public API adds field ---

Description:
A pub struct that could be exhaustively constructed with a literal using only public API has a new pub field, breaking existing exhaustive literals.
        ref: https://doc.rust-lang.org/reference/expressions/struct-expr.html
       impl: https://github.com/obi1kenobi/cargo-semver-checks/tree/v0.50.0/src/lints/constructible_struct_adds_field.ron

Failed in:
  field ProjectionExecNode.schema in /home/runner/work/datafusion/datafusion/datafusion/proto-models/src/generated/prost.rs:2267
  field ProjectionExecNode.schema in /home/runner/work/datafusion/datafusion/datafusion/proto-models/src/generated/prost.rs:2267

     Summary semver requires new major version: 1 major and 0 minor checks failed
    Finished [  56.462s] datafusion-proto-models

@github-actions github-actions Bot added the auto detected api change Auto detected API change label Sep 7, 2026
@codecov-commenter

codecov-commenter commented Sep 7, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 55.55556% with 12 lines in your changes missing coverage. Please review.
✅ Project coverage is 82.44%. Comparing base (c2cf289) to head (d110e54).
⚠️ Report is 122 commits behind head on main.

Files with missing lines Patch % Lines
datafusion/proto-models/src/generated/pbjson.rs 0.00% 11 Missing ⚠️
datafusion/physical-plan/src/projection.rs 93.75% 0 Missing and 1 partial ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main   #25009      +/-   ##
==========================================
+ Coverage   81.90%   82.44%   +0.53%     
==========================================
  Files        1134     1138       +4     
  Lines      425261   435461   +10200     
  Branches   425261   435461   +10200     
==========================================
+ Hits       348325   359027   +10702     
+ Misses      56295    54703    -1592     
- Partials    20641    21731    +1090     

☔ 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.

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

Thanks @goutamadwant !

@gene-bordegaray gene-bordegaray 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.

One concern, other than that thank you 👍

let input = ctx.encode_child(input)?;
let expr = ctx.encode_expressions(projection_exprs.iter().map(|p| &p.expr))?;
let expr_name = projection_exprs.iter().map(|p| p.alias.clone()).collect();
let schema = if *overrides_metadata {

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.

This conditioins shouldnt just be if we are overriding metadata I believe. Rather we should be checking if we have metadata in general.

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.

@gene-bordegaray Updated the condition to encode schema or output-field metadata even when inherited. The override check remains so an empty metadata map can still explicitly clear input metadata.

));
let plan = Arc::new(ProjectionExec::try_new(
vec![(col("value", &input_schema)?, "value".to_string())],
Arc::new(EmptyExec::new(Arc::clone(&input_schema))),

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.

this is preserving the metadata through the child after decoding, not a proojection that needs to rederive the metadata completely from itself.

Could we add a test that forces the projection to completely rederive the metadata from its own proto after roundtrip 👍

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.

@gene-bordegaray added a regression that removes metadata from the encoded child and checks the decoded projection independently. Let me know if it looks good now.

@gene-bordegaray gene-bordegaray 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.

this is good to me now. @jayzhan211 and @timsaucer I know you guys were involved on this as well 👍

let expr = ctx.encode_expressions(projection_exprs.iter().map(|p| &p.expr))?;
let expr_name = projection_exprs.iter().map(|p| p.alias.clone()).collect();
let output_schema = projector.output_schema();
// Keep inherited metadata self-contained, and retain empty overrides

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.

what does self-contained here mean

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.

@gene-bordegaray “Self-contained” refers to restoring output field/schema metadata even when the child does not preserve it. The child and expressions still determine names, types, and nullability. I've clarified this boundary in the description. let me know. thanks!

@gene-bordegaray

Copy link
Copy Markdown
Contributor

@jayzhan211 bumping this guy for visibility don't want it to get lost

@timsaucer

Copy link
Copy Markdown
Member

LGTM but it looks like you've got at least one unanswered question from your review. @goutamadwant are you able to address the question about self contained?

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

One non-blocking suggestion — this can go in as is 🚀

let output_schema = projector.output_schema();
// Keep inherited metadata self-contained, and retain empty overrides
// that explicitly clear metadata from the input.
let schema = if *overrides_metadata

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.

The || !metadata.is_empty() arms encode a full Schema for plain try_new projections, whose metadata decode already re-derives from the child. Measured on a 200-col identity projection with one metadata key per field: the projection node grows by ~6.9 KB (plan 10,354 → 17,248 bytes), repeated for every projection in a stack. roundtrip_projection_metadata_without_child_metadata only passes by swapping the child inside the proto, which encode never produces; overrides_metadata alone fixes #24695 and the explicit-clear case.

Fine as a follow-up

-        let schema = if *overrides_metadata
-            || !output_schema.metadata().is_empty()
-            || output_schema
-                .fields()
-                .iter()
-                .any(|field| !field.metadata().is_empty())
-        {
-            Some(output_schema.as_ref().try_into()?)
-        } else {
-            None
-        };
+        // Inherited metadata is re-derived from the child on decode.
+        let schema = overrides_metadata
+            .then(|| projector.output_schema().as_ref().try_into())
+            .transpose()?;

Signed-off-by: goutamadwant <workwithgoutam@gmail.com>

@gene-bordegaray gene-bordegaray 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.

I the last commit this makes the condition different and incorrect as inherited metadata will be dropped.

Also the last commit deleted the test that covered the case that wold catch this, so let's keep that 👍

Signed-off-by: goutamadwant <workwithgoutam@gmail.com>
@gene-bordegaray

Copy link
Copy Markdown
Contributor

actually this may be agood question for @timsaucer . For serialization do we usually want to uphold that any node can be reconstructed completely independently of the rest of the plan or is it ok for a node to rely on a child's protobuf for infromation such as metadata in this case?

Signed-off-by: goutamadwant <workwithgoutam@gmail.com>
@goutamadwant

Copy link
Copy Markdown
Contributor Author

I the last commit this makes the condition different and incorrect as inherited metadata will be dropped.

Also the last commit deleted the test that covered the case that wold catch this, so let's keep that 👍

@gene-bordegaray retained the restored inherited metadata and child-independent reconstruction test. Fixed the Arrow 60 compilation failure with explicit metadata maps.

@gene-bordegaray

Copy link
Copy Markdown
Contributor

I the last commit this makes the condition different and incorrect as inherited metadata will be dropped.
Also the last commit deleted the test that covered the case that wold catch this, so let's keep that 👍

@gene-bordegaray retained the restored inherited metadata and child-independent reconstruction test. Fixed the Arrow 60 compilation failure with explicit metadata maps.

Yes I see. I am mostly asking tim if the condition I am concerned about which is for serialization and deserilization is it typically good practice in this repo to have all nodes be able to deserialize without any dependency to children nodes. I was under the assumption that this was the case but here it causes us to have to serialize more over the wire then we would need to in some cases so we may not actually want to if not needed.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

auto detected api change Auto detected API change physical-plan Changes to the physical-plan crate proto Related to proto crate

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Physical-plan serialization drops ProjectionExec metadata

5 participants