This is an automated email from the ASF dual-hosted git repository.
github-merge-queue[bot] pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/datafusion.git
The following commit(s) were added to refs/heads/main by this push:
new e08aed1e5d feat: Implement FFI_QueryPlanner (#24028)
e08aed1e5d is described below
commit e08aed1e5de41dcf81d529140dae07723b942a5e
Author: Tim Saucer <[email protected]>
AuthorDate: Fri Aug 7 07:17:17 2026 -0400
feat: Implement FFI_QueryPlanner (#24028)
## Which issue does this PR close?
- Unblocks https://github.com/apache/datafusion-python/issues/1612
## Rationale for this change
This is the last in a series of PRs that would enable FFI `Session` to
support a `QueryPlanner`. The prior work was in
- https://github.com/apache/datafusion/pull/23649
- https://github.com/apache/datafusion/pull/23703
- https://github.com/apache/datafusion/pull/23842
With those changes in place we now have the dependencies correct that we
can expose a `FFI_QueryPlanner` on a `FFI_Session`. With this we can
enable foreign libraries such as `datafusion-distributed` and `ballista`
to provide a query planner in Python and connect it directly to a
`datafusion-python`'s `SessionContext`.
## What changes are included in this PR?
Addition only. Adds these functions to `FFI_Session` and their
supporting structures:
- `query_planner()`
- `optimize()`
- `physical_optimizers()`
## Are these changes tested?
Unit and integration tests are provided.
## Are there any user-facing changes?
This is addition, but it does break the FFI ABI, which is already
evolving in DF55.
---------
Co-authored-by: Claude Opus 5 (1M context) <[email protected]>
---
datafusion/datasource-parquet/src/file_format.rs | 1 +
.../datasource-parquet/src/opener/encryption.rs | 1 +
datafusion/datasource-parquet/src/sink.rs | 1 +
datafusion/ffi/src/lib.rs | 1 +
.../ffi/src/proto/logical_extension_codec.rs | 8 +-
.../ffi/src/proto/physical_extension_codec.rs | 8 +-
datafusion/ffi/src/query_planner.rs | 445 +++++++++++++++++++++
datafusion/ffi/src/session/mod.rs | 267 ++++++++++++-
datafusion/ffi/src/table_provider.rs | 4 +-
datafusion/ffi/src/table_provider_factory.rs | 2 +-
datafusion/ffi/src/tests/mod.rs | 13 +
datafusion/ffi/src/tests/query_planner.rs | 172 ++++++++
datafusion/ffi/src/tests/utils.rs | 43 +-
datafusion/ffi/src/udtf.rs | 2 +-
datafusion/ffi/tests/ffi_query_planner.rs | 348 ++++++++++++++++
docs/source/library-user-guide/upgrading/55.0.0.md | 23 +-
16 files changed, 1299 insertions(+), 40 deletions(-)
diff --git a/datafusion/datasource-parquet/src/file_format.rs
b/datafusion/datasource-parquet/src/file_format.rs
index 29083ebfb2..8d19bedeb7 100644
--- a/datafusion/datasource-parquet/src/file_format.rs
+++ b/datafusion/datasource-parquet/src/file_format.rs
@@ -297,6 +297,7 @@ async fn get_file_decryption_properties(
}
#[cfg(not(feature = "parquet_encryption"))]
+#[expect(clippy::unused_async)]
async fn get_file_decryption_properties(
_state: &dyn Session,
_options: &TableParquetOptions,
diff --git a/datafusion/datasource-parquet/src/opener/encryption.rs
b/datafusion/datasource-parquet/src/opener/encryption.rs
index b725198237..498fe8acf7 100644
--- a/datafusion/datasource-parquet/src/opener/encryption.rs
+++ b/datafusion/datasource-parquet/src/opener/encryption.rs
@@ -76,6 +76,7 @@ impl EncryptionContext {
#[cfg(not(feature = "parquet_encryption"))]
#[expect(dead_code)]
+#[expect(clippy::unused_async)]
impl EncryptionContext {
pub(super) async fn get_file_decryption_properties(
&self,
diff --git a/datafusion/datasource-parquet/src/sink.rs
b/datafusion/datasource-parquet/src/sink.rs
index e11f1d29d7..53f6f1e6b4 100644
--- a/datafusion/datasource-parquet/src/sink.rs
+++ b/datafusion/datasource-parquet/src/sink.rs
@@ -243,6 +243,7 @@ async fn set_writer_encryption_properties(
}
#[cfg(not(feature = "parquet_encryption"))]
+#[expect(clippy::unused_async)]
async fn set_writer_encryption_properties(
builder: WriterPropertiesBuilder,
_runtime: &Arc<RuntimeEnv>,
diff --git a/datafusion/ffi/src/lib.rs b/datafusion/ffi/src/lib.rs
index fd2ac58576..de8f8cba9c 100644
--- a/datafusion/ffi/src/lib.rs
+++ b/datafusion/ffi/src/lib.rs
@@ -39,6 +39,7 @@ pub mod physical_optimizer;
pub mod placement;
pub mod plan_properties;
pub mod proto;
+pub mod query_planner;
pub mod record_batch_stream;
pub mod schema_provider;
pub mod session;
diff --git a/datafusion/ffi/src/proto/logical_extension_codec.rs
b/datafusion/ffi/src/proto/logical_extension_codec.rs
index 97aa5c901a..ed2c594f1b 100644
--- a/datafusion/ffi/src/proto/logical_extension_codec.rs
+++ b/datafusion/ffi/src/proto/logical_extension_codec.rs
@@ -99,7 +99,7 @@ pub struct FFI_LogicalExtensionCodec {
try_encode_udwf:
unsafe extern "C" fn(&Self, node: FFI_WindowUDF) ->
FFI_Result<SVec<u8>>,
- pub task_ctx_provider: FFI_TaskContextProvider,
+ pub(crate) task_ctx_provider: FFI_TaskContextProvider,
/// Used to create a clone on the provider of the execution plan. This
should
/// only need to be called by the receiver of the plan.
@@ -295,7 +295,7 @@ impl Drop for FFI_LogicalExtensionCodec {
impl FFI_LogicalExtensionCodec {
/// Creates a new [`FFI_LogicalExtensionCodec`].
pub fn new(
- codec: Arc<dyn LogicalExtensionCodec + Send>,
+ codec: Arc<dyn LogicalExtensionCodec>,
runtime: Option<Handle>,
task_ctx_provider: impl Into<FFI_TaskContextProvider>,
) -> Self {
@@ -712,14 +712,12 @@ mod tests {
#[test]
fn ffi_logical_extension_codec_local_bypass() {
- let codec =
- Arc::new(TestExtensionCodec {}) as Arc<dyn LogicalExtensionCodec +
Send>;
+ let codec = Arc::new(TestExtensionCodec {}) as Arc<dyn
LogicalExtensionCodec>;
let (_ctx, task_ctx_provider) =
crate::util::tests::test_session_and_ctx();
let mut ffi_codec =
FFI_LogicalExtensionCodec::new(Arc::clone(&codec), None,
task_ctx_provider);
- let codec = codec as Arc<dyn LogicalExtensionCodec>;
// Verify local libraries can be downcast to their original
let foreign_codec: Arc<dyn LogicalExtensionCodec> =
(&ffi_codec).into();
assert!(arc_ptr_eq(&foreign_codec, &codec));
diff --git a/datafusion/ffi/src/proto/physical_extension_codec.rs
b/datafusion/ffi/src/proto/physical_extension_codec.rs
index 9e64df82e3..95d2ed68a6 100644
--- a/datafusion/ffi/src/proto/physical_extension_codec.rs
+++ b/datafusion/ffi/src/proto/physical_extension_codec.rs
@@ -92,7 +92,7 @@ pub struct FFI_PhysicalExtensionCodec {
unsafe extern "C" fn(&Self, node: FFI_WindowUDF) ->
FFI_Result<SVec<u8>>,
/// Access the current [`TaskContext`].
- task_ctx_provider: FFI_TaskContextProvider,
+ pub(crate) task_ctx_provider: FFI_TaskContextProvider,
/// Used to create a clone on the provider of the execution plan. This
should
/// only need to be called by the receiver of the plan.
@@ -281,7 +281,7 @@ impl Drop for FFI_PhysicalExtensionCodec {
impl FFI_PhysicalExtensionCodec {
/// Creates a new [`FFI_PhysicalExtensionCodec`].
pub fn new(
- codec: Arc<dyn PhysicalExtensionCodec + Send>,
+ codec: Arc<dyn PhysicalExtensionCodec>,
runtime: Option<Handle>,
task_ctx_provider: impl Into<FFI_TaskContextProvider>,
) -> Self {
@@ -695,14 +695,12 @@ pub(crate) mod tests {
#[test]
fn ffi_physical_extension_codec_local_bypass() {
- let codec =
- Arc::new(TestExtensionCodec {}) as Arc<dyn PhysicalExtensionCodec
+ Send>;
+ let codec = Arc::new(TestExtensionCodec {}) as Arc<dyn
PhysicalExtensionCodec>;
let (_ctx, task_ctx_provider) =
crate::util::tests::test_session_and_ctx();
let mut ffi_codec =
FFI_PhysicalExtensionCodec::new(Arc::clone(&codec), None,
task_ctx_provider);
- let codec = codec as Arc<dyn PhysicalExtensionCodec>;
// Verify local libraries can be downcast to their original
let foreign_codec: Arc<dyn PhysicalExtensionCodec> =
(&ffi_codec).into();
assert!(arc_ptr_eq(&foreign_codec, &codec));
diff --git a/datafusion/ffi/src/query_planner.rs
b/datafusion/ffi/src/query_planner.rs
new file mode 100644
index 0000000000..6d895d65c5
--- /dev/null
+++ b/datafusion/ffi/src/query_planner.rs
@@ -0,0 +1,445 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements. See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership. The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License. You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied. See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+//! FFI support for [`QueryPlanner`].
+//!
+//! A typical deployment has three libraries. Library A (for example,
+//! `datafusion-python`) owns the [`Session`] and codec registry. Library B
owns
+//! a custom table provider and its extension nodes. Library C (for example,
+//! Ballista or `datafusion-distributed`) owns the query planner. A serializes
a
+//! logical plan and invokes C, while `FFI_SessionRef` lets C call session
+//! services in A. C deserializes the logical plan, creates a physical plan,
+//! serializes that result, and returns it for A to deserialize. The logical
and
+//! physical extension codecs preserve nodes supplied by B.
+//!
+//! The physical result is serialized instead of returned as an
+//! [`crate::execution_plan::FFI_ExecutionPlan`]. An FFI execution-plan handle
is
+//! a foreign trait-object proxy, so even a built-in plan created in C cannot
be
+//! downcast to its concrete
+//! type in A. Serialization reconstructs known plan nodes with A's local Rust
+//! type identities, allowing A's optimizers and other consumers to downcast
+//! them. Extension codecs control how custom nodes are reconstructed.
+//!
+//! A node returned by B while C is planning is still foreign to C unless a
+//! codec boundary reconstructs it in C. The query-planner boundary guarantees
+//! that C-local serializable nodes, and extension nodes understood by the
+//! configured codecs, are reconstructed for A when the completed plan returns.
+//!
+//! # Delegating back to library A
+//!
+//! C commonly wants A's built-in planning as a starting point, then rewrites
the
+//! result. A must export its planner *before* installing C's planner on the
+//! session, and C must retain that handle: after the swap,
+//! [`Session::query_planner`] reports C's own planner, and
+//! [`Session::create_physical_plan`] dispatches to it, so either one is a
+//! self-call. Delegating to the retained handle is safe, because DataFusion's
+//! built-in physical planner never re-dispatches through [`Session`].
+//!
+//! Retain the planner rather than the session. [`FFI_QueryPlanner`] owns a
+//! reference-counted planner, so it outlives A's original session, whereas
+//! `FFI_SessionRef` borrows its session with the lifetime erased.
+
+use std::ffi::c_void;
+use std::sync::Arc;
+
+use async_ffi::{FfiFuture, FutureExt};
+use async_trait::async_trait;
+use datafusion_common::error::{DataFusionError, Result};
+use datafusion_expr::LogicalPlan;
+use datafusion_physical_plan::ExecutionPlan;
+use datafusion_proto::bytes::{
+ logical_plan_from_bytes_with_extension_codec,
+ logical_plan_to_bytes_with_extension_codec,
+ physical_plan_from_bytes_with_extension_codec,
+ physical_plan_to_bytes_with_extension_codec,
+};
+use datafusion_proto::logical_plan::LogicalExtensionCodec;
+use datafusion_proto::physical_plan::PhysicalExtensionCodec;
+use datafusion_session::{QueryPlanner, Session};
+use stabby::vec::Vec as SVec;
+use tokio::runtime::Handle;
+
+use crate::execution::FFI_TaskContextProvider;
+use crate::proto::logical_extension_codec::FFI_LogicalExtensionCodec;
+use crate::proto::physical_extension_codec::FFI_PhysicalExtensionCodec;
+use crate::session::{FFI_SessionRef, ForeignSession};
+use crate::util::FFI_Result;
+use crate::{df_result, sresult_return};
+
+/// An ABI-stable handle to a [`QueryPlanner`] owned by another library.
+///
+/// The Rust-facing adapters serialize the input [`LogicalPlan`] and resulting
+/// [`ExecutionPlan`]; callers do not invoke the byte-oriented function pointer
+/// directly.
+#[repr(C)]
+#[derive(Debug)]
+pub struct FFI_QueryPlanner {
+ create_physical_plan: unsafe extern "C" fn(
+ &Self,
+ logical_plan_serialized: SVec<u8>,
+ session: FFI_SessionRef,
+ ) -> FfiFuture<FFI_Result<SVec<u8>>>,
+
+ /// Codec used to encode and decode logical plans and extension nodes.
+ logical_codec: FFI_LogicalExtensionCodec,
+
+ /// Codec used to encode and decode physical plans and extension nodes.
+ physical_codec: FFI_PhysicalExtensionCodec,
+
+ /// Used to create a clone of the query planner.
+ clone: unsafe extern "C" fn(planner: &Self) -> Self,
+
+ /// Release the memory of the private data when it is no longer being used.
+ release: unsafe extern "C" fn(arg: &mut Self),
+
+ /// Return the major DataFusion version number of this planner.
+ pub version: unsafe extern "C" fn() -> u64,
+
+ /// Internal data. This is only to be accessed by the provider of the
planner.
+ /// A [`ForeignQueryPlanner`] should never attempt to access this data.
+ private_data: *mut c_void,
+
+ /// Utility to identify when FFI objects are accessed locally through
+ /// the foreign interface. See [`crate::get_library_marker_id`].
+ pub library_marker_id: extern "C" fn() -> usize,
+}
+
+unsafe impl Send for FFI_QueryPlanner {}
+unsafe impl Sync for FFI_QueryPlanner {}
+
+struct QueryPlannerPrivateData {
+ planner: Arc<dyn QueryPlanner + Send + Sync>,
+}
+
+impl FFI_QueryPlanner {
+ fn inner(&self) -> &Arc<dyn QueryPlanner + Send + Sync> {
+ let private_data = self.private_data as *const QueryPlannerPrivateData;
+ unsafe { &(*private_data).planner }
+ }
+}
+
+unsafe extern "C" fn create_physical_plan_fn_wrapper(
+ planner: &FFI_QueryPlanner,
+ logical_plan_serialized: SVec<u8>,
+ session: FFI_SessionRef,
+) -> FfiFuture<FFI_Result<SVec<u8>>> {
+ let internal_planner = Arc::clone(planner.inner());
+ let logical_codec: Arc<dyn LogicalExtensionCodec> =
(&planner.logical_codec).into();
+ let physical_codec: Arc<dyn PhysicalExtensionCodec> =
+ (&planner.physical_codec).into();
+
+ async move {
+ let mut foreign_session = None;
+ let session = sresult_return!(
+ session
+ .as_local()
+ .map(Ok::<&dyn Session, DataFusionError>)
+ .unwrap_or_else(|| {
+ foreign_session =
Some(ForeignSession::try_from(&session)?);
+ Ok(foreign_session.as_ref().unwrap())
+ })
+ );
+
+ let logical_plan =
sresult_return!(logical_plan_from_bytes_with_extension_codec(
+ logical_plan_serialized.as_slice(),
+ session.task_ctx().as_ref(),
+ logical_codec.as_ref(),
+ ));
+
+ let physical_plan = sresult_return!(
+ internal_planner
+ .create_physical_plan(&logical_plan, session)
+ .await
+ );
+ let physical_plan =
sresult_return!(physical_plan_to_bytes_with_extension_codec(
+ physical_plan,
+ physical_codec.as_ref(),
+ ));
+
+ FFI_Result::Ok(SVec::from(physical_plan.as_ref()))
+ }
+ .into_ffi()
+}
+
+unsafe extern "C" fn release_fn_wrapper(planner: &mut FFI_QueryPlanner) {
+ unsafe {
+ debug_assert!(!planner.private_data.is_null());
+ let private_data =
+ Box::from_raw(planner.private_data as *mut
QueryPlannerPrivateData);
+ drop(private_data);
+ planner.private_data = std::ptr::null_mut();
+ }
+}
+
+unsafe extern "C" fn clone_fn_wrapper(planner: &FFI_QueryPlanner) ->
FFI_QueryPlanner {
+ let old_planner = Arc::clone(planner.inner());
+
+ let private_data = Box::into_raw(Box::new(QueryPlannerPrivateData {
+ planner: old_planner,
+ })) as *mut c_void;
+
+ FFI_QueryPlanner {
+ create_physical_plan: create_physical_plan_fn_wrapper,
+ logical_codec: planner.logical_codec.clone(),
+ physical_codec: planner.physical_codec.clone(),
+ clone: clone_fn_wrapper,
+ release: release_fn_wrapper,
+ version: super::version,
+ private_data,
+ library_marker_id: crate::get_library_marker_id,
+ }
+}
+
+impl Drop for FFI_QueryPlanner {
+ fn drop(&mut self) {
+ unsafe { (self.release)(self) }
+ }
+}
+
+impl Clone for FFI_QueryPlanner {
+ fn clone(&self) -> Self {
+ unsafe { (self.clone)(self) }
+ }
+}
+
+impl FFI_QueryPlanner {
+ /// Creates an [`FFI_QueryPlanner`] with native extension codecs.
+ ///
+ /// Both codecs are required so that the caller states which extension
nodes
+ /// survive the boundary. Pass
+ ///
[`DefaultLogicalExtensionCodec`](datafusion_proto::logical_plan::DefaultLogicalExtensionCodec)
+ /// and
+ ///
[`DefaultPhysicalExtensionCodec`](datafusion_proto::physical_plan::DefaultPhysicalExtensionCodec)
+ /// when no custom nodes are involved. `runtime` and `task_ctx_provider`
+ /// support codec callbacks across the FFI boundary.
+ pub fn new(
+ planner: Arc<dyn QueryPlanner + Send + Sync>,
+ runtime: Option<Handle>,
+ task_ctx_provider: impl Into<FFI_TaskContextProvider>,
+ logical_codec: Arc<dyn LogicalExtensionCodec>,
+ physical_codec: Arc<dyn PhysicalExtensionCodec>,
+ ) -> Self {
+ let task_ctx_provider = task_ctx_provider.into();
+ let logical_codec = FFI_LogicalExtensionCodec::new(
+ logical_codec,
+ runtime.clone(),
+ task_ctx_provider.clone(),
+ );
+ let physical_codec =
+ FFI_PhysicalExtensionCodec::new(physical_codec, runtime,
task_ctx_provider);
+ Self::new_with_ffi_codecs(planner, logical_codec, physical_codec)
+ }
+
+ /// Creates an [`FFI_QueryPlanner`] using prebuilt FFI extension codecs.
+ ///
+ /// If `planner` is already foreign, this re-exports its original FFI
handle
+ /// rather than adding another wrapper layer. The handle still adopts the
+ /// codecs supplied here, so they are never silently discarded.
+ pub fn new_with_ffi_codecs(
+ planner: Arc<dyn QueryPlanner + Send + Sync>,
+ logical_codec: FFI_LogicalExtensionCodec,
+ physical_codec: FFI_PhysicalExtensionCodec,
+ ) -> Self {
+ let any_ref: &dyn std::any::Any = planner.as_ref();
+ if let Some(planner) = any_ref.downcast_ref::<ForeignQueryPlanner>() {
+ let mut planner = planner.0.clone();
+ planner.logical_codec = logical_codec;
+ planner.physical_codec = physical_codec;
+ return planner;
+ }
+
+ let private_data = Box::new(QueryPlannerPrivateData { planner });
+
+ Self {
+ create_physical_plan: create_physical_plan_fn_wrapper,
+ logical_codec,
+ physical_codec,
+ clone: clone_fn_wrapper,
+ release: release_fn_wrapper,
+ version: super::version,
+ private_data: Box::into_raw(private_data) as *mut c_void,
+ library_marker_id: crate::get_library_marker_id,
+ }
+ }
+
+ /// Creates a physical plan through this planner's FFI interface.
+ ///
+ /// This serializes `logical_plan`, exports `session` as an
+ /// `FFI_SessionRef`, invokes the planner's owning library, and
+ /// deserializes its physical-plan response. `session_runtime` is attached
+ /// to the exported session for callbacks that need its Tokio runtime.
+ ///
+ /// The [`QueryPlanner`] implementation for [`ForeignQueryPlanner`] cannot
+ /// obtain the session owner's runtime from the trait API, so it calls this
+ /// method with `None`. Embedders that own the runtime and need session
+ /// callbacks to enter it must call this method directly with
`Some(handle)`.
+ pub async fn create_physical_plan_with_session_runtime(
+ &self,
+ logical_plan: &LogicalPlan,
+ session: &dyn Session,
+ session_runtime: Option<Handle>,
+ ) -> Result<Arc<dyn ExecutionPlan>> {
+ let codec: Arc<dyn LogicalExtensionCodec> =
(&self.logical_codec).into();
+ let logical_plan =
+ logical_plan_to_bytes_with_extension_codec(logical_plan,
codec.as_ref())?;
+ let logical_plan = SVec::from(logical_plan.as_ref());
+ let task_ctx = session.task_ctx();
+ let session = FFI_SessionRef::new_with_ffi_codecs(
+ session,
+ session_runtime,
+ self.logical_codec.clone(),
+ self.physical_codec.clone(),
+ );
+
+ let physical_plan = unsafe {
+ df_result!((self.create_physical_plan)(self, logical_plan,
session).await)?
+ };
+ let physical_codec: Arc<dyn PhysicalExtensionCodec> =
+ (&self.physical_codec).into();
+
+ physical_plan_from_bytes_with_extension_codec(
+ physical_plan.as_slice(),
+ task_ctx.as_ref(),
+ physical_codec.as_ref(),
+ )
+ }
+}
+
+/// Consumer-side [`QueryPlanner`] adapter for an [`FFI_QueryPlanner`].
+///
+/// Calls serialize the logical plan, invoke the producing library, and
+/// deserialize its physical-plan response.
+#[derive(Debug)]
+pub struct ForeignQueryPlanner(pub FFI_QueryPlanner);
+
+unsafe impl Send for ForeignQueryPlanner {}
+unsafe impl Sync for ForeignQueryPlanner {}
+
+impl From<&FFI_QueryPlanner> for Arc<dyn QueryPlanner + Send + Sync> {
+ fn from(planner: &FFI_QueryPlanner) -> Self {
+ if (planner.library_marker_id)() == crate::get_library_marker_id() {
+ Arc::clone(planner.inner())
+ } else {
+ Arc::new(ForeignQueryPlanner(planner.clone()))
+ }
+ }
+}
+
+#[async_trait]
+impl QueryPlanner for ForeignQueryPlanner {
+ async fn create_physical_plan(
+ &self,
+ logical_plan: &LogicalPlan,
+ session: &dyn Session,
+ ) -> Result<Arc<dyn ExecutionPlan>> {
+ self.0
+ .create_physical_plan_with_session_runtime(logical_plan, session,
None)
+ .await
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use std::sync::Arc;
+
+ use arrow::datatypes::{DataType, Field, Schema};
+ use datafusion::prelude::SessionContext;
+ use datafusion_common::Result;
+ use datafusion_execution::TaskContextProvider;
+ use datafusion_expr::LogicalPlanBuilder;
+ use datafusion_physical_plan::empty::EmptyExec;
+ use datafusion_proto::logical_plan::DefaultLogicalExtensionCodec;
+ use datafusion_proto::physical_plan::DefaultPhysicalExtensionCodec;
+
+ use super::*;
+
+ #[derive(Debug)]
+ struct EmptyQueryPlanner;
+
+ #[async_trait]
+ impl QueryPlanner for EmptyQueryPlanner {
+ async fn create_physical_plan(
+ &self,
+ _logical_plan: &LogicalPlan,
+ _session: &dyn Session,
+ ) -> Result<Arc<dyn ExecutionPlan>> {
+ let schema =
+ Arc::new(Schema::new(vec![Field::new("a", DataType::Int32,
true)]));
+ Ok(Arc::new(EmptyExec::new(schema)))
+ }
+ }
+
+ fn create_ffi_query_planner(ctx: Arc<SessionContext>) -> FFI_QueryPlanner {
+ let task_ctx_provider = Arc::clone(&ctx) as Arc<dyn
TaskContextProvider>;
+ FFI_QueryPlanner::new(
+ Arc::new(EmptyQueryPlanner),
+ None,
+ &task_ctx_provider,
+ Arc::new(DefaultLogicalExtensionCodec {}),
+ Arc::new(DefaultPhysicalExtensionCodec {}),
+ )
+ }
+
+ #[test]
+ fn test_ffi_query_planner_local_bypass() {
+ let ctx = Arc::new(SessionContext::new());
+ let ffi_planner = create_ffi_query_planner(ctx);
+ let planner: Arc<dyn QueryPlanner + Send + Sync> =
(&ffi_planner).into();
+ let any_ref: &dyn std::any::Any = planner.as_ref();
+ assert!(any_ref.downcast_ref::<EmptyQueryPlanner>().is_some());
+ }
+
+ #[tokio::test]
+ async fn test_round_trip_ffi_query_planner_create_physical_plan() ->
Result<()> {
+ let ctx = Arc::new(SessionContext::new());
+ let mut ffi_planner = create_ffi_query_planner(Arc::clone(&ctx));
+ ffi_planner.library_marker_id = crate::mock_foreign_marker_id;
+
+ let planner: Arc<dyn QueryPlanner + Send + Sync> =
(&ffi_planner).into();
+ let any_ref: &dyn std::any::Any = planner.as_ref();
+ assert!(any_ref.downcast_ref::<ForeignQueryPlanner>().is_some());
+
+ let logical_plan = LogicalPlanBuilder::empty(false).build()?;
+ let state = ctx.state();
+ let physical_plan = planner.create_physical_plan(&logical_plan,
&state).await?;
+ assert_eq!(physical_plan.name(), "EmptyExec");
+ assert!(physical_plan.is::<EmptyExec>());
+
+ Ok(())
+ }
+
+ #[tokio::test]
+ async fn test_create_physical_plan_with_session_runtime() -> Result<()> {
+ let ctx = Arc::new(SessionContext::new());
+ let ffi_planner = create_ffi_query_planner(Arc::clone(&ctx));
+ let logical_plan = LogicalPlanBuilder::empty(false).build()?;
+ let state = ctx.state();
+
+ let physical_plan = ffi_planner
+ .create_physical_plan_with_session_runtime(
+ &logical_plan,
+ &state,
+ Some(Handle::current()),
+ )
+ .await?;
+
+ assert_eq!(physical_plan.name(), "EmptyExec");
+ assert!(physical_plan.is::<EmptyExec>());
+
+ Ok(())
+ }
+}
diff --git a/datafusion/ffi/src/session/mod.rs
b/datafusion/ffi/src/session/mod.rs
index 519384379e..83f842508a 100644
--- a/datafusion/ffi/src/session/mod.rs
+++ b/datafusion/ffi/src/session/mod.rs
@@ -15,10 +15,25 @@
// specific language governing permissions and limitations
// under the License.
+//! FFI support for [`Session`].
+//!
+//! # Delegating physical planning
+//!
+//! Consider a session owned by library A that uses a query planner owned by
+//! library C. After A installs C's planner, [`ForeignSession::query_planner`]
+//! returns C's planner and [`ForeignSession::create_physical_plan`] dispatches
+//! to C's planner. C must not call `create_physical_plan`, or invoke the
planner
+//! returned by `query_planner`, to delegate planning back to A. Repeating
either
+//! self-call recurses until the stack is exhausted.
+//!
+//! To delegate safely, A must export its original planner before installing
C's
+//! planner, and C must retain and invoke that planner directly. See the
+//! [`crate::query_planner`] module for details.
+
use std::any::Any;
use std::collections::HashMap;
use std::ffi::c_void;
-use std::sync::Arc;
+use std::sync::{Arc, OnceLock};
use arrow_schema::SchemaRef;
use arrow_schema::ffi::FFI_ArrowSchema;
@@ -37,12 +52,18 @@ use datafusion_expr::{
};
use datafusion_physical_expr::PhysicalExpr;
use datafusion_physical_plan::ExecutionPlan;
-use datafusion_proto::bytes::{logical_plan_from_bytes, logical_plan_to_bytes};
+use datafusion_proto::bytes::{
+ logical_plan_from_bytes, logical_plan_from_bytes_with_extension_codec,
+ logical_plan_to_bytes, logical_plan_to_bytes_with_extension_codec,
+};
use datafusion_proto::logical_plan::LogicalExtensionCodec;
use datafusion_proto::logical_plan::from_proto::parse_expr;
use datafusion_proto::logical_plan::to_proto::serialize_expr;
+use datafusion_proto::physical_plan::DefaultPhysicalExtensionCodec;
use datafusion_proto::protobuf::LogicalExprNode;
-use datafusion_session::{CatalogProviderList, Session};
+use datafusion_session::{
+ CatalogProviderList, PhysicalOptimizerRule, QueryPlanner, Session,
+};
use prost::Message;
use stabby::str::Str as SStr;
@@ -55,7 +76,10 @@ use crate::catalog_provider_list::FFI_CatalogProviderList;
use crate::execution::FFI_TaskContext;
use crate::execution_plan::FFI_ExecutionPlan;
use crate::physical_expr::FFI_PhysicalExpr;
+use crate::physical_optimizer::FFI_PhysicalOptimizerRule;
use crate::proto::logical_extension_codec::FFI_LogicalExtensionCodec;
+use crate::proto::physical_extension_codec::FFI_PhysicalExtensionCodec;
+use crate::query_planner::FFI_QueryPlanner;
use crate::session::config::FFI_SessionConfig;
use crate::udaf::FFI_AggregateUDF;
use crate::udf::FFI_ScalarUDF;
@@ -86,6 +110,13 @@ pub(crate) struct FFI_SessionRef {
catalog_list: unsafe extern "C" fn(&Self) -> FFI_CatalogProviderList,
+ query_planner: unsafe extern "C" fn(&Self) -> FFI_QueryPlanner,
+
+ optimize: unsafe extern "C" fn(
+ &Self,
+ logical_plan_serialized: SVec<u8>,
+ ) -> FFI_Result<SVec<u8>>,
+
create_physical_plan:
unsafe extern "C" fn(
&Self,
@@ -110,8 +141,12 @@ pub(crate) struct FFI_SessionRef {
task_ctx: unsafe extern "C" fn(&Self) -> FFI_TaskContext,
+ physical_optimizers: unsafe extern "C" fn(&Self) ->
SVec<FFI_PhysicalOptimizerRule>,
+
logical_codec: FFI_LogicalExtensionCodec,
+ physical_codec: FFI_PhysicalExtensionCodec,
+
/// Used to create a clone on the provider of the registry. This should
/// only need to be called by the receiver of the plan.
clone: unsafe extern "C" fn(plan: &Self) -> Self,
@@ -135,12 +170,12 @@ unsafe impl Send for FFI_SessionRef {}
unsafe impl Sync for FFI_SessionRef {}
struct SessionPrivateData<'a> {
- session: &'a (dyn Session + Send + Sync),
+ session: &'a dyn Session,
runtime: Option<Handle>,
}
impl FFI_SessionRef {
- fn inner(&self) -> &(dyn Session + Send + Sync) {
+ fn inner(&self) -> &dyn Session {
let private_data = self.private_data as *const SessionPrivateData;
unsafe { (*private_data).session }
}
@@ -173,6 +208,36 @@ unsafe extern "C" fn catalog_list_fn_wrapper(
)
}
+unsafe extern "C" fn query_planner_fn_wrapper(
+ session: &FFI_SessionRef,
+) -> FFI_QueryPlanner {
+ FFI_QueryPlanner::new_with_ffi_codecs(
+ session.inner().query_planner(),
+ session.logical_codec.clone(),
+ session.physical_codec.clone(),
+ )
+}
+
+unsafe extern "C" fn optimize_fn_wrapper(
+ session: &FFI_SessionRef,
+ logical_plan_serialized: SVec<u8>,
+) -> FFI_Result<SVec<u8>> {
+ let logical_codec: Arc<dyn LogicalExtensionCodec> =
(&session.logical_codec).into();
+ let inner = session.inner();
+ let logical_plan =
sresult_return!(logical_plan_from_bytes_with_extension_codec(
+ logical_plan_serialized.as_slice(),
+ inner.task_ctx().as_ref(),
+ logical_codec.as_ref(),
+ ));
+ let optimized_plan = sresult_return!(inner.optimize(&logical_plan));
+ let optimized_plan =
sresult_return!(logical_plan_to_bytes_with_extension_codec(
+ &optimized_plan,
+ logical_codec.as_ref(),
+ ));
+
+ FFI_Result::Ok(SVec::from(optimized_plan.as_ref()))
+}
+
unsafe extern "C" fn create_physical_plan_fn_wrapper(
session: &FFI_SessionRef,
logical_plan_serialized: SVec<u8>,
@@ -303,6 +368,18 @@ unsafe extern "C" fn task_ctx_fn_wrapper(session:
&FFI_SessionRef) -> FFI_TaskCo
session.inner().task_ctx().into()
}
+unsafe extern "C" fn physical_optimizers_fn_wrapper(
+ session: &FFI_SessionRef,
+) -> SVec<FFI_PhysicalOptimizerRule> {
+ let runtime = unsafe { session.runtime().clone() };
+ session
+ .inner()
+ .physical_optimizers()
+ .iter()
+ .map(|rule| FFI_PhysicalOptimizerRule::new(Arc::clone(rule),
runtime.clone()))
+ .collect()
+}
+
unsafe extern "C" fn release_fn_wrapper(provider: &mut FFI_SessionRef) {
unsafe {
let private_data =
@@ -324,6 +401,8 @@ unsafe extern "C" fn clone_fn_wrapper(provider:
&FFI_SessionRef) -> FFI_SessionR
session_id: session_id_fn_wrapper,
config: config_fn_wrapper,
catalog_list: catalog_list_fn_wrapper,
+ query_planner: query_planner_fn_wrapper,
+ optimize: optimize_fn_wrapper,
create_physical_plan: create_physical_plan_fn_wrapper,
create_physical_expr: create_physical_expr_fn_wrapper,
scalar_functions: scalar_functions_fn_wrapper,
@@ -332,7 +411,9 @@ unsafe extern "C" fn clone_fn_wrapper(provider:
&FFI_SessionRef) -> FFI_SessionR
table_options: table_options_fn_wrapper,
default_table_options: default_table_options_fn_wrapper,
task_ctx: task_ctx_fn_wrapper,
+ physical_optimizers: physical_optimizers_fn_wrapper,
logical_codec: provider.logical_codec.clone(),
+ physical_codec: provider.physical_codec.clone(),
clone: clone_fn_wrapper,
release: release_fn_wrapper,
@@ -350,14 +431,60 @@ impl Drop for FFI_SessionRef {
}
impl FFI_SessionRef {
- /// Creates a new [`FFI_SessionRef`].
+ /// Creates a new [`FFI_SessionRef`] with a default physical extension
codec.
+ ///
+ /// The synthesized [`DefaultPhysicalExtensionCodec`] supports built-in
physical
+ /// nodes only. A query planner obtained through this session reference
therefore
+ /// cannot encode or decode custom physical extension nodes. Use
+ /// [`Self::new_with_ffi_codecs`] with matching logical and physical
codecs when
+ /// custom physical nodes must cross the FFI boundary.
+ ///
+ /// The physical codec wrapper requires a
+ ///
[`FFI_TaskContextProvider`](crate::execution::FFI_TaskContextProvider), but this
+ /// constructor has only a session reference and a logical codec. It
therefore
+ /// reuses the logical codec's provider. The provider may be owned by
another
+ /// library; this is safe, but it must remain live and return the task
context
+ /// intended for codec callbacks. The default physical codec does not
successfully
+ /// decode extension nodes, so callers that need such callbacks must
instead use
+ /// [`Self::new_with_ffi_codecs`] with an explicitly configured physical
codec and
+ /// task context provider.
pub fn new(
- session: &(dyn Session + Send + Sync),
+ session: &dyn Session,
runtime: Option<Handle>,
logical_codec: FFI_LogicalExtensionCodec,
+ ) -> Self {
+ // `Session` provides a TaskContext but not the reference-counted
+ // TaskContextProvider needed by the FFI codec. Reuse the provider
associated
+ // with the logical codec under the assumptions documented above.
+ let physical_codec = FFI_PhysicalExtensionCodec::new(
+ Arc::new(DefaultPhysicalExtensionCodec {}),
+ runtime.clone(),
+ logical_codec.task_ctx_provider.clone(),
+ );
+ Self::new_with_ffi_codecs(session, runtime, logical_codec,
physical_codec)
+ }
+
+ /// Creates a new [`FFI_SessionRef`] using existing FFI codecs.
+ ///
+ /// The codecs must form a matching pair that can round-trip every logical
and
+ /// physical extension node exposed through the session. Their task context
+ /// providers must remain live and return contexts appropriate for their
decode
+ /// callbacks.
+ ///
+ /// If `session` is already foreign, this re-exports its original FFI
handle
+ /// rather than adding another wrapper layer. The handle adopts the codecs
+ /// supplied here while retaining its original private data and runtime.
+ pub fn new_with_ffi_codecs(
+ session: &dyn Session,
+ runtime: Option<Handle>,
+ logical_codec: FFI_LogicalExtensionCodec,
+ physical_codec: FFI_PhysicalExtensionCodec,
) -> Self {
if let Some(session) =
session.as_any().downcast_ref::<ForeignSession>() {
- return session.session.clone();
+ let mut session = session.session.clone();
+ session.logical_codec = logical_codec;
+ session.physical_codec = physical_codec;
+ return session;
}
let private_data = Box::new(SessionPrivateData { session, runtime });
@@ -366,6 +493,8 @@ impl FFI_SessionRef {
session_id: session_id_fn_wrapper,
config: config_fn_wrapper,
catalog_list: catalog_list_fn_wrapper,
+ query_planner: query_planner_fn_wrapper,
+ optimize: optimize_fn_wrapper,
create_physical_plan: create_physical_plan_fn_wrapper,
create_physical_expr: create_physical_expr_fn_wrapper,
scalar_functions: scalar_functions_fn_wrapper,
@@ -374,7 +503,9 @@ impl FFI_SessionRef {
table_options: table_options_fn_wrapper,
default_table_options: default_table_options_fn_wrapper,
task_ctx: task_ctx_fn_wrapper,
+ physical_optimizers: physical_optimizers_fn_wrapper,
logical_codec,
+ physical_codec,
clone: clone_fn_wrapper,
release: release_fn_wrapper,
@@ -387,8 +518,17 @@ impl FFI_SessionRef {
/// This wrapper struct exists on the receiver side of the FFI interface, so
it has
/// no guarantees about being able to access the data in `private_data`. Any
functions
-/// defined on this struct must only use the stable functions provided in
-/// FFI_Session to interact with the foreign table provider.
+/// defined on this struct must use only the stable function pointers in
+/// `FFI_SessionRef` to interact with the foreign session.
+///
+/// # Query planner delegation
+///
+/// If the session owner installed the current foreign query planner,
+/// [`Session::create_physical_plan`] dispatches back to that planner and
+/// [`Session::query_planner`] returns that planner. The planner must retain
and
+/// invoke the session owner's previous planner instead of using either method
to
+/// delegate back to the session. Otherwise, repeated delegation exhausts the
+/// stack. See [`crate::query_planner`] for details.
#[derive(Debug)]
pub struct ForeignSession {
session: FFI_SessionRef,
@@ -402,13 +542,15 @@ pub struct ForeignSession {
table_options: TableOptions,
runtime_env: Arc<RuntimeEnv>,
props: ExecutionProps,
+ query_planner: OnceLock<Arc<dyn QueryPlanner + Send + Sync>>,
+ physical_optimizers: OnceLock<Vec<Arc<dyn PhysicalOptimizerRule + Send +
Sync>>>,
}
unsafe impl Send for ForeignSession {}
unsafe impl Sync for ForeignSession {}
impl FFI_SessionRef {
- pub fn as_local(&self) -> Option<&(dyn Session + Send + Sync)> {
+ pub fn as_local(&self) -> Option<&dyn Session> {
if (self.library_marker_id)() == crate::get_library_marker_id() {
return Some(self.inner());
}
@@ -462,7 +604,6 @@ impl TryFrom<&FFI_SessionRef> for ForeignSession {
)
})
.collect();
-
Ok(Self {
session: session.clone(),
config,
@@ -475,6 +616,8 @@ impl TryFrom<&FFI_SessionRef> for ForeignSession {
extension_types:
Arc::new(MemoryExtensionTypeRegistry::default()),
runtime_env: Default::default(),
props: Default::default(),
+ query_planner: OnceLock::new(),
+ physical_optimizers: OnceLock::new(),
})
}
}
@@ -573,6 +716,31 @@ impl Session for ForeignSession {
Arc::clone(&self.catalog_list)
}
+ fn query_planner(&self) -> Arc<dyn QueryPlanner + Send + Sync> {
+ Arc::clone(self.query_planner.get_or_init(|| unsafe {
+ let planner = (self.session.query_planner)(&self.session);
+ (&planner).into()
+ }))
+ }
+
+ fn optimize(&self, plan: &LogicalPlan) ->
datafusion_common::Result<LogicalPlan> {
+ unsafe {
+ let codec: Arc<dyn LogicalExtensionCodec> =
+ (&self.session.logical_codec).into();
+ let logical_plan =
+ logical_plan_to_bytes_with_extension_codec(plan,
codec.as_ref())?;
+ let optimized_plan = df_result!((self.session.optimize)(
+ &self.session,
+ SVec::from(logical_plan.as_ref()),
+ ))?;
+ logical_plan_from_bytes_with_extension_codec(
+ optimized_plan.as_slice(),
+ self.task_ctx().as_ref(),
+ codec.as_ref(),
+ )
+ }
+ }
+
async fn create_physical_plan(
&self,
logical_plan: &LogicalPlan,
@@ -613,6 +781,15 @@ impl Session for ForeignSession {
}
}
+ fn physical_optimizers(&self) -> &[Arc<dyn PhysicalOptimizerRule + Send +
Sync>] {
+ self.physical_optimizers.get_or_init(|| unsafe {
+ (self.session.physical_optimizers)(&self.session)
+ .into_iter()
+ .map(|rule| (&rule).into())
+ .collect()
+ })
+ }
+
fn scalar_functions(&self) -> &HashMap<String, Arc<ScalarUDF>> {
&self.scalar_functions
}
@@ -672,6 +849,7 @@ impl Session for ForeignSession {
#[cfg(test)]
mod tests {
use std::sync::Arc;
+ use std::sync::atomic::{AtomicUsize, Ordering};
use arrow_schema::{DataType, Field, Schema};
use datafusion::catalog::MemoryCatalogProvider;
@@ -683,6 +861,59 @@ mod tests {
use super::*;
+ static QUERY_PLANNER_CALLS: AtomicUsize = AtomicUsize::new(0);
+ static PHYSICAL_OPTIMIZER_CALLS: AtomicUsize = AtomicUsize::new(0);
+
+ unsafe extern "C" fn counting_query_planner(
+ session: &FFI_SessionRef,
+ ) -> FFI_QueryPlanner {
+ QUERY_PLANNER_CALLS.fetch_add(1, Ordering::Relaxed);
+ unsafe { query_planner_fn_wrapper(session) }
+ }
+
+ unsafe extern "C" fn counting_physical_optimizers(
+ session: &FFI_SessionRef,
+ ) -> SVec<FFI_PhysicalOptimizerRule> {
+ PHYSICAL_OPTIMIZER_CALLS.fetch_add(1, Ordering::Relaxed);
+ unsafe { physical_optimizers_fn_wrapper(session) }
+ }
+
+ #[test]
+ fn test_foreign_session_lazily_loads_planning_state() -> Result<(),
DataFusionError> {
+ QUERY_PLANNER_CALLS.store(0, Ordering::Relaxed);
+ PHYSICAL_OPTIMIZER_CALLS.store(0, Ordering::Relaxed);
+
+ let (ctx, task_ctx_provider) =
crate::util::tests::test_session_and_ctx();
+ let logical_codec = FFI_LogicalExtensionCodec::new(
+ Arc::new(DefaultLogicalExtensionCodec {}),
+ None,
+ task_ctx_provider,
+ );
+ let state = ctx.state();
+ let mut local_session = FFI_SessionRef::new(&state, None,
logical_codec);
+ local_session.query_planner = counting_query_planner;
+ local_session.physical_optimizers = counting_physical_optimizers;
+
+ let mut foreign_session = ForeignSession::try_from(&local_session)?;
+ assert_eq!(QUERY_PLANNER_CALLS.load(Ordering::Relaxed), 0);
+ assert_eq!(PHYSICAL_OPTIMIZER_CALLS.load(Ordering::Relaxed), 0);
+
+ // `FFI_SessionRef::clone` restores the standard function pointers, so
+ // instrument the clone retained by `ForeignSession` as well.
+ foreign_session.session.query_planner = counting_query_planner;
+ foreign_session.session.physical_optimizers =
counting_physical_optimizers;
+
+ foreign_session.query_planner();
+ foreign_session.query_planner();
+ assert_eq!(QUERY_PLANNER_CALLS.load(Ordering::Relaxed), 1);
+
+ foreign_session.physical_optimizers();
+ foreign_session.physical_optimizers();
+ assert_eq!(PHYSICAL_OPTIMIZER_CALLS.load(Ordering::Relaxed), 1);
+
+ Ok(())
+ }
+
#[tokio::test]
async fn test_ffi_session() -> Result<(), DataFusionError> {
let (ctx, task_ctx_provider) =
crate::util::tests::test_session_and_ctx();
@@ -731,14 +962,16 @@ mod tests {
let logical_plan = LogicalPlan::default();
assert_eq!(foreign_session.optimize(&logical_plan)?, logical_plan);
- assert!(foreign_session.physical_optimizers().is_empty());
+ assert_eq!(
+ foreign_session.physical_optimizers().len(),
+ state.physical_optimizers().len()
+ );
assert!(foreign_session.statistics_registry().is_none());
- let planner_error = foreign_session
+ let planned = foreign_session
.query_planner()
.create_physical_plan(&logical_plan, &foreign_session)
- .await
- .unwrap_err();
- assert!(planner_error.to_string().contains("does not expose"));
+ .await?;
+ assert_eq!(planned.name(), "EmptyExec");
let physical_plan =
foreign_session.create_physical_plan(&logical_plan).await?;
assert_eq!(
diff --git a/datafusion/ffi/src/table_provider.rs
b/datafusion/ffi/src/table_provider.rs
index 5a4b2fa272..ee9377bff0 100644
--- a/datafusion/ffi/src/table_provider.rs
+++ b/datafusion/ffi/src/table_provider.rs
@@ -263,7 +263,7 @@ unsafe extern "C" fn scan_fn_wrapper(
let session = sresult_return!(
session
.as_local()
- .map(Ok::<&(dyn Session + Send + Sync), DataFusionError>)
+ .map(Ok::<&dyn Session, DataFusionError>)
.unwrap_or_else(|| {
foreign_session =
Some(ForeignSession::try_from(&session)?);
Ok(foreign_session.as_ref().unwrap())
@@ -314,7 +314,7 @@ unsafe extern "C" fn insert_into_fn_wrapper(
let session = sresult_return!(
session
.as_local()
- .map(Ok::<&(dyn Session + Send + Sync), DataFusionError>)
+ .map(Ok::<&dyn Session, DataFusionError>)
.unwrap_or_else(|| {
foreign_session =
Some(ForeignSession::try_from(&session)?);
Ok(foreign_session.as_ref().unwrap())
diff --git a/datafusion/ffi/src/table_provider_factory.rs
b/datafusion/ffi/src/table_provider_factory.rs
index b70e72f31a..63ebb51bb1 100644
--- a/datafusion/ffi/src/table_provider_factory.rs
+++ b/datafusion/ffi/src/table_provider_factory.rs
@@ -211,7 +211,7 @@ async fn create_fn_wrapper_impl(
let mut foreign_session = None;
let session = session
.as_local()
- .map(Ok::<&(dyn Session + Send + Sync), DataFusionError>)
+ .map(Ok::<&dyn Session, DataFusionError>)
.unwrap_or_else(|| {
foreign_session = Some(ForeignSession::try_from(&session)?);
Ok(foreign_session.as_ref().unwrap())
diff --git a/datafusion/ffi/src/tests/mod.rs b/datafusion/ffi/src/tests/mod.rs
index 59bcc861d0..357953674a 100644
--- a/datafusion/ffi/src/tests/mod.rs
+++ b/datafusion/ffi/src/tests/mod.rs
@@ -43,6 +43,8 @@ use crate::execution_plan::FFI_ExecutionPlan;
use crate::execution_plan::tests::EmptyExec;
use crate::physical_optimizer::FFI_PhysicalOptimizerRule;
use crate::proto::logical_extension_codec::FFI_LogicalExtensionCodec;
+use crate::proto::physical_extension_codec::FFI_PhysicalExtensionCodec;
+use crate::query_planner::FFI_QueryPlanner;
use crate::table_provider::FFI_TableProvider;
use crate::table_provider_factory::FFI_TableProviderFactory;
use crate::tests::catalog::create_catalog_provider_list;
@@ -50,11 +52,13 @@ use crate::udaf::FFI_AggregateUDF;
use crate::udf::FFI_ScalarUDF;
use crate::udtf::FFI_TableFunction;
use crate::udwf::FFI_WindowUDF;
+use crate::util::FFI_Option;
mod async_provider;
pub mod catalog;
pub mod config;
mod physical_optimizer;
+mod query_planner;
mod sync_provider;
mod table_provider_factory;
mod udf_udaf_udwf;
@@ -117,6 +121,14 @@ pub struct ForeignLibraryModule {
pub create_context_aware_optimizer_rule: extern "C" fn() ->
FFI_PhysicalOptimizerRule,
+ /// Construct a query planner. When `library_a_planner` is provided the
+ /// planner delegates to it, as library C does after library A swaps
planners.
+ pub create_query_planner: extern "C" fn(
+ logical_codec: FFI_LogicalExtensionCodec,
+ physical_codec: FFI_PhysicalExtensionCodec,
+ library_a_planner: FFI_Option<FFI_QueryPlanner>,
+ ) -> FFI_QueryPlanner,
+
pub version: extern "C" fn() -> u64,
/// Create an aggregate UDAF using first_value
@@ -269,6 +281,7 @@ pub extern "C" fn datafusion_ffi_get_module() ->
ForeignLibraryModule {
physical_optimizer::create_physical_optimizer_rule,
create_context_aware_optimizer_rule:
physical_optimizer::create_context_aware_optimizer_rule,
+ create_query_planner: query_planner::create_query_planner,
version: super::version,
create_first_value_udaf: create_ffi_first_value_func,
}
diff --git a/datafusion/ffi/src/tests/query_planner.rs
b/datafusion/ffi/src/tests/query_planner.rs
new file mode 100644
index 0000000000..90d713f8a6
--- /dev/null
+++ b/datafusion/ffi/src/tests/query_planner.rs
@@ -0,0 +1,172 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements. See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership. The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License. You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied. See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+use std::any::Any;
+use std::sync::Arc;
+
+use arrow::datatypes::{DataType, Field, Schema};
+use async_trait::async_trait;
+use datafusion_catalog::default_table_source::source_as_provider;
+use datafusion_common::{Result, exec_err};
+use datafusion_expr::LogicalPlan;
+use datafusion_physical_plan::ExecutionPlan;
+use datafusion_physical_plan::empty::EmptyExec;
+use datafusion_physical_plan::sorts::sort::SortExec;
+use datafusion_physical_plan::union::UnionExec;
+use datafusion_session::{QueryPlanner, Session};
+
+use crate::execution_plan::ForeignExecutionPlan;
+use crate::proto::logical_extension_codec::FFI_LogicalExtensionCodec;
+use crate::proto::physical_extension_codec::FFI_PhysicalExtensionCodec;
+use crate::query_planner::{FFI_QueryPlanner, ForeignQueryPlanner};
+use crate::session::ForeignSession;
+use crate::table_provider::ForeignTableProvider;
+use crate::util::FFI_Option;
+
+#[derive(Debug)]
+struct TestQueryPlanner;
+
+#[async_trait]
+impl QueryPlanner for TestQueryPlanner {
+ async fn create_physical_plan(
+ &self,
+ logical_plan: &LogicalPlan,
+ session: &dyn Session,
+ ) -> Result<Arc<dyn ExecutionPlan>> {
+ if let LogicalPlan::TableScan(scan) = logical_plan {
+ if session.as_any().downcast_ref::<ForeignSession>().is_none() {
+ return exec_err!("library A's session was not foreign to
library C");
+ }
+
+ let provider = source_as_provider(&scan.source)?;
+ if provider.downcast_ref::<ForeignTableProvider>().is_none() {
+ return exec_err!("library B's provider was not foreign to
library C");
+ }
+ let library_b_plan = provider
+ .scan(session, scan.projection.as_ref(), &scan.filters,
scan.fetch)
+ .await?;
+
+ if !library_b_plan.is::<ForeignExecutionPlan>() {
+ return exec_err!("library B's plan unexpectedly downcast as
C-local");
+ }
+
+ let plan = UnionExec::try_new(vec![
+ Arc::clone(&library_b_plan),
+ Arc::clone(&library_b_plan),
+ ])?;
+ if !plan.is::<UnionExec>() {
+ return exec_err!("library C could not downcast its local
UnionExec");
+ }
+ return Ok(plan);
+ }
+
+ let query_planner = session.query_planner();
+ let planner_any: &dyn Any = query_planner.as_ref();
+ if planner_any.downcast_ref::<ForeignQueryPlanner>().is_none() {
+ return exec_err!("query planner did not cross the FFI boundary");
+ }
+ session.optimize(logical_plan)?;
+ if session.physical_optimizers().is_empty() {
+ return exec_err!("physical optimizers did not cross the FFI
boundary");
+ }
+
+ let schema = Arc::new(Schema::new(vec![Field::new("a",
DataType::Int32, true)]));
+ Ok(Arc::new(EmptyExec::new(schema)))
+ }
+}
+
+/// Library C's planner for the planner-swap deployment.
+///
+/// It holds the query planner library A exported *before* A swapped this
planner
+/// into its session, so delegating to it cannot re-enter library C.
+#[derive(Debug)]
+struct SwappedQueryPlanner {
+ library_a_planner: Arc<dyn QueryPlanner + Send + Sync>,
+}
+
+#[async_trait]
+impl QueryPlanner for SwappedQueryPlanner {
+ async fn create_physical_plan(
+ &self,
+ logical_plan: &LogicalPlan,
+ session: &dyn Session,
+ ) -> Result<Arc<dyn ExecutionPlan>> {
+ if session.as_any().downcast_ref::<ForeignSession>().is_none() {
+ return exec_err!("library A's session was not foreign to library
C");
+ }
+
+ // After the swap, the planner installed on library A's session is this
+ // planner, so `session.query_planner()` and
`session.create_physical_plan()`
+ // are both self-references. Assert the hazard instead of triggering
it:
+ // calling either would recurse until the stack is exhausted.
+ let installed = session.query_planner();
+ let installed: &dyn Any = installed.as_ref();
+ if installed.downcast_ref::<Self>().is_none() {
+ return exec_err!(
+ "expected the swapped session to report library C's own
planner"
+ );
+ }
+
+ // Delegate to library A. The result crosses the FFI boundary as
+ // serialized bytes, so library C receives nodes carrying its own local
+ // Rust type identities.
+ let plan = self
+ .library_a_planner
+ .create_physical_plan(logical_plan, session)
+ .await?;
+
+ if plan.is::<ForeignExecutionPlan>() {
+ return exec_err!("library A's plan was opaque to library C");
+ }
+ let Some(sort) = plan.downcast_ref::<SortExec>() else {
+ return exec_err!(
+ "library C could not downcast library A's SortExec; got {}",
+ plan.name()
+ );
+ };
+ // Library B's scan is still foreign to library C. Only a codec
boundary
+ // reconstructs it, and library A's codec hands back an A-local node.
+ if !sort.input().is::<ForeignExecutionPlan>() {
+ return exec_err!("library B's scan unexpectedly downcast as
C-local");
+ }
+
+ Ok(UnionExec::try_new(vec![
+ Arc::clone(&plan),
+ Arc::clone(&plan),
+ ])?)
+ }
+}
+
+/// Creates library C's query planner.
+///
+/// `library_a_planner` is the planner library A exported before swapping this
one
+/// onto its session. When it is absent the planner does its own planning
instead
+/// of delegating.
+pub extern "C" fn create_query_planner(
+ logical_codec: FFI_LogicalExtensionCodec,
+ physical_codec: FFI_PhysicalExtensionCodec,
+ library_a_planner: FFI_Option<FFI_QueryPlanner>,
+) -> FFI_QueryPlanner {
+ let planner: Arc<dyn QueryPlanner + Send + Sync> = match
library_a_planner.as_ref() {
+ Some(library_a_planner) => Arc::new(SwappedQueryPlanner {
+ library_a_planner: library_a_planner.into(),
+ }),
+ None => Arc::new(TestQueryPlanner),
+ };
+
+ FFI_QueryPlanner::new_with_ffi_codecs(planner, logical_codec,
physical_codec)
+}
diff --git a/datafusion/ffi/src/tests/utils.rs
b/datafusion/ffi/src/tests/utils.rs
index b6b50cbce8..3119ab96d4 100644
--- a/datafusion/ffi/src/tests/utils.rs
+++ b/datafusion/ffi/src/tests/utils.rs
@@ -62,14 +62,12 @@ fn find_library() -> Result<PathBuf> {
find_cdylib(deps_dir)
}
-pub fn get_module() -> Result<ForeignLibraryModule> {
+fn load_module(lib_path: &Path) -> Result<ForeignLibraryModule> {
let expected_version = crate::version();
- let lib_path = find_library()?;
-
// Load the library using libloading
let lib = unsafe {
- libloading::Library::new(&lib_path)
+ libloading::Library::new(lib_path)
.map_err(|e| DataFusionError::External(Box::new(e)))?
};
@@ -87,3 +85,40 @@ pub fn get_module() -> Result<ForeignLibraryModule> {
Ok(module)
}
+
+pub fn get_module() -> Result<ForeignLibraryModule> {
+ load_module(&find_library()?)
+}
+
+/// Load an independent copy of the integration-test cdylib.
+///
+/// Copying to a unique path makes the dynamic loader create a separate image
+/// with its own library marker and Rust object graph.
+pub fn get_module_copy(name: &str) -> Result<ForeignLibraryModule> {
+ let source = find_library()?;
+ let file_name = source
+ .file_name()
+ .and_then(|name| name.to_str())
+ .ok_or_else(|| DataFusionError::External("Invalid cdylib
filename".into()))?;
+ // Windows cannot remove a loaded DLL, so use a stable name that bounds the
+ // retained test artifacts to one file per library role.
+ #[cfg(target_os = "windows")]
+ let destination = source.with_file_name(format!("{name}_{file_name}"));
+ #[cfg(not(target_os = "windows"))]
+ let destination =
+ source.with_file_name(format!("{}_{}_{}", std::process::id(), name,
file_name));
+
+ std::fs::copy(&source, &destination)
+ .map_err(|e| DataFusionError::External(Box::new(e)))?;
+ match load_module(&destination) {
+ Ok(module) => {
+ #[cfg(not(target_os = "windows"))]
+ let _ = std::fs::remove_file(destination);
+ Ok(module)
+ }
+ Err(error) => {
+ let _ = std::fs::remove_file(destination);
+ Err(error)
+ }
+ }
+}
diff --git a/datafusion/ffi/src/udtf.rs b/datafusion/ffi/src/udtf.rs
index 0a11102879..fa28519d58 100644
--- a/datafusion/ffi/src/udtf.rs
+++ b/datafusion/ffi/src/udtf.rs
@@ -155,7 +155,7 @@ unsafe extern "C" fn call_with_args_wrapper(
let session = sresult_return!(
session
.as_local()
- .map(Ok::<&(dyn Session + Send + Sync), DataFusionError>)
+ .map(Ok::<&dyn Session, DataFusionError>)
.unwrap_or_else(|| {
foreign_session = Some(ForeignSession::try_from(&session)?);
Ok(foreign_session.as_ref().unwrap())
diff --git a/datafusion/ffi/tests/ffi_query_planner.rs
b/datafusion/ffi/tests/ffi_query_planner.rs
new file mode 100644
index 0000000000..c72b8c3e88
--- /dev/null
+++ b/datafusion/ffi/tests/ffi_query_planner.rs
@@ -0,0 +1,348 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements. See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership. The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License. You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied. See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+mod utils;
+
+#[cfg(feature = "integration-tests")]
+mod tests {
+ use std::sync::{Arc, OnceLock, Weak};
+
+ use arrow::datatypes::SchemaRef;
+ use datafusion::execution::SessionStateBuilder;
+ use datafusion::prelude::SessionContext;
+ use datafusion_catalog::TableProvider;
+ use datafusion_common::{
+ DataFusionError, Result, TableReference, exec_err, not_impl_err,
+ };
+ use datafusion_execution::{TaskContext, TaskContextProvider};
+ use datafusion_expr::logical_plan::Extension;
+ use datafusion_expr::{LogicalPlan, col};
+ use datafusion_ffi::execution::FFI_TaskContextProvider;
+ use datafusion_ffi::execution_plan::ForeignExecutionPlan;
+ use
datafusion_ffi::proto::logical_extension_codec::FFI_LogicalExtensionCodec;
+ use
datafusion_ffi::proto::physical_extension_codec::FFI_PhysicalExtensionCodec;
+ use datafusion_ffi::query_planner::{FFI_QueryPlanner, ForeignQueryPlanner};
+ use datafusion_ffi::table_provider::ForeignTableProvider;
+ use datafusion_ffi::tests::{
+ create_test_schema,
+ utils::{get_module, get_module_copy},
+ };
+ use datafusion_ffi::util::FFI_Option;
+ use datafusion_physical_plan::ExecutionPlan;
+ use datafusion_physical_plan::empty::EmptyExec;
+ use datafusion_physical_plan::sorts::sort::SortExec;
+ use datafusion_physical_plan::union::UnionExec;
+ use datafusion_proto::logical_plan::LogicalExtensionCodec;
+ use datafusion_proto::physical_plan::{
+ DefaultPhysicalExtensionCodec, PhysicalExtensionCodec,
+ PhysicalProtoConverterExtension,
+ };
+ use datafusion_session::QueryPlanner;
+
+ #[tokio::test]
+ async fn test_ffi_query_planner() -> Result<(), DataFusionError> {
+ let module = get_module()?;
+ let (ctx, logical_codec) = crate::utils::ctx_and_codec();
+ let task_ctx_provider = Arc::clone(&ctx) as Arc<dyn
TaskContextProvider>;
+ let task_ctx_provider =
FFI_TaskContextProvider::from(&task_ctx_provider);
+ let physical_codec = FFI_PhysicalExtensionCodec::new(
+ Arc::new(DefaultPhysicalExtensionCodec {}),
+ None,
+ task_ctx_provider,
+ );
+
+ let ffi_planner = (module.create_query_planner)(
+ logical_codec,
+ physical_codec,
+ FFI_Option::None,
+ );
+ let planner: Arc<dyn QueryPlanner + Send + Sync> =
(&ffi_planner).into();
+
+ let any_ref: &dyn std::any::Any = planner.as_ref();
+ assert!(any_ref.downcast_ref::<ForeignQueryPlanner>().is_some());
+
+ let logical_plan =
datafusion_expr::LogicalPlanBuilder::empty(false).build()?;
+ let state = ctx.state();
+ let physical_plan = planner.create_physical_plan(&logical_plan,
&state).await?;
+
+ assert_eq!(physical_plan.name(), "EmptyExec");
+ assert!(physical_plan.is::<EmptyExec>());
+
+ Ok(())
+ }
+
+ /// Test-only codec that preserves library B's table provider while the
logical
+ /// plan crosses between library A and library C.
+ ///
+ /// Encoding writes a fixed identifier and stores a weak reference to the
+ /// provider. Decoding validates the identifier and upgrades that
reference.
+ /// This works because all three test libraries run in one process and
library
+ /// A's session continues to own the provider.
+ ///
+ /// This is not a general serialization format for table providers. A
+ /// cross-process deployment must provide its own codec that either
resolves a
+ /// stable identifier through shared state or reconstructs the provider
from a
+ /// portable, provider-specific description. DataFusion passes the table
+ /// reference, schema, and task context separately to the decoder.
+ #[derive(Debug, Default)]
+ struct LibraryALogicalCodec {
+ library_b_provider: OnceLock<Weak<dyn TableProvider>>,
+ }
+
+ impl LogicalExtensionCodec for LibraryALogicalCodec {
+ fn try_decode(
+ &self,
+ _buf: &[u8],
+ _inputs: &[LogicalPlan],
+ _ctx: &TaskContext,
+ ) -> Result<Extension> {
+ not_impl_err!("logical extension nodes are not used in this test")
+ }
+
+ fn try_encode(&self, _node: &Extension, _buf: &mut Vec<u8>) ->
Result<()> {
+ not_impl_err!("logical extension nodes are not used in this test")
+ }
+
+ fn try_decode_table_provider(
+ &self,
+ buf: &[u8],
+ _table_ref: &TableReference,
+ _schema: SchemaRef,
+ _ctx: &TaskContext,
+ ) -> Result<Arc<dyn TableProvider>> {
+ if buf != b"library-b-provider" {
+ return exec_err!("unexpected library B provider payload");
+ }
+ self.library_b_provider
+ .get()
+ .and_then(Weak::upgrade)
+ .ok_or_else(|| DataFusionError::Plan("missing library B
provider".into()))
+ }
+
+ fn try_encode_table_provider(
+ &self,
+ _table_ref: &TableReference,
+ node: Arc<dyn TableProvider>,
+ buf: &mut Vec<u8>,
+ ) -> Result<()> {
+ self.library_b_provider
+ .get_or_init(|| Arc::downgrade(&node));
+ buf.extend_from_slice(b"library-b-provider");
+ Ok(())
+ }
+ }
+
+ /// Library A's physical codec reconstructs B's opaque foreign plan as an
+ /// A-local test plan when the result returns from library C.
+ ///
+ /// Encoding sees B's node in one of two shapes. When A serializes a plan
it
+ /// built itself, B's scan is a [`ForeignExecutionPlan`]. When library C
+ /// serializes a plan containing a node A previously handed it, the FFI
handle
+ /// unwraps back to its home library, so A is asked to encode the very
+ /// [`EmptyExec`] its own `try_decode` produced.
+ #[derive(Debug)]
+ struct LibraryAPhysicalCodec;
+
+ impl PhysicalExtensionCodec for LibraryAPhysicalCodec {
+ fn try_decode(
+ &self,
+ buf: &[u8],
+ inputs: &[Arc<dyn ExecutionPlan>],
+ _ctx: &TaskContext,
+ _proto_converter: &dyn PhysicalProtoConverterExtension,
+ ) -> Result<Arc<dyn ExecutionPlan>> {
+ if buf != b"library-b-empty-exec" || !inputs.is_empty() {
+ return exec_err!("unexpected library B execution plan
payload");
+ }
+ Ok(Arc::new(EmptyExec::new(create_test_schema())))
+ }
+
+ fn try_encode(
+ &self,
+ node: Arc<dyn ExecutionPlan>,
+ buf: &mut Vec<u8>,
+ _proto_converter: &dyn PhysicalProtoConverterExtension,
+ ) -> Result<()> {
+ if !node.is::<ForeignExecutionPlan>() && !node.is::<EmptyExec>() {
+ return exec_err!(
+ "expected library B's plan to be foreign or A-local; got
{}",
+ node.name()
+ );
+ }
+ buf.extend_from_slice(b"library-b-empty-exec");
+ Ok(())
+ }
+ }
+
+ #[tokio::test]
+ async fn test_three_library_query_planner_restores_type_identity() ->
Result<()> {
+ // Library A: datafusion-python owns the session and codec registry.
+ let state = SessionStateBuilder::new_with_default_features()
+ .with_physical_optimizer_rules(vec![])
+ .build();
+ let ctx = Arc::new(SessionContext::new_with_state(state));
+ let task_ctx_provider = Arc::clone(&ctx) as Arc<dyn
TaskContextProvider>;
+ let ffi_task_ctx_provider =
FFI_TaskContextProvider::from(&task_ctx_provider);
+ let logical_codec = FFI_LogicalExtensionCodec::new(
+ Arc::new(LibraryALogicalCodec::default()),
+ None,
+ ffi_task_ctx_provider.clone(),
+ );
+ let physical_codec = FFI_PhysicalExtensionCodec::new(
+ Arc::new(LibraryAPhysicalCodec),
+ None,
+ ffi_task_ctx_provider,
+ );
+
+ let library_b = get_module_copy("query_planner_library_b")?;
+ let library_c = get_module_copy("query_planner_library_c")?;
+
+ // Library B: reuse the synchronous table provider from the existing
+ // FFI integration-test module.
+ let ffi_provider = (library_b.create_table)(true,
logical_codec.clone());
+ let provider: Arc<dyn TableProvider> = (&ffi_provider).into();
+ assert!(provider.downcast_ref::<ForeignTableProvider>().is_some());
+ ctx.register_table("library_b", provider)?;
+ let logical_plan =
ctx.table("library_b").await?.into_optimized_plan()?;
+
+ // Library C: a foreign query planner sees B's scan result as opaque,
+ // but can downcast its own UnionExec. Its result is serialized rather
+ // than returned as FFI_ExecutionPlan.
+ let ffi_planner = (library_c.create_query_planner)(
+ logical_codec,
+ physical_codec,
+ FFI_Option::None,
+ );
+ let planner: Arc<dyn QueryPlanner + Send + Sync> =
(&ffi_planner).into();
+ let planner_any: &dyn std::any::Any = planner.as_ref();
+ assert!(planner_any.downcast_ref::<ForeignQueryPlanner>().is_some());
+
+ let state = ctx.state();
+ let physical_plan = planner.create_physical_plan(&logical_plan,
&state).await?;
+
+ // Deserialization in A reconstructs the full result as A-local
+ // concrete nodes, including the plans that originated in B.
+ assert!(physical_plan.is::<UnionExec>());
+ assert!(!physical_plan.is::<ForeignExecutionPlan>());
+ let children = physical_plan.children();
+ assert_eq!(children.len(), 2);
+ assert!(children.iter().all(|child| child.is::<EmptyExec>()));
+ assert!(
+ children
+ .iter()
+ .all(|child| !child.is::<ForeignExecutionPlan>())
+ );
+
+ Ok(())
+ }
+
+ /// Exercises the deployment library C actually uses: library A hands its
own
+ /// query planner to C, then installs C's planner on the session it already
+ /// owns. C plans by delegating back to A's captured planner.
+ ///
+ /// This is the case that requires serialized plans in both directions. C
must
+ /// downcast the nodes A produced in order to rewrite them, and A must
downcast
+ /// the nodes C produced in order to run its own passes over the result.
+ #[tokio::test]
+ async fn test_query_planner_swap_round_trips_type_identity() -> Result<()>
{
+ // Library A: datafusion-python owns the session and codec registry.
The
+ // physical optimizer rules are cleared so the assertions below observe
+ // planning alone.
+ let state = SessionStateBuilder::new_with_default_features()
+ .with_physical_optimizer_rules(vec![])
+ .build();
+ let ctx = Arc::new(SessionContext::new_with_state(state));
+ let task_ctx_provider = Arc::clone(&ctx) as Arc<dyn
TaskContextProvider>;
+ let ffi_task_ctx_provider =
FFI_TaskContextProvider::from(&task_ctx_provider);
+ let logical_codec = FFI_LogicalExtensionCodec::new(
+ Arc::new(LibraryALogicalCodec::default()),
+ None,
+ ffi_task_ctx_provider.clone(),
+ );
+ let physical_codec = FFI_PhysicalExtensionCodec::new(
+ Arc::new(LibraryAPhysicalCodec),
+ None,
+ ffi_task_ctx_provider,
+ );
+
+ let library_b = get_module_copy("planner_swap_library_b")?;
+ let library_c = get_module_copy("planner_swap_library_c")?;
+
+ // Library B: a table provider that is foreign to both A and C.
+ let ffi_provider = (library_b.create_table)(true,
logical_codec.clone());
+ let provider: Arc<dyn TableProvider> = (&ffi_provider).into();
+ ctx.register_table("library_b", provider)?;
+
+ // Library A exports its default planner *before* the swap. Fetching it
+ // afterwards through `FFI_SessionRef::query_planner` would hand
library C
+ // its own planner back.
+ let library_a_planner = Arc::clone(ctx.state().query_planner());
+ let ffi_library_a_planner = FFI_QueryPlanner::new_with_ffi_codecs(
+ library_a_planner,
+ logical_codec.clone(),
+ physical_codec.clone(),
+ );
+
+ // Library C: builds its planner around A's planner.
+ let ffi_planner = (library_c.create_query_planner)(
+ logical_codec,
+ physical_codec,
+ FFI_Option::Some(ffi_library_a_planner),
+ );
+ let library_c_planner: Arc<dyn QueryPlanner + Send + Sync> =
+ (&ffi_planner).into();
+ let planner_any: &dyn std::any::Any = library_c_planner.as_ref();
+ assert!(planner_any.downcast_ref::<ForeignQueryPlanner>().is_some());
+
+ // Library A swaps C's planner into the session it already owns.
Mutating
+ // the existing state keeps the `Arc<SessionContext>` identity stable,
so
+ // the task context provider captured by the codecs above stays
current.
+ let state_ref = ctx.state_ref();
+ let swapped =
SessionStateBuilder::new_from_existing(state_ref.read().clone())
+ .with_query_planner(library_c_planner)
+ .build();
+ *state_ref.write() = swapped;
+
+ // A sort keeps a well-known, non-extension node at the root of A's
+ // physical plan. A projection or limit would be pushed into the scan,
+ // leaving only library B's opaque node for C to inspect.
+ let logical_plan = ctx
+ .table("library_b")
+ .await?
+ .sort(vec![col("a").sort(true, true)])?
+ .into_optimized_plan()?;
+
+ // Planning now runs A -> C -> A -> C -> A across three library images.
+ let physical_plan =
ctx.state().create_physical_plan(&logical_plan).await?;
+
+ // Library A reconstructs C's result as A-local concrete nodes,
including
+ // the plan that originated in B.
+ assert!(physical_plan.is::<UnionExec>());
+ assert!(!physical_plan.is::<ForeignExecutionPlan>());
+ let children = physical_plan.children();
+ assert_eq!(children.len(), 2);
+ for child in &children {
+ let sort = child
+ .downcast_ref::<SortExec>()
+ .expect("library A could not downcast the SortExec it
planned");
+ assert!(sort.input().is::<EmptyExec>());
+ assert!(!sort.input().is::<ForeignExecutionPlan>());
+ }
+
+ Ok(())
+ }
+}
diff --git a/docs/source/library-user-guide/upgrading/55.0.0.md
b/docs/source/library-user-guide/upgrading/55.0.0.md
index e8e7f4a68f..2811fb4df2 100644
--- a/docs/source/library-user-guide/upgrading/55.0.0.md
+++ b/docs/source/library-user-guide/upgrading/55.0.0.md
@@ -925,15 +925,28 @@ fn physical_optimizers(&self) -> &[Arc<dyn
PhysicalOptimizerRule + Send + Sync>]
}
```
-`ForeignSession::create_physical_plan` continues to run the complete planning
-pipeline in the library that owns the session. `ForeignSession::query_planner`
-returns `UnsupportedQueryPlanner` until the query planner FFI interface is
-available. FFI wrappers for the individual planner and optimizer interfaces are
-not included in this release.
+`ForeignSession::create_physical_plan` runs the complete planning pipeline in
the
+library that owns the session. `ForeignSession::query_planner`, `optimize`, and
+`physical_optimizers` forward to the owning session across the FFI boundary. A
+foreign query planner can also be installed on a session through the new
+`datafusion_ffi::query_planner::FFI_QueryPlanner`; see that module's
+documentation for how plans and extension codecs cross the boundary.
See [PR #23703](https://github.com/apache/datafusion/pull/23703) for details on
the catalog changes.
+### `FFI_LogicalExtensionCodec::task_ctx_provider` is now private
+
+The `task_ctx_provider` field on
+`datafusion_ffi::proto::logical_extension_codec::FFI_LogicalExtensionCodec` was
+`pub` and is now crate-private, matching `FFI_PhysicalExtensionCodec`.
+
+**Who is affected:**
+
+- Code that read or cloned `FFI_LogicalExtensionCodec::task_ctx_provider`
+ directly. Pass the task context provider to `FFI_LogicalExtensionCodec::new`
+ instead, and keep your own copy if you need it elsewhere.
+
### Unused `async` removed from several public functions
Public functions that were declared `async` but never awaited anything are now
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]