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
Copy link
Copy Markdown
Contributor

@geoffreyclaude geoffreyclaude commented May 27, 2026

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.

Copy link
Copy Markdown
Contributor

@gabotechs gabotechs left a comment

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

Copy link
Copy Markdown
Contributor Author

@geoffreyclaude geoffreyclaude May 27, 2026

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.

Copy link
Copy Markdown
Contributor

@gene-bordegaray gene-bordegaray left a comment

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
Copy link
Copy Markdown
Contributor Author

geoffreyclaude commented May 27, 2026

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
Copy link
Copy Markdown
Contributor

alamb commented May 27, 2026

Looks good to me -- thank you everyone

Merged via the queue into apache:main with commit 72e3de7 May 27, 2026
38 checks passed
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