Skip to content

feat: allow extension PhysicalExprs to decode via a per-type registry - #24628

Merged
adriangb merged 4 commits into
apache:mainfrom
pydantic:claude/datafusion-24626-0a1685
Oct 20, 2026
Merged

adriangb merged 4 commits into
apache:mainfrom
pydantic:claude/datafusion-24626-0a1685

Conversation

@adriangb

@adriangb adriangb commented Aug 24, 2026 •

Copy link
Copy Markdown
Contributor

Which issue does this PR close?

Rationale for this change

The user-visible problem

A library that ships its own PhysicalExpr has to write a PhysicalExtensionCodec to serialize it, in a file separate from the expression, and two such libraries on one session have to compose their codecs by hand. PhysicalExtensionExprNode carries an opaque payload and the encoded children but no type discriminator, so the codec is the discriminator: ComposedPhysicalExtensionCodec writes the position of the encoding codec into the payload bytes, the decoding side must register the same codecs in the same order, and a name collision between two independent crates cannot be detected.

A decoder also had no way to reach session state. PhysicalExprDecodeCtx had schema() and decode() but nothing like ExecutionPlanDecodeCtx::task_ctx(), so an extension expression could not resolve a UDF from the function registry or read session configuration while decoding.

Where this sits in the bigger picture

Physical-plan serialization is moving from central downcast_ref chains in datafusion-proto to per-type hooks that live next to each type: try_to_proto (a trait method) plus a try_from_proto constructor. #22418 did this for built-in PhysicalExprs and #23494 for ExecutionPlans. Third-party types could use the encode half but not the decode half. This PR closes the loop for PhysicalExpr; #24631 does the same for ExecutionPlan with the same shape and names (Extension*FromProto pairing the wire name with the constructor, register_*/decode_* facades over one shared registry, attached through SessionConfig::with_extension).

#24631 (merged) added the shared ProtoDecoderRegistry that both kinds register into. #26353 (open) adds SessionConfig::update_extension, so that several libraries can add to the session's registry without replacing each other's decoders. This PR does not depend on it.

How this relates to PhysicalExtensionCodec

The codec is not deprecated and this PR does not touch ComposedPhysicalExtensionCodec. The decode rule is:

  • A PhysicalExtensionExprNode with an expr_name the decoding session has registered decodes through the expression's own ExtensionExprFromProto::try_from_proto. A failure there is fatal: falling through to the codec would let it wrongly decode a payload it never wrote.
  • Anything else (no name, or a name this session does not know) takes PhysicalExtensionCodec::try_decode_expr exactly as before. If the codec also fails, the error names the missing registration and lists what the session does know.

The codec keeps UDF/UDAF/UDWF payloads (a function is an instance, not a type), anything not yet migrated, and the released FFI_PhysicalExtensionCodec that datafusion-python exposes.

How FFI will work

The registry stores decoders as a private fn pointer; the public surface is ExtensionExprFromProto, extension_expr_node and the facade functions. The storage can later grow to carry an FFI vtable and private data without breaking anything shipped here. PhysicalExprNode crosses an ABI as prost bytes, the pattern datafusion-ffi already uses for Statistics.

What changes are included in this PR?

Built-in expression serialization is untouched: built-ins keep their inherent try_from_proto, dispatched from their own ExprType variant, and no built-in code or caller changes.

  • Wire field. PhysicalExtensionExprNode gains optional string expr_name = 3; — additive and proto3-compatible. The codec encode path writes None.
  • Session access while decoding. PhysicalExprDecodeCtx::session::<T>() -> Option<&T> returns the session the decode runs under, type-erased because TaskContext lives in a crate that depends on this one. It is backed by a new defaulted PhysicalExprDecode::session() method (returns None); datafusion-proto and ExecutionPlanDecodeCtx supply their TaskContext, so a decoder calls ctx.session::<TaskContext>(). PhysicalExprDecodeCtx is not generic and its constructor is unchanged.
  • expr_id stamping. serialize_physical_expr_with_converter now sets the expression's expression_id() on a node returned by try_to_proto when the hook left expr_id empty (a hook that sets its own keeps it). Without this, an identity-bearing extension written with extension_expr_node would lose its identity and defeat DeduplicatingProtoConverter.
  • ExtensionExprFromProto (in datafusion_physical_expr::proto). An extension expression implements this one trait, pairing const NAME with fn try_from_proto(node: &PhysicalExprNode, ctx: &PhysicalExprDecodeCtx<'_>). Its try_to_proto is one call — extension_expr_node::<Self>(ctx, payload, self.children()) — which builds the node and stamps Self::NAME, so the encoded name and the registry key cannot drift apart. Built-ins do not implement it, so register_physical_expr::<Column>(..) does not compile (a compile_fail doctest checks this). extension_expr_node is a free function because PhysicalExprEncodeCtx lives in datafusion-physical-expr-common, below the crate that can name the trait.
  • Registration goes into the ProtoDecoderRegistry from feat: allow extension ExecutionPlans to decode via a per-type registry instead of PhysicalExtensionCodec #24631: call register_physical_expr::<T>(&mut registry) for every extension expression the session must decode, and attach it with SessionConfig::with_extension. Registering the same type twice is a no-op; a different type under a taken name, or an empty name, is an error. Names are scoped per kind, so a plan and an expression may share a name in one registry. This crate adds register_physical_expr, physical_expr_names, and decode_physical_expr (#[doc(hidden)], internal to datafusion-proto; it reads the name off the node so a caller cannot pair a node with a name it does not carry).
  • Re-exports. datafusion::physical_plan::proto re-exports ExtensionExprFromProto, extension_expr_node, register_physical_expr, physical_expr_names, decode_physical_expr, PhysicalExprDecodeCtx and PhysicalExprEncodeCtx, alongside the ExecutionPlan facade and ProtoDecoderRegistry.
  • Decode dispatch in datafusion-proto per the rule above (when the codec fallback also fails, the missing registration is added as context on the codec's own error so its kind still reaches callers), a doc pointer on PhysicalExtensionCodec::try_decode_expr, and an upgrade-guide entry in 56.0.0.

Why a separate trait rather than a method on PhysicalExpr

  1. An associated constant makes a trait not dyn-compatible (E0038), which breaks every Arc<dyn PhysicalExpr>, so the wire name cannot live on PhysicalExpr.
  2. Cargo unifies features across the graph, so a required method on a proto-gated trait is E0046 in a downstream crate that never enabled the feature.
  3. With a default, a forgotten decoder becomes a decode-time NotImplemented instead of a compile error.

Decoding is a constructor with no self and can never be dispatched through &dyn PhysicalExpr, so the encode/decode asymmetry is structural.

Session scoping goes through SessionConfig extensions rather than a new field on TaskContext/SessionState, which would force datafusion-physical-expr-common/proto on for everyone.

What is the testing strategy for this PR?

datafusion/proto/tests/cases/plans/expr_registry.rs, 10 tests. A RefusingCodec errors on every method, so any codec involvement is fatal; a codec that can decode the same payload makes the fallback paths distinguishable from mere failure.

  • Registry decode end to end with no codec on either side, including reading the session from the decoder.
  • Fallback: unnamed or unregistered names reach the codec; a codec failure keeps its own error and names the missing registration.
  • Registration rejects collisions and empty names; same type twice is a no-op.
  • Nested extension expressions both decode through the registry.
  • A failing registry decoder does not fall through to a codec that could decode the node.
  • An extension expression resolves a scalar UDF and an aggregate UDF from the reading session via ctx.session::<TaskContext>().
  • A whole plan roundtrip through physical_plan_to_bytes_with_extension_codec / physical_plan_from_bytes_with_extension_codec.
  • An identity-bearing extension referenced twice keeps its expression_id() under DeduplicatingProtoConverter: shared state is preserved and children decode correctly.
  • A registered extension plan and expression with the same wire name roundtrip together in one plan.

Are there any user-facing changes?

Additive. Nobody is affected unless they opt in; there are no breaking API changes. Documented in the 56.0.0 upgrade guide.

Two things worth a reviewer's attention:

  • The wire field is additive for binary protobuf but not for the optional JSON serde: generated JSON deserializers reject unknown fields, so a node carrying exprName needs a reader built after this change.
  • The codec fallback is only equivalent for nodes written the old way. Once an expression's try_to_proto writes its own named node, a reader that fails to register the type hands that payload to try_decode_expr, and a codec that still recognizes the expression may decode the new payload as the old message. The upgrade guide says to register migrated expressions on every reader before writers start using them.

🤖 Generated with Claude Code

@github-actions github-actions Bot added documentation Improvements or additions to documentation physical-expr Changes to the physical-expr crates proto Related to proto crate physical-plan Changes to the physical-plan crate labels Aug 24, 2026
@github-actions

github-actions Bot commented Aug 24, 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-expr v55.1.0 (current)
       Built [  26.608s] (current)
     Parsing datafusion-physical-expr v55.1.0 (current)
      Parsed [   0.052s] (current)
    Building datafusion-physical-expr v55.1.0 (baseline)
       Built [  25.593s] (baseline)
     Parsing datafusion-physical-expr v55.1.0 (baseline)
      Parsed [   0.051s] (baseline)
    Checking datafusion-physical-expr v55.1.0 -> v55.1.0 (no change; assume patch)
     Checked [   0.421s] 229 checks: 229 pass, 31 skip
     Summary no semver update required
    Finished [  54.727s] datafusion-physical-expr
    Building datafusion-physical-expr-common v55.1.0 (current)
       Built [  20.847s] (current)
     Parsing datafusion-physical-expr-common v55.1.0 (current)
      Parsed [   0.020s] (current)
    Building datafusion-physical-expr-common v55.1.0 (baseline)
       Built [  20.955s] (baseline)
     Parsing datafusion-physical-expr-common v55.1.0 (baseline)
      Parsed [   0.020s] (baseline)
    Checking datafusion-physical-expr-common v55.1.0 -> v55.1.0 (no change; assume patch)
     Checked [   0.253s] 229 checks: 229 pass, 31 skip
     Summary no semver update required
    Finished [  42.771s] datafusion-physical-expr-common
    Building datafusion-physical-plan v55.1.0 (current)
       Built [  33.115s] (current)
     Parsing datafusion-physical-plan v55.1.0 (current)
      Parsed [   0.210s] (current)
    Building datafusion-physical-plan v55.1.0 (baseline)
       Built [  32.607s] (baseline)
     Parsing datafusion-physical-plan v55.1.0 (baseline)
      Parsed [   0.207s] (baseline)
    Checking datafusion-physical-plan v55.1.0 -> v55.1.0 (no change; assume patch)
     Checked [   0.864s] 229 checks: 229 pass, 31 skip
     Summary no semver update required
    Finished [  68.236s] datafusion-physical-plan
    Building datafusion-proto v55.1.0 (current)
       Built [  48.980s] (current)
     Parsing datafusion-proto v55.1.0 (current)
      Parsed [   0.012s] (current)
    Building datafusion-proto v55.1.0 (baseline)
       Built [  50.054s] (baseline)
     Parsing datafusion-proto v55.1.0 (baseline)
      Parsed [   0.013s] (baseline)
    Checking datafusion-proto v55.1.0 -> v55.1.0 (no change; assume patch)
     Checked [   0.146s] 229 checks: 229 pass, 31 skip
     Summary no semver update required
    Finished [ 100.301s] datafusion-proto
    Building datafusion-proto-models v55.1.0 (current)
       Built [  21.928s] (current)
     Parsing datafusion-proto-models v55.1.0 (current)
      Parsed [   0.146s] (current)
    Building datafusion-proto-models v55.1.0 (baseline)
       Built [  21.779s] (baseline)
     Parsing datafusion-proto-models v55.1.0 (baseline)
      Parsed [   0.148s] (baseline)
    Checking datafusion-proto-models v55.1.0 -> v55.1.0 (no change; assume patch)
     Checked [   2.419s] 229 checks: 228 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.51.0/src/lints/constructible_struct_adds_field.ron

Failed in:
  field PhysicalExtensionExprNode.expr_name in /home/runner/work/datafusion/datafusion/datafusion/proto-models/src/generated/prost.rs:2012
  field PhysicalExtensionExprNode.expr_name in /home/runner/work/datafusion/datafusion/datafusion/proto-models/src/generated/prost.rs:2012

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

@github-actions github-actions Bot added the auto detected api change Auto detected API change label Aug 24, 2026
@adriangb
adriangb force-pushed the claude/datafusion-24626-0a1685 branch from 1684c28 to 7b19bb3 Compare August 24, 2026 18:10
Comment thread datafusion/physical-expr-common/src/physical_expr.rs Outdated
@codecov-commenter

codecov-commenter commented Aug 24, 2026 •

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 76.19048% with 25 lines in your changes missing coverage. Please review.
✅ Project coverage is 82.92%. Comparing base (7e102d7) to head (5dc260a).
⚠️ Report is 6 commits behind head on main.

Files with missing lines Patch % Lines
datafusion/proto-models/src/generated/pbjson.rs 0.00% 13 Missing ⚠️
...tafusion/physical-expr-common/src/physical_expr.rs 33.33% 3 Missing and 1 partial ⚠️
datafusion/physical-expr/src/proto.rs 91.66% 1 Missing and 2 partials ⚠️
datafusion/physical-plan/src/proto/mod.rs 0.00% 3 Missing ⚠️
datafusion/proto/src/physical_plan/from_proto.rs 97.05% 1 Missing ⚠️
datafusion/proto/src/physical_plan/to_proto.rs 92.30% 0 Missing and 1 partial ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main   #24628      +/-   ##
==========================================
- Coverage   82.92%   82.92%   -0.01%     
==========================================
  Files        1155     1156       +1     
  Lines      458204   458302      +98     
  Branches   458204   458302      +98     
==========================================
+ Hits       379965   380034      +69     
- Misses      55205    55229      +24     
- Partials    23034    23039       +5     

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

Comment thread datafusion/physical-expr-common/src/physical_expr.rs Outdated
Comment thread datafusion/physical-expr-common/src/physical_expr.rs Outdated
Comment thread datafusion/physical-expr-common/src/physical_expr.rs Outdated
Comment thread datafusion/proto/tests/cases/plans/expr_registry.rs Outdated
@adriangb
adriangb force-pushed the claude/datafusion-24626-0a1685 branch 3 times, most recently from 31a7449 to 569a62e Compare September 15, 2026 13:40
Comment thread datafusion/physical-expr/src/proto.rs Outdated
Comment thread datafusion/physical-expr/src/proto.rs Outdated
Comment thread datafusion/physical-expr/src/proto.rs Outdated
Comment thread datafusion/physical-expr/src/proto_test_util.rs Outdated
Comment thread docs/source/library-user-guide/upgrading/56.0.0.md Outdated
@adriangb
adriangb marked this pull request as ready for review September 15, 2026 13:51
@adriangb
adriangb force-pushed the claude/datafusion-24626-0a1685 branch from 569a62e to a58c8e1 Compare September 15, 2026 13:51
@adriangb
adriangb force-pushed the claude/datafusion-24626-0a1685 branch 2 times, most recently from 86b779f to 9e43f5d Compare September 17, 2026 15:58
@adriangb
adriangb marked this pull request as draft September 17, 2026 17:14
@adriangb

Copy link
Copy Markdown
Contributor Author

I put this in draft. Lets sequence behind #24631 to avoid duplication. I will keep it updated though.

@adriangb
adriangb force-pushed the claude/datafusion-24626-0a1685 branch 2 times, most recently from 5064ec8 to f11996d Compare September 23, 2026 16:27
@adriangb
adriangb force-pushed the claude/datafusion-24626-0a1685 branch from f11996d to 7f9361a Compare September 30, 2026 19:35
@adriangb
adriangb force-pushed the claude/datafusion-24626-0a1685 branch from 7f9361a to f3d8556 Compare October 14, 2026 23:58
adriangb added a commit to pydantic/datafusion that referenced this pull request Oct 14, 2026
…try instead of `PhysicalExtensionCodec` (apache#24631)

## Which issue does this PR close?

- Closes apache#24625.

## Rationale for this change

### The user-visible problem

A library that ships its own `ExecutionPlan` (datafusion-distributed
ships six) has to write a `PhysicalExtensionCodec` to serialize it: a
central `downcast_ref` chain for encode and a matching `match` for
decode, in a file separate from the plan. Two such libraries on one
session also have to compose their codecs by hand through
`ComposedPhysicalExtensionCodec`, and their users have to remember to
install the composed codec on every process that decodes a plan.

That composition is fragile in a way that is easy to miss.
`PhysicalExtensionNode` carries no type discriminator, so the codec *is*
the discriminator. `ComposedPhysicalExtensionCodec` copes by writing the
**position** of the encoding codec into the payload bytes. The decoding
side must therefore register the same codecs in the same order, a plan
that two codecs both accept resolves to whichever was registered first,
and a name collision between two independent crates cannot be detected.
datafusion-python and datafusion-distributed have each built their own
workaround for this (a codec-id layer and a `user_codec.rs` composition
file respectively).

### Where this sits in the bigger picture

Physical-plan serialization is moving from central `downcast_ref` chains
in `datafusion-proto` to per-type hooks that live next to each type:
`try_to_proto` (a trait method, dispatched through the vtable) plus an
inherent `try_from_proto` (a constructor, so it cannot be a trait
method). apache#23494 did this for
every built-in `ExecutionPlan`;
apache#22418 did it for
`PhysicalExpr`.

Third-party types could use the encode half of that but not the decode
half, because decoding an extension node always routed through
`PhysicalExtensionCodec::try_decode`. This PR closes the loop for
`ExecutionPlan`; apache#24628 does
the same for `PhysicalExpr` with the same shape and the same names
(`Extension*FromProto` pairing the wire name with the constructor,
`register_*` / `decode_*` facades over one shared registry, attached
through `SessionConfig::with_extension`). **apache#24628 is stacked on this
PR**, which adds the shared registry, and must land second.

### How this relates to `PhysicalExtensionCodec`

The codec is **not deprecated** and this PR does not touch
`ComposedPhysicalExtensionCodec`. The decode rule is:

- A `PhysicalExtensionNode` **with a `plan_name` that the decoding
session has registered** decodes through the plan's own
`try_from_proto`. A failure there is fatal: falling through to the codec
would let it wrongly decode a payload it never wrote, which is the bug
the name exists to prevent.
- **Anything else** (no name, because a codec wrote it, or a name this
session does not know) takes the codec chain exactly as before. If the
codec also fails, the missing registration is added as *context* on the
codec's own error, and lists what the session does know. The codec's
error kind is kept, because a codec can fail for a reason that has
nothing to do with a missing registration.

The codec keeps three jobs the registry does not take: UDF/UDAF/UDWF
payloads (a function is an *instance*, not a type, so a type-keyed
registry does not fit), anything not yet migrated, and the released
`FFI_PhysicalExtensionCodec` in `datafusion-ffi`, which
datafusion-python exposes as public API. What becomes deprecable once
plans and expressions are registry-backed is
`ComposedPhysicalExtensionCodec`, whose reason to exist was composition;
that is a later, separate decision. Making the composed codec itself
name-keyed instead of position-keyed (as suggested on the issue) is also
orthogonal to this PR and can be done independently.

### How FFI will work

Today `FFI_ExecutionPlan` has no serialization slot, so a foreign plan
always goes through the codec. This PR does not change that. It is
designed so FFI can plug in later with no breaking change:

- The registry stores decoders as a **private** `fn` pointer. The public
surface is `ExtensionPlanFromProto` and the three facade functions. The
storage can grow to carry an FFI vtable and private data, and a
lower-level entry point that takes a name and a decoder object can be
added, both without breaking anything shipped here.
- `PhysicalPlanNode` is a prost message, so it crosses the ABI as bytes,
the same way `FFI_ExecutionPlan::partition_statistics` already ships a
prost-encoded `Statistics`. Protobuf's field-skipping gives version-skew
tolerance that a `#[repr(C)]` struct cannot.
- One limit FFI inherits from the codec rather than from this PR:
`ForeignPhysicalExtensionCodec::try_decode` discards the caller's
`TaskContext` and rebuilds one from the provider's side, so a decoder
that reads the *decoding* session's state (the second test below) would
not see it across FFI.

## What changes are included in this PR?

Three stacked commits, each green on its own. Built-in plan
serialization is untouched: they keep the inherent `try_from_proto` they
have had since 55.0.0, dispatched from their own `PhysicalPlanType`
variant.

1. **`feat: add an optional plan_name discriminator to
PhysicalExtensionNode`.** `optional string plan_name = 3;` — additive on
the binary wire format: old writers omit it and old readers skip it. The
generated JSON codec is stricter, see "user-facing changes" below.
Nothing reads it yet.
2. **`feat: add ProtoDecoderRegistry, one decoder store for every
extension kind`.** A `(trait, name)`-keyed store of type-erased decoders
in `datafusion-proto-models`, with no knowledge of any decode context.
Nothing uses it yet.
3. **`feat: decode extension ExecutionPlans through a session-scoped
registry`.**
- An extension plan implements one trait, `ExtensionPlanFromProto`,
which pairs the name it is dispatched by with the constructor that
rebuilds it. Built-ins do not implement it — they have a wire variant of
their own — so `register_execution_plan::<FilterExec>(..)` does not
compile, and **no built-in code or caller changes at all**. Its
`try_to_proto` writes itself with `ctx.extension_node::<Self>(payload,
self.children())`, which builds the `PhysicalExtensionNode` and stamps
`Self::NAME`, so the name on the wire and the registry key cannot drift
apart. Its `try_from_proto` receives the whole `PhysicalPlanNode`,
matches the `Extension` variant, and decodes `inputs` with
`ctx.decode_children`.
- `ProtoDecoderRegistry` (new, in `datafusion-proto-models`): build one,
`register_execution_plan::<T>(&mut registry)` for every extension plan
the session must decode, and attach it with the existing
`SessionConfig::with_extension`. Registering the same type twice is a
no-op (keyed by `TypeId`); a *different* type under a taken name is an
error, so collisions surface at registration. `decode_execution_plan`
reads the name off the node rather than taking it as an argument, so a
caller cannot pair a node with a name it does not carry.
- The registry is **keyed by the trait as well as the name**, and is
shared by every extension kind DataFusion can decode. This crate
contributes only the `dyn ExecutionPlan` face of it. A session therefore
carries **one** registry rather than one per kind, a plan and an
expression may use the same name without colliding, and
apache#24628 registers into the same
object. It lives in `datafusion-proto-models` because that crate already
sits below every crate that owns one of these traits, behind the same
`proto` feature; it stores each decoder type-erased and never names a
decode context.
- A session carries one registry and `with_extension` replaces it, so
the **application** that owns the session builds it. A library exposes
`pub fn register(registry: &mut ProtoDecoderRegistry) -> Result<()>` —
which can register its plans *and* its expressions — and the application
composes the libraries it uses. The libraries need to know nothing about
each other and the call order does not matter. There is no
`SessionConfig` extension trait and no merge-on-register helper: public
API is added when a use needs it, not ahead of one.
- It is attached through the `SessionConfig` extension map because
`datafusion-execution` sits *below* the crates that own these traits and
cannot name them; the extension map is the one type-erased hole through
that layering, and it reaches every `TaskContext` derived from the
session with no new plumbing.
- Decode dispatch in `datafusion-proto`, per the rule above, plus the
upgrade-guide entry (in 56.0.0, the guide for the next release).

## What is the testing strategy for this PR?

12 unit tests across `datafusion-proto-models` and
`datafusion-physical-plan`, 10 integration tests in
`datafusion/proto/tests/cases/plans/extensions.rs`, and doctests on the
public traits:

- `ProtoDecoderRegistry` itself: 6 unit tests, including the same name
under two different traits resolving to two different decoders.
- Registration: idempotent for the same type, an error for a different
type under the same name, empty names rejected, a name registered for
another kind invisible to the `ExecutionPlan` face, and a `compile_fail`
doctest for `register_execution_plan::<FilterExec>(..)` (verified to
fail on the `ExtensionPlanFromProto` bound, not on anything incidental).
- A runnable example,
`datafusion-examples/examples/proto/extension_plan_registry.rs`: the
same two-crate tree `composed_extension_codec.rs` builds, decoded by
name with no composed codec, plus the error a session that forgot to
register gets.
- A plan round-tripping with `DefaultPhysicalExtensionCodec`, which
decodes nothing, so whatever survives came from the registry.
- A **session-dependent** plan modeled on datafusion-distributed's
`NetworkShuffleExec`: its worker pool is rebuilt from the *decoding*
session through `ExecutionPlanDecodeCtx::task_ctx()`, and never rides
the wire.
- Two independent crates' plans nested inside each other, decoding with
no `ComposedPhysicalExtensionCodec`.
- The registry taking precedence over a codec that claims every payload,
and a registered decoder's failure *not* falling back to that codec.
- A codec failing for an unrelated reason: the missing-registration hint
is added, and the codec's own error kind still reaches `find_root()`.
- Back-compat both ways: codec-encoded plans stay anonymous on the wire
and decode as before; a name the session does not know (on a session
that *does* have a registry with other plans in it) still decodes if a
codec understands the payload; a node re-encoded without the field reads
back as unnamed.
- A bytes-level `encode_to_vec` / `decode` hop, so tag 3 is exercised as
wire bytes and not just as a struct field.

Verified locally on the final tree and with `cargo check` on every
commit of the stack: the `datafusion-physical-expr`,
`datafusion-physical-plan` and `datafusion-proto` test suites, the
examples crate, CI's clippy script, rustdoc with private items under `-D
warnings`, prettier, typos and fmt.

## Are there any user-facing changes?

New public API, all opt-in; no behavior change for existing code. One
mechanical break, in the 56.0.0 upgrade guide: `PhysicalExtensionNode`
gained a field, so exhaustive struct literals must set `plan_name`
(`None` reproduces the previous behavior). Built-in plans are untouched
— no signature, trait or import change for anyone calling
`FilterExec::try_from_proto(..)`.

Two things worth a reviewer's attention:

- **JSON is not as forgiving as the binary format.** The generated
`pbjson` deserializer rejects a field it does not know, so a *named*
node written by a new writer cannot be read as JSON by a reader on an
older DataFusion. The binary prost path skips the unknown field as
usual, and a plan that does not opt in writes no name and is unaffected.
If you serialize plans as JSON across versions, upgrade the readers
before you opt a plan in.
- **Opting a plan in changes what the codec fallback means for that
plan.** Once its `try_to_proto` writes its own payload, a reader that
fails to register the type hands that payload to the codec it removed,
and a codec that still recognizes the plan may decode the new payload as
the old message rather than fail. The upgrade guide says to register
migrated plans everywhere their plans are read.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

https://claude.ai/code/session_017UzVkTrKmRRc2fKCdMkqBp

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
@adriangb
adriangb marked this pull request as ready for review October 15, 2026 00:00
@adriangb
adriangb force-pushed the claude/datafusion-24626-0a1685 branch from f3d8556 to 6e88226 Compare October 15, 2026 03:50
@adriangb

Copy link
Copy Markdown
Contributor Author

@gabotechs this is ready for review if you are interested

…ExprNode`

Additive and proto3-compatible: on the binary wire format old writers omit
the field and old readers skip it. The generated JSON codec is stricter —
`pbjson` rejects a field it does not know — so a named node written by a new
writer cannot be read as JSON by an older reader. An unnamed node is
unaffected, and nothing writes a name yet.

Nothing reads it either; the codec path keeps writing `None`, since there the
codec remains the discriminator.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@adriangb
adriangb force-pushed the claude/datafusion-24626-0a1685 branch from 6e88226 to f8b165c Compare October 17, 2026 15:22
@adriangb
adriangb requested a review from kosiew October 17, 2026 15:23
`PhysicalExtensionExprNode` carried no type discriminator, so the
`PhysicalExtensionCodec` *was* the discriminator, with the same problems the
plan side has: composition is by registration order, and a name collision
between two independent crates is undetectable.

An extension expression now implements `ExtensionExprFromProto`, which pairs
the name it is dispatched by with the constructor that rebuilds it. Its
`try_to_proto` writes itself with `extension_expr_node`, which stamps
`Self::NAME`, so the encoded name and the registry key cannot drift apart.
`register_physical_expr` puts its decoder in the `ProtoDecoderRegistry` the
decoding session carries — the same registry extension `ExecutionPlan`s use,
keyed on the trait as well as the name.

Only extension expressions implement the trait. A built-in has an `ExprType`
variant of its own, keeps its inherent `try_from_proto`, and has no wire name
to be registered under — so `register_physical_expr::<Column>(..)` does not
compile. Nothing about built-in serialization changes, and no caller needs a
new import.

`extension_expr_node` is a free function rather than a method on
`PhysicalExprEncodeCtx` because that context lives in
`datafusion-physical-expr-common`, below the crate that can name
`ExtensionExprFromProto` — the same layering that makes the decode context
generic over its session.

Decode rule: a named node the session has a decoder for goes to that decoder,
and a failure there is fatal (falling through would let a codec decode a
payload it never wrote); anything else takes the `PhysicalExtensionCodec`
chain exactly as before. If the codec fails too, the missing registration is
added as *context* on the codec's own error, so the kind the codec chose
still reaches a caller that matches on it.

`decode_physical_expr` reads the name off the node rather than taking it as
an argument, so a caller cannot pair a node with a name it does not carry.

Documented in the 56.0.0 upgrade guide; tests in
`datafusion/proto/tests/cases/plans/expr_registry.rs`.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@adriangb
adriangb force-pushed the claude/datafusion-24626-0a1685 branch from f8b165c to 40473b0 Compare October 17, 2026 17:22

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

@adriangb,

Thanks for working on this. The registry-based decoding approach looks good overall, and I appreciate the backward compatibility with the existing codec path.

I found one issue with preserving expression identity when migrating to the new helper. I've also left two minor suggestions for test coverage and documentation.

expr_name: Some(T::NAME.to_string()),
},
)),
..Default::default()

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 helper leaves expr_id as None, and the serializer returns hook-generated nodes without adding the expression's ID. This means an extension with expression_id() == Some(42) loses its identity when switching to registry encoding, preventing the deduplicating decoder from restoring shared state. Could we stamp the ID at the common serialization boundary when the hook hasn't supplied one, and add a regression test with two references to an identity-bearing extension that verifies shared state and correctly decoded children?

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.

Good catch. Fixed in 5dc260a. The serialized now sets expression_id() on any node from a hook that leaves expr_id empty. I added a test for deduping by id.

/// A whole plan, through `physical_plan_to_bytes` and
/// `physical_plan_from_bytes`.
#[test]
fn registry_expr_survives_a_whole_plan_roundtrip() -> Result<()> {

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 follow-up roundtrip test containing both a registered extension plan and expression with the same wire name? The existing registry tests cover name isolation, but this would also exercise both registration paths together in a real plan.

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.

also added in 5dc260a

repeated PhysicalExprNode inputs = 2;
// Optional type discriminator naming the extension expression, e.g.
// "my_crate.MyExpr". When present and registered in the session's
// PhysicalExprRegistry, decoding is routed to that registered

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 comment refers to PhysicalExprRegistry, but the implementation uses ProtoDecoderRegistry. Could we update the name here and regenerate the corresponding comment in prost.rs?

adriangb and others added 2 commits October 19, 2026 14:07
…dtrip tests

- serialize_physical_expr_with_converter now sets the node's expr_id from
  PhysicalExpr::expression_id when try_to_proto left it unset, so extension
  expressions built with extension_expr_node keep dedup identity.
- Add a dedup regression test and a plan+expr same-wire-name roundtrip test.
- Fix stale PhysicalExprRegistry reference in datafusion.proto / prost.rs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@adriangb
adriangb requested a review from kosiew October 19, 2026 20:00

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

@adriangb,

Thanks for addressing the earlier review comments. I've rechecked the follow-up changes, and the expression ID preservation, shared-state deduplication, same-name plan/expression roundtrip, and registry documentation corrections all look good. I don't see any remaining correctness blockers.

image

One non-blocking suggestion: the PR description still references the old generic PhysicalExprDecodeCtx<'a, S>, ExprDecodeSession, a third constructor argument, and a required PhysicalExprFromProto import for built-ins. Could you update the API examples, migration guidance, re-export list, and test count to match the current implementation?

The prerequisite on #24631 and the major-release compatibility considerations still apply. Otherwise, this looks good to me.

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

Didn't give an in-depth review (trusting @kosiew here), but overall looks good and no surprises 👍 thanks @adriangb!

@adriangb

Copy link
Copy Markdown
Contributor Author

PR description updated, thanks for pointing it out @kosiew!

@adriangb
adriangb added this pull request to the merge queue Oct 20, 2026
Merged via the queue into apache:main with commit 9c626cd Oct 20, 2026
44 checks passed
@adriangb
adriangb deleted the claude/datafusion-24626-0a1685 branch October 20, 2026 19:53
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 documentation Improvements or additions to documentation physical-expr Changes to the physical-expr crates physical-plan Changes to the physical-plan crate proto Related to proto crate v56.0.0

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Allow extension PhysicalExprs to decode via a per-type registry instead of PhysicalExtensionCodec

4 participants