Skip to content

Support transparent ExecutionPlan downcasts#22559

Merged
alamb merged 1 commit into
apache:mainfrom
geoffreyclaude:fix/transparent-plan-downcast
May 27, 2026
Merged

Support transparent ExecutionPlan downcasts#22559
alamb merged 1 commit into
apache:mainfrom
geoffreyclaude:fix/transparent-plan-downcast

Conversation

@geoffreyclaude

@geoffreyclaude geoffreyclaude commented May 27, 2026

Copy link
Copy Markdown
Contributor

Which issue does this PR close?

Rationale for this change

DataFusion 54 changed ExecutionPlan downcasting to use the Any supertrait directly. That removes ExecutionPlan::as_any, which had also served as a customization point for wrapper nodes: wrappers could identify as themselves internally while exposing the wrapped plan type to normal downcast-based inspection.

This draft PR proposes one possible fix: add an explicit ExecutionPlan::downcast_delegate() hook for wrapper nodes that want their public ExecutionPlan downcast identity to be delegated to another plan.

The proposed behavior intentionally preserves the old as_any override semantics: when a node opts into downcast delegation, intermediate delegating wrappers are invisible to dyn ExecutionPlan::is::<T>() and downcast_ref::<T>(). A different "dual identity" model, where wrappers can downcast both as themselves and as their delegates, may also be useful, but that would be a new behavior rather than a compatibility fix.

This is only a suggested implementation for the linked issue. Other solution proposals, including different API names or a different shape for the hook, are very welcome.

What changes are included in this PR?

  • Adds ExecutionPlan::downcast_delegate() with a default implementation returning None.
  • Updates dyn ExecutionPlan::is::<T>() and downcast_ref::<T>() to delegate to downcast_delegate() when present, otherwise use the current concrete plan type.
  • Documents that downcast_delegate() is only for type introspection and is independent from children() / plan traversal.
  • Adds tests for direct and nested downcast-delegating wrappers, including that intermediate delegating wrappers remain invisible to normal downcast-based inspection.

An alternative API shape would make every plan expose an explicit downcast target. A concrete spelling could make the self-target case explicit:

enum DowncastTarget<'a> {
    SelfTarget,
    Plan(&'a dyn ExecutionPlan),
}

fn downcast_target(&self) -> DowncastTarget<'_> {
    DowncastTarget::SelfTarget
}

pub fn downcast_ref<T: ExecutionPlan>(&self) -> Option<&T> {
    match self.downcast_target() {
        DowncastTarget::Plan(target) => target.downcast_ref::<T>(),
        DowncastTarget::SelfTarget => (self as &dyn Any).downcast_ref(),
    }
}

That frames every plan as having a public downcast target, which is close to the old as_any mental model. A simpler conceptual version would be fn downcast_target(&self) -> &dyn ExecutionPlan with the default target being self, but the real helper still needs an explicit self-target base case to avoid recursing forever. This PR keeps the primary proposal as an explicit Option-based opt-in.

Are these changes tested?

Yes.

  • cargo test -p datafusion-physical-plan execution_plan_downcast
  • cargo test -p datafusion-physical-plan --lib
  • cargo fmt --all -- --check
  • git diff --check

Are there any user-facing changes?

Yes. This adds a new public ExecutionPlan trait method with a default implementation, and it changes ExecutionPlan downcast helpers to honor wrappers that explicitly opt into delegating public downcast identity.

@github-actions github-actions Bot added the physical-plan Changes to the physical-plan crate label May 27, 2026
@geoffreyclaude geoffreyclaude force-pushed the fix/transparent-plan-downcast branch 4 times, most recently from 2128754 to cae7bf1 Compare May 27, 2026 11:46
@timsaucer timsaucer self-requested a review May 27, 2026 11:47
@timsaucer

Copy link
Copy Markdown
Member

I'll review as soon as it's marked ready, but at a high level looks good so far

@geoffreyclaude geoffreyclaude force-pushed the fix/transparent-plan-downcast branch 2 times, most recently from 259871e to 6563792 Compare May 27, 2026 12:18
@geoffreyclaude geoffreyclaude marked this pull request as ready for review May 27, 2026 12:25
@geoffreyclaude geoffreyclaude force-pushed the fix/transparent-plan-downcast branch from 6563792 to fc8f083 Compare May 27, 2026 12:47
@geoffreyclaude

Copy link
Copy Markdown
Contributor Author

I'll review as soon as it's marked ready, but at a high level looks good so far

@timsaucer Should be ready for review now. I just renamed the API to downcast_delegate which seemed clearer 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.

👍 Even if it's not super pretty to have that extra method there, it gets the job done without introducing any breaking API change, and it's landed in a way that only people caring about implementing wrapper types need to care about this, without getting in the way of everyone else, so +1 on my side.

Before moving forward, let's wait for at least @timsaucer to give his opinion here.

Comment thread datafusion/physical-plan/src/execution_plan.rs Outdated
Comment on lines +747 to +750
match self.downcast_delegate() {
Some(delegate) => delegate.is::<T>(),
None => (self as &dyn Any).is::<T>(),
}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Won't this prevent you from downcasting to the wrapper type?

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.

Yes, and this is intentional to mimic the old as_any behavior. I've updated the PR description to be clear about this choice.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It seems like the earlier PR would still work for all use cases, right? Because you're always choosing to downcast to a specific type, it seems like it would enable downcasting to either (or many, depending on levels) type.

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.

The previous PR would stop on the first match, rather than go all the way to the leaf child. I agree this is being pedantic, as "real world" cases would probably never have a case where you'd need to match the leaf child but get interrupted by an intermediate node in a "wrapper chain". However, I still find the current implementation clearer and easier to reason about: the leaf node is what is returned.

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.

Should we revert the hew :Any instead? I think it was just supposed to be a cleanup but we didn't realize it would have downstream impacts 🤔

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.

Also I wonder if there is some way to avoid using as_any / delegating logic at all ... But to do that we would have to remove all uses of as_any / downcasting I suspect

@geoffreyclaude geoffreyclaude May 27, 2026

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.

Should we revert the new :Any instead? I think it was just supposed to be a cleanup but we didn't realize it would have downstream impacts 🤔

@alamb Honestly I prefer the new API with downcast_delegate: it's explicit and clearer.

Also I wonder if there is some way to avoid using as_any / delegating logic at all ... But to do that we would have to remove all uses of as_any / downcasting I suspect

That would be a pretty big breaking change as well, as the is a check is quite widespread.

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

looks good

I think this is feasible and good as we are gonna get 👍

Comment thread datafusion/physical-plan/src/execution_plan.rs Outdated
@geoffreyclaude geoffreyclaude force-pushed the fix/transparent-plan-downcast branch from fc8f083 to 41a9861 Compare May 27, 2026 13:20
@timsaucer

Copy link
Copy Markdown
Member

Do we need to also open a PR to target branch-54?

@geoffreyclaude

geoffreyclaude commented May 27, 2026

Copy link
Copy Markdown
Contributor Author

Do we need to also open a PR to target branch-54?

@timsaucer I opened #22565 on branch-54, which is a clean cherry-pick of this PR's single commit. I suggest we give both at least 24 hours grace before merging, so others can chime in if needed.

Comment on lines +747 to +750
match self.downcast_delegate() {
Some(delegate) => delegate.is::<T>(),
None => (self as &dyn Any).is::<T>(),
}

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.

Should we revert the hew :Any instead? I think it was just supposed to be a cleanup but we didn't realize it would have downstream impacts 🤔

Comment on lines +747 to +750
match self.downcast_delegate() {
Some(delegate) => delegate.is::<T>(),
None => (self as &dyn Any).is::<T>(),
}

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.

Also I wonder if there is some way to avoid using as_any / delegating logic at all ... But to do that we would have to remove all uses of as_any / downcasting I suspect

/// instrumentation. The default implementation returns `None`, meaning this
/// plan's concrete type is used for type introspection.
///
/// The `is` and `downcast_ref` helpers follow the returned delegate instead

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.

It might be good to add a specific example here (like a wrapper node that wants to observe changes but still be treated as the underlying node when DataFusion checks for patterns using downcast 🤔

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.

There's the unit test with DowncastDelegatingExec which already shows a possible implementation. Would you also like an inline 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.

Yeah, I was thinking an inline comment so that if someone else is reading (just) the ExecutionPlan docs they can more quickly understand if they need to worry about implementing this method or not (probably not)

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.

done: I amended the commit to be clearer in the comment.

@geoffreyclaude geoffreyclaude force-pushed the fix/transparent-plan-downcast branch from 41a9861 to 1851c8a Compare May 27, 2026 18:37
@alamb alamb added this pull request to the merge queue May 27, 2026
@alamb

alamb commented May 27, 2026

Copy link
Copy Markdown
Contributor

Looks good to me -- thank you everyone

Merged via the queue into apache:main with commit 72e3de7 May 27, 2026
38 checks passed
gabotechs pushed a commit that referenced this pull request May 28, 2026
## Which issue does this PR close?

- Refs #22557.
- Companion PR to #22559, targeting `branch-54`.

## Rationale for this change

DataFusion 54 changed `ExecutionPlan` downcasting to use the `Any`
supertrait directly. That removes `ExecutionPlan::as_any`, which had
also served as a customization point for wrapper nodes: wrappers could
identify as themselves internally while exposing the wrapped plan type
to normal downcast-based inspection.

This PR adds an explicit `ExecutionPlan::downcast_delegate()` hook for
wrapper nodes that want their public `ExecutionPlan` downcast identity
to be delegated to another plan.

The proposed behavior intentionally preserves the old `as_any` override
semantics: when a node opts into downcast delegation, intermediate
delegating wrappers are invisible to `dyn ExecutionPlan::is::<T>()` and
`downcast_ref::<T>()`.

## What changes are included in this PR?

- Adds `ExecutionPlan::downcast_delegate()` with a default
implementation returning `None`.
- Updates `dyn ExecutionPlan::is::<T>()` and `downcast_ref::<T>()` to
delegate to `downcast_delegate()` when present, otherwise use the
current concrete plan type.
- Documents that `downcast_delegate()` is only for type introspection
and is independent from `children()` / plan traversal.
- Adds tests for direct and nested downcast-delegating wrappers,
including that intermediate delegating wrappers remain invisible to
normal downcast-based inspection.

## Are these changes tested?

Yes.

- `cargo test -p datafusion-physical-plan execution_plan_downcast`
- `cargo test -p datafusion-physical-plan --lib`
- `cargo fmt --all -- --check`
- `git diff --check`

## Are there any user-facing changes?

Yes. This adds a new public `ExecutionPlan` trait method with a default
implementation, and it changes `ExecutionPlan` downcast helpers to honor
wrappers that explicitly opt into delegating public downcast identity.
pull Bot pushed a commit to buraksenn/datafusion that referenced this pull request Jun 24, 2026
apache#23154)

## Which issue does this PR close?

N/A. This is a follow-up to apache#22559 and apache#21263, but there is no dedicated
issue for this specific bug.

## Rationale for this change

apache#21263 removed `ExecutionPlan::as_any()` now that `ExecutionPlan` has
`Any` as a supertrait. Most call sites were migrated from code like
this:

```rust
plan.as_any().downcast_ref::<SomeExec>()
```

to the new helper:

```rust
plan.downcast_ref::<SomeExec>()
```

That distinction matters after apache#22559. `ExecutionPlan::downcast_ref` is
now the public downcast operation for execution plans: it follows
`ExecutionPlan::downcast_delegate()` before falling back to raw `Any`.
This lets wrapper plans keep their own concrete type private while still
presenting the wrapped plan's normal public identity.

For example, tracing or instrumentation wrappers can implement
`downcast_delegate()` so callers that ask "is this a `FilterExec` /
`EmptyExec` / `ProjectionExec`?" get the same answer they would have
received without the wrapper. The wrapper remains visible only to code
that intentionally performs a raw concrete-type `Any` check.

This problem came up while trying to instrument distributed plans
through
[`datafusion-distributed`](https://github.com/datafusion-contrib/datafusion-distributed).
In that setting, physical plans may be wrapped for
tracing/instrumentation and then serialized for distributed execution.
The wrapper is meant to be transparent for normal plan inspection, but
serialization still needs to recognize the delegated built-in plan
underneath it.

The physical plan proto serializer still had one leftover mechanical
migration from the old `as_any()` world:

```rust
let plan = plan.as_ref() as &dyn Any;
```

That line bypasses `ExecutionPlan::downcast_ref` entirely. As a result,
a delegating wrapper around a built-in execution plan is not serialized
as the built-in plan. Instead, the serializer sees only the wrapper's
concrete type, fails all built-in `Exec` checks, and falls through to
the extension codec. With the default extension codec this produces an
unsupported-plan error, even though the wrapped plan itself is
serializable.

This is particularly relevant for transparent wrappers such as
[`InstrumentedExec`](https://github.com/datafusion-contrib/datafusion-tracing/blob/main/datafusion-tracing/src/instrumented_exec.rs)
in
[`datafusion-contrib/datafusion-tracing`](https://github.com/datafusion-contrib/datafusion-tracing),
which intentionally delegate public downcasts to the wrapped plan.

## What changes are included in this PR?

- Changes physical plan proto serialization to bind the plan as `&dyn
ExecutionPlan` instead of `&dyn Any`.
- Leaves the existing serializer downcast chain intact, so each
`plan.downcast_ref::<...>()` now dispatches through the `ExecutionPlan`
helper and therefore honors `downcast_delegate()`.
- Adds a regression test with a small wrapper execution plan that
delegates public downcasts to an inner `EmptyExec`.
- Audited other `as_ref() as &dyn Any` patterns. The remaining hits are
either non-`ExecutionPlan` traits, `PhysicalExpr` checks, UDF tests, or
the intentional raw `Any` fallback inside the `ExecutionPlan` helper
itself.

## Are these changes tested?

Yes.

```bash
cargo fmt --all
cargo fmt --all --check
cargo test -p datafusion-proto --test proto_integration serialize_uses_downcast_delegate
cargo clippy --all-targets --all-features -- -D warnings
```

## Are there any user-facing changes?

Yes, as a bug fix. Physical plan proto serialization now honors the
documented `ExecutionPlan::downcast_delegate()` behavior. Transparent
wrapper plans can serialize as their delegated built-in execution plan
when appropriate instead of requiring an extension codec for the wrapper
itself.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

physical-plan Changes to the physical-plan crate

Projects

None yet

Development

Successfully merging this pull request may close these issues.

ExecutionPlan downcasting no longer supports transparent wrapper nodes

5 participants