Skip to content

Commit 9ba147c

Browse files
committed
Add FFI query planner support
AI Disclosure: This code was written in part by an AI agent.:
1 parent cc2ec5c commit 9ba147c

11 files changed

Lines changed: 344 additions & 50 deletions

File tree

Cargo.lock

Lines changed: 40 additions & 38 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 12 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@ datafusion-functions-aggregate = { version = "54.1.0" }
5050
datafusion-functions-window = { version = "54.1.0" }
5151
datafusion-spark = { version = "54.1.0" }
5252
datafusion-expr = { version = "54.1.0" }
53+
datafusion-session = { version = "54.1.0" }
5354
prost = "0.14.3"
5455
serde_json = "1"
5556
uuid = { version = "1.23" }
@@ -72,13 +73,14 @@ codegen-units = 2
7273
# We cannot publish to crates.io with any patches in the below section. Developers
7374
# must remove any entries in this section before creating a release candidate.
7475
[patch.crates-io]
75-
datafusion = { git = "https://github.com/apache/datafusion", rev = "dbcb5c0f729e9ef6b0ab4c79253fe3b657929f48" }
76-
datafusion-substrait = { git = "https://github.com/apache/datafusion", rev = "dbcb5c0f729e9ef6b0ab4c79253fe3b657929f48" }
77-
datafusion-proto = { git = "https://github.com/apache/datafusion", rev = "dbcb5c0f729e9ef6b0ab4c79253fe3b657929f48" }
78-
datafusion-ffi = { git = "https://github.com/apache/datafusion", rev = "dbcb5c0f729e9ef6b0ab4c79253fe3b657929f48" }
79-
datafusion-catalog = { git = "https://github.com/apache/datafusion", rev = "dbcb5c0f729e9ef6b0ab4c79253fe3b657929f48" }
80-
datafusion-common = { git = "https://github.com/apache/datafusion", rev = "dbcb5c0f729e9ef6b0ab4c79253fe3b657929f48" }
81-
datafusion-functions-aggregate = { git = "https://github.com/apache/datafusion", rev = "dbcb5c0f729e9ef6b0ab4c79253fe3b657929f48" }
82-
datafusion-functions-window = { git = "https://github.com/apache/datafusion", rev = "dbcb5c0f729e9ef6b0ab4c79253fe3b657929f48" }
83-
datafusion-spark = { git = "https://github.com/apache/datafusion", rev = "dbcb5c0f729e9ef6b0ab4c79253fe3b657929f48" }
84-
datafusion-expr = { git = "https://github.com/apache/datafusion", rev = "dbcb5c0f729e9ef6b0ab4c79253fe3b657929f48" }
76+
datafusion = { git = "https://github.com/timsaucer/datafusion", rev = "b18d41d81089ab95d824b658cd5232f9c1a695ed" }
77+
datafusion-substrait = { git = "https://github.com/timsaucer/datafusion", rev = "b18d41d81089ab95d824b658cd5232f9c1a695ed" }
78+
datafusion-proto = { git = "https://github.com/timsaucer/datafusion", rev = "b18d41d81089ab95d824b658cd5232f9c1a695ed" }
79+
datafusion-ffi = { git = "https://github.com/timsaucer/datafusion", rev = "b18d41d81089ab95d824b658cd5232f9c1a695ed" }
80+
datafusion-catalog = { git = "https://github.com/timsaucer/datafusion", rev = "b18d41d81089ab95d824b658cd5232f9c1a695ed" }
81+
datafusion-common = { git = "https://github.com/timsaucer/datafusion", rev = "b18d41d81089ab95d824b658cd5232f9c1a695ed" }
82+
datafusion-functions-aggregate = { git = "https://github.com/timsaucer/datafusion", rev = "b18d41d81089ab95d824b658cd5232f9c1a695ed" }
83+
datafusion-functions-window = { git = "https://github.com/timsaucer/datafusion", rev = "b18d41d81089ab95d824b658cd5232f9c1a695ed" }
84+
datafusion-spark = { git = "https://github.com/timsaucer/datafusion", rev = "b18d41d81089ab95d824b658cd5232f9c1a695ed" }
85+
datafusion-expr = { git = "https://github.com/timsaucer/datafusion", rev = "b18d41d81089ab95d824b658cd5232f9c1a695ed" }
86+
datafusion-session = { git = "https://github.com/timsaucer/datafusion", rev = "b18d41d81089ab95d824b658cd5232f9c1a695ed" }

crates/core/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@ datafusion-substrait = { workspace = true, optional = true }
5454
datafusion-proto = { workspace = true }
5555
datafusion-ffi = { workspace = true }
5656
datafusion-spark = { workspace = true, features = ["core"] }
57+
datafusion-session = { workspace = true }
5758
prost = { workspace = true } # keep in line with `datafusion-substrait`
5859
serde_json = { workspace = true }
5960
uuid = { workspace = true, features = ["v4"] }

crates/core/src/context.rs

Lines changed: 57 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ use std::sync::Arc;
2424
use arrow::array::RecordBatchReader;
2525
use arrow::ffi_stream::ArrowArrayStreamReader;
2626
use arrow::pyarrow::FromPyArrow;
27+
use async_trait::async_trait;
2728
use datafusion::arrow::datatypes::{DataType, Schema, SchemaRef};
2829
use datafusion::arrow::pyarrow::PyArrowType;
2930
use datafusion::arrow::record_batch::RecordBatch;
@@ -36,14 +37,16 @@ use datafusion::datasource::listing::{
3637
};
3738
use datafusion::datasource::{MemTable, TableProvider};
3839
use datafusion::execution::context::{
39-
DataFilePaths, SQLOptions, SessionConfig, SessionContext, TaskContext,
40+
DataFilePaths, QueryPlanner, SQLOptions, SessionConfig, SessionContext, TaskContext,
4041
};
4142
use datafusion::execution::disk_manager::DiskManagerMode;
4243
use datafusion::execution::memory_pool::{FairSpillPool, GreedyMemoryPool, UnboundedMemoryPool};
4344
use datafusion::execution::options::{ArrowReadOptions, ReadOptions};
4445
use datafusion::execution::runtime_env::RuntimeEnvBuilder;
4546
use datafusion::execution::session_state::SessionStateBuilder;
4647
use datafusion::execution::{FunctionRegistry, TaskContextProvider};
48+
use datafusion::logical_expr::LogicalPlan;
49+
use datafusion::physical_plan::ExecutionPlan;
4750
use datafusion::prelude::{
4851
AvroReadOptions, CsvReadOptions, DataFrame, JsonReadOptions, ParquetReadOptions,
4952
};
@@ -53,15 +56,18 @@ use datafusion_ffi::config::extension_options::FFI_ExtensionOptions;
5356
use datafusion_ffi::execution::FFI_TaskContextProvider;
5457
use datafusion_ffi::proto::logical_extension_codec::FFI_LogicalExtensionCodec;
5558
use datafusion_ffi::proto::physical_extension_codec::FFI_PhysicalExtensionCodec;
59+
use datafusion_ffi::query_planner::FFI_QueryPlanner;
5660
use datafusion_ffi::table_provider_factory::FFI_TableProviderFactory;
5761
use datafusion_proto::logical_plan::LogicalExtensionCodec;
5862
use datafusion_proto::physical_plan::PhysicalExtensionCodec;
5963
use datafusion_python_util::{
6064
create_logical_extension_capsule, create_physical_extension_capsule,
61-
ffi_logical_codec_from_pycapsule, get_global_ctx, get_tokio_runtime,
65+
create_query_planner_capsule, ffi_logical_codec_from_pycapsule,
66+
ffi_query_planner_from_pycapsule, get_global_ctx, get_tokio_runtime,
6267
physical_codec_from_pycapsule, physical_optimizer_rule_from_pycapsule, spawn_future,
6368
wait_for_future,
6469
};
70+
use datafusion_session::Session;
6571
use object_store::ObjectStore;
6672
use pyo3::IntoPyObjectExt;
6773
use pyo3::exceptions::{PyKeyError, PyRuntimeError, PyValueError};
@@ -221,6 +227,25 @@ impl PySessionConfig {
221227
}
222228
}
223229

230+
#[derive(Debug, Clone)]
231+
struct PythonQueryPlanner {
232+
planner: FFI_QueryPlanner,
233+
}
234+
235+
#[async_trait]
236+
impl QueryPlanner for PythonQueryPlanner {
237+
async fn create_physical_plan(
238+
&self,
239+
logical_plan: &LogicalPlan,
240+
session: &dyn Session,
241+
) -> datafusion::common::Result<Arc<dyn ExecutionPlan>> {
242+
let runtime = get_tokio_runtime().handle().clone();
243+
self.planner
244+
.create_physical_plan_with_session_runtime(logical_plan, session, Some(runtime))
245+
.await
246+
}
247+
}
248+
224249
/// Runtime options for a SessionContext
225250
#[pyclass(
226251
from_py_object,
@@ -1211,6 +1236,23 @@ impl PySessionContext {
12111236
Ok(())
12121237
}
12131238

1239+
pub fn with_query_planner(&self, planner: Bound<'_, PyAny>) -> PyDataFusionResult<Self> {
1240+
let mut planner = ffi_query_planner_from_pycapsule(&planner)?;
1241+
planner.logical_codec = self.ffi_logical_codec().as_ref().clone();
1242+
planner.physical_codec = self.ffi_physical_codec().as_ref().clone();
1243+
let planner = Arc::new(PythonQueryPlanner { planner });
1244+
let state = SessionStateBuilder::new_from_existing(self.ctx.state())
1245+
.with_query_planner(planner)
1246+
.build();
1247+
let ctx = Arc::new(SessionContext::new_with_state(state));
1248+
1249+
Ok(Self {
1250+
ctx,
1251+
logical_codec: Arc::clone(&self.logical_codec),
1252+
physical_codec: Arc::clone(&self.physical_codec),
1253+
})
1254+
}
1255+
12141256
pub fn table_provider(&self, name: &str, py: Python) -> PyResult<PyTable> {
12151257
let provider = wait_for_future(py, self.ctx.table_provider(name))
12161258
// Outer error: runtime/async failure
@@ -1385,6 +1427,19 @@ impl PySessionContext {
13851427
create_logical_extension_capsule(py, ffi.as_ref())
13861428
}
13871429

1430+
pub fn __datafusion_query_planner__<'py>(
1431+
&self,
1432+
py: Python<'py>,
1433+
) -> PyResult<Bound<'py, PyCapsule>> {
1434+
let planner = Arc::clone(self.ctx.state().query_planner());
1435+
let ffi = FFI_QueryPlanner::new_with_ffi_codecs(
1436+
planner,
1437+
self.ffi_logical_codec().as_ref().clone(),
1438+
self.ffi_physical_codec().as_ref().clone(),
1439+
);
1440+
create_query_planner_capsule(py, &ffi)
1441+
}
1442+
13881443
pub fn with_logical_extension_codec<'py>(
13891444
&self,
13901445
codec: Bound<'py, PyAny>,

crates/util/src/lib.rs

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ use datafusion_ffi::execution::FFI_TaskContextProvider;
2929
use datafusion_ffi::physical_optimizer::FFI_PhysicalOptimizerRule;
3030
use datafusion_ffi::proto::logical_extension_codec::FFI_LogicalExtensionCodec;
3131
use datafusion_ffi::proto::physical_extension_codec::FFI_PhysicalExtensionCodec;
32+
use datafusion_ffi::query_planner::FFI_QueryPlanner;
3233
use datafusion_ffi::table_provider::FFI_TableProvider;
3334
use datafusion_proto::physical_plan::PhysicalExtensionCodec;
3435
use pyo3::exceptions::{PyImportError, PyTypeError, PyValueError};
@@ -231,6 +232,38 @@ pub fn ffi_logical_codec_from_pycapsule(obj: Bound<PyAny>) -> PyResult<FFI_Logic
231232
Ok(codec.clone())
232233
}
233234

235+
pub fn create_query_planner_capsule<'py>(
236+
py: Python<'py>,
237+
planner: &FFI_QueryPlanner,
238+
) -> PyResult<Bound<'py, PyCapsule>> {
239+
PyCapsule::new_with_value(py, planner.clone(), cr"datafusion_query_planner")
240+
}
241+
242+
pub fn ffi_query_planner_from_pycapsule(obj: &Bound<PyAny>) -> PyResult<FFI_QueryPlanner> {
243+
let attr_name = "__datafusion_query_planner__";
244+
let capsule = if obj.hasattr(attr_name)? {
245+
obj.getattr(attr_name)?.call0()?
246+
} else {
247+
obj.clone()
248+
};
249+
250+
let capsule = capsule.cast::<PyCapsule>()?;
251+
validate_pycapsule(capsule, "datafusion_query_planner")?;
252+
let data: NonNull<FFI_QueryPlanner> = capsule
253+
.pointer_checked(Some(c"datafusion_query_planner"))?
254+
.cast();
255+
let planner = unsafe { data.as_ref() };
256+
let planner_version = unsafe { (planner.version)() };
257+
let expected_version = datafusion_ffi::version();
258+
if planner_version != expected_version {
259+
return Err(PyImportError::new_err(format!(
260+
"Incompatible DataFusion query planner version {planner_version}; expected major version {expected_version}."
261+
)));
262+
}
263+
264+
Ok(planner.clone())
265+
}
266+
234267
pub fn create_physical_extension_capsule<'py>(
235268
py: Python<'py>,
236269
codec: &FFI_PhysicalExtensionCodec,

examples/datafusion-ffi-example/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ datafusion-functions-window = { workspace = true }
3434
datafusion-expr = { workspace = true }
3535
datafusion-ffi = { workspace = true }
3636
datafusion-proto = { workspace = true }
37+
datafusion-session = { workspace = true }
3738

3839
arrow = { workspace = true }
3940
arrow-array = { workspace = true }
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
# Licensed to the Apache Software Foundation (ASF) under one
2+
# or more contributor license agreements. See the NOTICE file
3+
# distributed with this work for additional information
4+
# regarding copyright ownership. The ASF licenses this file
5+
# to you under the Apache License, Version 2.0 (the
6+
# "License"); you may not use this file except in compliance
7+
# with the License. You may obtain a copy of the License at
8+
#
9+
# http://www.apache.org/licenses/LICENSE-2.0
10+
#
11+
# Unless required by applicable law or agreed to in writing,
12+
# software distributed under the License is distributed on an
13+
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
14+
# KIND, either express or implied. See the License for the
15+
# specific language governing permissions and limitations
16+
# under the License.
17+
18+
from __future__ import annotations
19+
20+
import pytest
21+
from datafusion import SessionContext
22+
from datafusion_ffi_example import MyQueryPlanner
23+
24+
25+
@pytest.mark.parametrize("raw_capsule", [False, True])
26+
def test_ffi_query_planner_runs_during_planning(raw_capsule: bool):
27+
"""A query planner imported from another library creates the physical plan."""
28+
planner = MyQueryPlanner()
29+
exported_planner = (
30+
planner.__datafusion_query_planner__() if raw_capsule else planner
31+
)
32+
ctx = SessionContext().with_query_planner(exported_planner)
33+
34+
before = planner.plan_calls()
35+
result = ctx.sql("SELECT 1 AS value").collect()
36+
after = planner.plan_calls()
37+
38+
assert after > before
39+
assert result == []

examples/datafusion-ffi-example/src/lib.rs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ use crate::config::MyConfig;
2323
use crate::logical_extension_codec::MyLogicalExtensionCodec;
2424
use crate::physical_extension_codec::MyPhysicalExtensionCodec;
2525
use crate::physical_optimizer::MyPhysicalOptimizerRule;
26+
use crate::query_planner::MyQueryPlanner;
2627
use crate::scalar_udf::IsNullUDF;
2728
use crate::table_function::MyTableFunction;
2829
use crate::table_provider::MyTableProvider;
@@ -35,6 +36,7 @@ pub(crate) mod config;
3536
pub(crate) mod logical_extension_codec;
3637
pub(crate) mod physical_extension_codec;
3738
pub(crate) mod physical_optimizer;
39+
pub(crate) mod query_planner;
3840
pub(crate) mod scalar_udf;
3941
pub(crate) mod table_function;
4042
pub(crate) mod table_provider;
@@ -58,5 +60,6 @@ fn datafusion_ffi_example(m: &Bound<'_, PyModule>) -> PyResult<()> {
5860
m.add_class::<MyLogicalExtensionCodec>()?;
5961
m.add_class::<MyPhysicalExtensionCodec>()?;
6062
m.add_class::<MyPhysicalOptimizerRule>()?;
63+
m.add_class::<MyQueryPlanner>()?;
6164
Ok(())
6265
}
Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
// Licensed to the Apache Software Foundation (ASF) under one
2+
// or more contributor license agreements. See the NOTICE file
3+
// distributed with this work for additional information
4+
// regarding copyright ownership. The ASF licenses this file
5+
// to you under the Apache License, Version 2.0 (the
6+
// "License"); you may not use this file except in compliance
7+
// with the License. You may obtain a copy of the License at
8+
//
9+
// http://www.apache.org/licenses/LICENSE-2.0
10+
//
11+
// Unless required by applicable law or agreed to in writing,
12+
// software distributed under the License is distributed on an
13+
// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
14+
// KIND, either express or implied. See the License for the
15+
// specific language governing permissions and limitations
16+
// under the License.
17+
18+
use std::sync::Arc;
19+
use std::sync::atomic::{AtomicUsize, Ordering};
20+
21+
use arrow::datatypes::{DataType, Field, Schema};
22+
use async_trait::async_trait;
23+
use datafusion::execution::TaskContextProvider;
24+
use datafusion::execution::context::{QueryPlanner, SessionContext};
25+
use datafusion::logical_expr::LogicalPlan;
26+
use datafusion::physical_plan::ExecutionPlan;
27+
use datafusion::physical_plan::empty::EmptyExec;
28+
use datafusion_ffi::query_planner::FFI_QueryPlanner;
29+
use datafusion_python_util::get_tokio_runtime;
30+
use datafusion_session::Session;
31+
use pyo3::prelude::*;
32+
use pyo3::types::PyCapsule;
33+
34+
#[derive(Debug)]
35+
struct CountingQueryPlanner {
36+
plan_calls: Arc<AtomicUsize>,
37+
}
38+
39+
#[async_trait]
40+
impl QueryPlanner for CountingQueryPlanner {
41+
async fn create_physical_plan(
42+
&self,
43+
_logical_plan: &LogicalPlan,
44+
_session: &dyn Session,
45+
) -> datafusion::common::Result<Arc<dyn ExecutionPlan>> {
46+
self.plan_calls.fetch_add(1, Ordering::SeqCst);
47+
let schema = Arc::new(Schema::new(vec![Field::new(
48+
"value",
49+
DataType::Int64,
50+
false,
51+
)]));
52+
Ok(Arc::new(EmptyExec::new(schema)))
53+
}
54+
}
55+
56+
/// Python-visible query planner used to test planning across a real FFI boundary.
57+
#[pyclass(
58+
from_py_object,
59+
name = "MyQueryPlanner",
60+
module = "datafusion_ffi_example",
61+
subclass
62+
)]
63+
#[derive(Debug, Default, Clone)]
64+
pub(crate) struct MyQueryPlanner {
65+
plan_calls: Arc<AtomicUsize>,
66+
}
67+
68+
#[pymethods]
69+
impl MyQueryPlanner {
70+
#[new]
71+
fn new() -> Self {
72+
Self::default()
73+
}
74+
75+
fn plan_calls(&self) -> usize {
76+
self.plan_calls.load(Ordering::SeqCst)
77+
}
78+
79+
fn __datafusion_query_planner__<'py>(
80+
&self,
81+
py: Python<'py>,
82+
) -> PyResult<Bound<'py, PyCapsule>> {
83+
let planner: Arc<dyn QueryPlanner + Send + Sync> = Arc::new(CountingQueryPlanner {
84+
plan_calls: Arc::clone(&self.plan_calls),
85+
});
86+
let runtime = get_tokio_runtime().handle().clone();
87+
let ctx_provider = Arc::new(SessionContext::new()) as Arc<dyn TaskContextProvider>;
88+
let ffi = FFI_QueryPlanner::new(planner, Some(runtime), &ctx_provider, None, None);
89+
90+
PyCapsule::new_with_value(py, ffi, cr"datafusion_query_planner")
91+
}
92+
}

0 commit comments

Comments
 (0)