You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@datafusion.apache.org by gi...@apache.org on 2026/09/28 17:11:25 UTC
(datafusion) 01/01: fix(ffi): don't discard downcast-delegating wrappers of foreign plans (#25749)
This is an automated email from the ASF dual-hosted git repository.
github-merge-queue[bot] pushed a commit to branch gh-readonly-queue/main/pr-25749-991fd23dd0be0046af5945b8c3905612859b657a
in repository https://gitbox.apache.org/repos/asf/datafusion.git
commit a0a048f78a38f998ea2a88e32faa66db80048e4d
Author: Tim Saucer <ti...@gmail.com>
AuthorDate: Mon Sep 28 17:11:03 2026 +0000
fix(ffi): don't discard downcast-delegating wrappers of foreign plans (#25749)
## Which issue does this PR close?
- Closes #25717.
## Rationale for this change
A transparent wrapper `ExecutionPlan` (one that implements
`downcast_delegate`, such as `datafusion-tracing`'s `InstrumentedExec`)
around a `ForeignExecutionPlan` is silently dropped when it is passed
back across the FFI boundary. For example, installing the tracing
instrumentation rule via FFI leaves the plan un-instrumented. Also, when
such a wrapper is the child of a foreign plan, it does not receive the
runtime handle.
## What changes are included in this PR?
- `FFI_ExecutionPlan::new`: the "already foreign" shortcut now checks
the concrete type via `Any` instead of the delegate-aware
`downcast_ref`, so it only fires for a real `ForeignExecutionPlan`.
- `pass_runtime_to_children`: the child check also uses `Any`, so a
local wrapper child of a foreign parent is re-wrapped with the runtime.
- `plan_is_foreign` intentionally stays delegate-aware, since a
delegating wrapper typically forwards `replace_children` to the foreign
plan and its children still need the runtime. A comment explains this.
## What is the testing strategy for this PR?
- Added an optional `downcast_delegate` mode to the existing `EmptyExec`
test plan (`with_downcast_delegate`).
- Added `test_ffi_execution_plan_delegating_wrapper`, which checks that
the wrapper survives `FFI_ExecutionPlan::new` and that a wrapper child
of a foreign parent gets the runtime. Reverting either fix on its own
makes the test fail.
## Are there any user-facing changes?
No API changes. Wrapper plans that implement `downcast_delegate` are now
kept when they cross the FFI boundary instead of being discarded.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-authored-by: Claude Opus 5.5 (1M context) <no...@anthropic.com>
Co-authored-by: Andrew Lamb <an...@nerdnetworks.org>
---
datafusion/ffi/src/execution_plan.rs | 68 ++++++++++++++++++++++++++++++++++--
1 file changed, 65 insertions(+), 3 deletions(-)
diff --git a/datafusion/ffi/src/execution_plan.rs b/datafusion/ffi/src/execution_plan.rs
index b242d1b275..6ef1f17cf6 100644
--- a/datafusion/ffi/src/execution_plan.rs
+++ b/datafusion/ffi/src/execution_plan.rs
@@ -15,6 +15,7 @@
// specific language governing permissions and limitations
// under the License.
+use std::any::Any;
use std::ffi::c_void;
use std::pin::Pin;
use std::sync::Arc;
@@ -288,6 +289,9 @@ fn pass_runtime_to_children(
runtime: &Handle,
) -> Result<Option<Arc<dyn ExecutionPlan>>> {
let mut updated_children = false;
+ // This intentionally uses the delegate-aware `is`: a transparent wrapper
+ // around a `ForeignExecutionPlan` typically forwards `replace_children` to
+ // it, so its children will still cross the FFI boundary and need the runtime.
let plan_is_foreign = plan.is::<ForeignExecutionPlan>();
let children = plan
@@ -306,8 +310,12 @@ fn pass_runtime_to_children(
// we called `children()` above we will get something other than a
// `ForeignExecutionPlan`. In this case wrap the plan in a `ForeignExecutionPlan`
// because when we call `replace_children` below it will extract the
- // FFI plan that does contain the runtime.
- if plan_is_foreign && !child.is::<ForeignExecutionPlan>() {
+ // FFI plan that does contain the runtime. Check the concrete type rather
+ // than the delegate-aware `is` so that a local wrapper which delegates to
+ // a `ForeignExecutionPlan` still receives the runtime.
+ if plan_is_foreign
+ && !(child.as_ref() as &dyn Any).is::<ForeignExecutionPlan>()
+ {
updated_children = true;
let ffi_child = FFI_ExecutionPlan::new(child, Some(runtime.clone()));
let foreign_child = ForeignExecutionPlan::try_from(ffi_child);
@@ -334,7 +342,13 @@ impl FFI_ExecutionPlan {
pub fn new(mut plan: Arc<dyn ExecutionPlan>, runtime: Option<Handle>) -> Self {
// Note to developers: `pass_runtime_to_children` relies on the logic here to
// get the underlying FFI plan during calls to `new_with_children`.
- if let Some(plan) = plan.downcast_ref::<ForeignExecutionPlan>() {
+ //
+ // Check the concrete type rather than using the delegate-aware
+ // `downcast_ref`, otherwise a wrapper whose `downcast_delegate` is a
+ // `ForeignExecutionPlan` would be discarded here.
+ if let Some(plan) =
+ (plan.as_ref() as &dyn Any).downcast_ref::<ForeignExecutionPlan>()
+ {
return plan.plan.clone();
}
@@ -571,6 +585,7 @@ pub mod tests {
dynamic_expressions: Vec<Arc<dyn PhysicalExpr>>,
metrics: Option<MetricsSet>,
statistics: Option<Statistics>,
+ downcast_delegate: bool,
}
impl EmptyExec {
@@ -587,6 +602,7 @@ pub mod tests {
dynamic_expressions: Vec::default(),
metrics: None,
statistics: None,
+ downcast_delegate: false,
}
}
@@ -600,6 +616,13 @@ pub mod tests {
self
}
+ /// Act as a transparent wrapper around `child` via `downcast_delegate`.
+ pub fn with_downcast_delegate(mut self, child: Arc<dyn ExecutionPlan>) -> Self {
+ self.children = vec![child];
+ self.downcast_delegate = true;
+ self
+ }
+
pub fn with_partitioning(mut self, partitioning: Partitioning) -> Self {
self.props =
Arc::new(self.props.as_ref().clone().with_partitioning(partitioning));
@@ -658,6 +681,7 @@ pub mod tests {
dynamic_expressions: self.dynamic_expressions.clone(),
metrics: self.metrics.clone(),
statistics: self.statistics.clone(),
+ downcast_delegate: self.downcast_delegate,
}))
}
@@ -679,6 +703,12 @@ pub mod tests {
unimplemented!()
}
+ fn downcast_delegate(&self) -> Option<&dyn ExecutionPlan> {
+ self.downcast_delegate
+ .then(|| self.children.first().map(|c| c.as_ref()))
+ .flatten()
+ }
+
fn dynamic_expressions_produced(&self) -> Vec<Arc<dyn PhysicalExpr>> {
self.dynamic_expressions.iter().map(Arc::clone).collect()
}
@@ -1004,4 +1034,36 @@ pub mod tests {
let foreign_plan: Arc<dyn ExecutionPlan> = (&ffi_plan).try_into().unwrap();
assert!(foreign_plan.is::<ForeignExecutionPlan>());
}
+
+ /// A wrapper whose `downcast_delegate` is a `ForeignExecutionPlan` must not be
+ /// mistaken for one: it must survive `FFI_ExecutionPlan::new` and still receive
+ /// the runtime when it is the local child of a foreign parent.
+ #[tokio::test]
+ async fn test_ffi_execution_plan_delegating_wrapper() -> Result<()> {
+ let schema = Arc::new(arrow::datatypes::Schema::empty());
+
+ let mut ffi_inner =
+ FFI_ExecutionPlan::new(Arc::new(EmptyExec::new(Arc::clone(&schema))), None);
+ ffi_inner.library_marker_id = crate::mock_foreign_marker_id;
+ let foreign_inner: Arc<dyn ExecutionPlan> =
+ Arc::new(ForeignExecutionPlan::try_from(ffi_inner)?);
+ let wrapper: Arc<dyn ExecutionPlan> = Arc::new(
+ EmptyExec::new(Arc::clone(&schema)).with_downcast_delegate(foreign_inner),
+ );
+ assert!(wrapper.is::<ForeignExecutionPlan>());
+
+ let ffi_wrapper = FFI_ExecutionPlan::new(Arc::clone(&wrapper), None);
+ assert!(ffi_wrapper.inner().downcast_delegate().is_some());
+
+ let parent = Arc::new(EmptyExec::new(schema)).replace_children(
+ vec![wrapper],
+ ReplaceChildrenOptions::new(ChildrenPropertiesMode::Recompute),
+ )?;
+ let mut ffi_parent = FFI_ExecutionPlan::new(parent, None);
+ ffi_parent.library_marker_id = crate::mock_foreign_marker_id;
+ let foreign_parent: Arc<dyn ExecutionPlan> = (&ffi_parent).try_into()?;
+ assert!(pass_runtime_to_children(&foreign_parent, &Handle::current())?.is_some());
+
+ Ok(())
+ }
}
---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@datafusion.apache.org
For additional commands, e-mail: commits-help@datafusion.apache.org