Skip to content

Commit 47aea03

Browse files
committed
Add three-library FFI planner example
AI Disclosure: This code was written in part by an AI agent.:
1 parent 9ba147c commit 47aea03

22 files changed

Lines changed: 943 additions & 165 deletions

File tree

.github/workflows/build.yml

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -186,7 +186,7 @@ jobs:
186186
manylinux: "2_28"
187187

188188
# FFI test wheel only needs to be built once per platform; gate to abi3.
189-
- name: Build FFI test library
189+
- name: Build FFI provider test library
190190
if: matrix.python-tag == 'abi3'
191191
uses: PyO3/maturin-action@v1
192192
with:
@@ -196,6 +196,16 @@ jobs:
196196
args: --out dist
197197
rustup-components: rust-std
198198

199+
- name: Build FFI query planner test library
200+
if: matrix.python-tag == 'abi3'
201+
uses: PyO3/maturin-action@v1
202+
with:
203+
target: x86_64-unknown-linux-gnu
204+
manylinux: "2_28"
205+
working-directory: examples/datafusion-ffi-query-planner-example
206+
args: --out dist
207+
rustup-components: rust-std
208+
199209
- name: Archive wheels
200210
uses: actions/upload-artifact@v7
201211
with:
@@ -207,7 +217,9 @@ jobs:
207217
uses: actions/upload-artifact@v7
208218
with:
209219
name: test-ffi-manylinux-x86_64
210-
path: examples/datafusion-ffi-example/dist/*
220+
path: |
221+
examples/datafusion-ffi-example/dist/*
222+
examples/datafusion-ffi-query-planner-example/dist/*
211223
212224
# ============================================
213225
# Build - Linux ARM64

.github/workflows/test.yml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -121,6 +121,8 @@ jobs:
121121
run: |
122122
cd examples/datafusion-ffi-example
123123
uv run --no-project pytest python/tests/_test*.py
124+
cd ../datafusion-ffi-query-planner-example
125+
uv run --no-project pytest python/tests/_test*.py
124126
125127
- name: Run tpchgen-cli to create 1 Gb dataset
126128
if: matrix.wheel-tag == 'abi3'

Cargo.lock

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

Cargo.toml

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,12 @@ edition = "2024"
2727
rust-version = "1.88"
2828

2929
[workspace]
30-
members = ["crates/core", "crates/util", "examples/datafusion-ffi-example"]
30+
members = [
31+
"crates/core",
32+
"crates/util",
33+
"examples/datafusion-ffi-example",
34+
"examples/datafusion-ffi-query-planner-example",
35+
]
3136
resolver = "3"
3237

3338
[workspace.dependencies]

crates/core/src/context.rs

Lines changed: 91 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -227,9 +227,34 @@ impl PySessionConfig {
227227
}
228228
}
229229

230+
#[derive(Debug)]
231+
struct PlanningTaskContextProvider(Arc<TaskContext>);
232+
233+
impl TaskContextProvider for PlanningTaskContextProvider {
234+
fn task_ctx(&self) -> Arc<TaskContext> {
235+
Arc::clone(&self.0)
236+
}
237+
}
238+
230239
#[derive(Debug, Clone)]
231240
struct PythonQueryPlanner {
232241
planner: FFI_QueryPlanner,
242+
logical_codec: Arc<PythonLogicalCodec>,
243+
physical_codec: Arc<PythonPhysicalCodec>,
244+
}
245+
246+
impl PythonQueryPlanner {
247+
fn with_codecs(
248+
&self,
249+
logical_codec: Arc<PythonLogicalCodec>,
250+
physical_codec: Arc<PythonPhysicalCodec>,
251+
) -> Self {
252+
Self {
253+
planner: self.planner.clone(),
254+
logical_codec,
255+
physical_codec,
256+
}
257+
}
233258
}
234259

235260
#[async_trait]
@@ -240,9 +265,35 @@ impl QueryPlanner for PythonQueryPlanner {
240265
session: &dyn Session,
241266
) -> datafusion::common::Result<Arc<dyn ExecutionPlan>> {
242267
let runtime = get_tokio_runtime().handle().clone();
243-
self.planner
268+
let ctx_provider = Arc::new(PlanningTaskContextProvider(session.task_ctx()));
269+
let (logical_codec, physical_codec) = {
270+
let dyn_ctx_provider: Arc<dyn TaskContextProvider> = ctx_provider.clone();
271+
let logical_codec: Arc<dyn LogicalExtensionCodec> =
272+
Arc::clone(&self.logical_codec) as Arc<dyn LogicalExtensionCodec>;
273+
let physical_codec: Arc<dyn PhysicalExtensionCodec + Send> =
274+
Arc::clone(&self.physical_codec) as Arc<dyn PhysicalExtensionCodec + Send>;
275+
(
276+
FFI_LogicalExtensionCodec::new(
277+
logical_codec,
278+
Some(runtime.clone()),
279+
&dyn_ctx_provider,
280+
),
281+
FFI_PhysicalExtensionCodec::new(
282+
physical_codec,
283+
Some(runtime.clone()),
284+
&dyn_ctx_provider,
285+
),
286+
)
287+
};
288+
289+
let mut planner = self.planner.clone();
290+
planner.logical_codec = logical_codec;
291+
planner.physical_codec = physical_codec;
292+
let result = planner
244293
.create_physical_plan_with_session_runtime(logical_plan, session, Some(runtime))
245-
.await
294+
.await;
295+
drop(ctx_provider);
296+
result
246297
}
247298
}
248299

@@ -1237,10 +1288,12 @@ impl PySessionContext {
12371288
}
12381289

12391290
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 });
1291+
let planner = ffi_query_planner_from_pycapsule(&planner)?;
1292+
let planner = Arc::new(PythonQueryPlanner {
1293+
planner,
1294+
logical_codec: Arc::clone(&self.logical_codec),
1295+
physical_codec: Arc::clone(&self.physical_codec),
1296+
});
12441297
let state = SessionStateBuilder::new_from_existing(self.ctx.state())
12451298
.with_query_planner(planner)
12461299
.build();
@@ -1448,10 +1501,13 @@ impl PySessionContext {
14481501
let inner: Arc<dyn LogicalExtensionCodec> = (&inner_ffi).into();
14491502
let logical_codec = Arc::new(PythonLogicalCodec::new(inner));
14501503

1504+
let physical_codec = Arc::clone(&self.physical_codec);
1505+
let ctx = self
1506+
.ctx_with_query_planner_codecs(Arc::clone(&logical_codec), Arc::clone(&physical_codec));
14511507
Ok(Self {
1452-
ctx: Arc::clone(&self.ctx),
1508+
ctx,
14531509
logical_codec,
1454-
physical_codec: Arc::clone(&self.physical_codec),
1510+
physical_codec,
14551511
})
14561512
}
14571513

@@ -1470,9 +1526,12 @@ impl PySessionContext {
14701526
let inner = physical_codec_from_pycapsule(&codec)?;
14711527
let physical_codec = Arc::new(PythonPhysicalCodec::new(inner));
14721528

1529+
let logical_codec = Arc::clone(&self.logical_codec);
1530+
let ctx = self
1531+
.ctx_with_query_planner_codecs(Arc::clone(&logical_codec), Arc::clone(&physical_codec));
14731532
Ok(Self {
1474-
ctx: Arc::clone(&self.ctx),
1475-
logical_codec: Arc::clone(&self.logical_codec),
1533+
ctx,
1534+
logical_codec,
14761535
physical_codec,
14771536
})
14781537
}
@@ -1486,15 +1545,36 @@ impl PySessionContext {
14861545
PythonPhysicalCodec::new(Arc::clone(self.physical_codec.inner()))
14871546
.with_python_udf_inlining(enabled),
14881547
);
1548+
let ctx = self
1549+
.ctx_with_query_planner_codecs(Arc::clone(&logical_codec), Arc::clone(&physical_codec));
14891550
Self {
1490-
ctx: Arc::clone(&self.ctx),
1551+
ctx,
14911552
logical_codec,
14921553
physical_codec,
14931554
}
14941555
}
14951556
}
14961557

14971558
impl PySessionContext {
1559+
fn ctx_with_query_planner_codecs(
1560+
&self,
1561+
logical_codec: Arc<PythonLogicalCodec>,
1562+
physical_codec: Arc<PythonPhysicalCodec>,
1563+
) -> Arc<SessionContext> {
1564+
let state = self.ctx.state();
1565+
let query_planner = state.query_planner();
1566+
let planner_any: &dyn std::any::Any = query_planner.as_ref();
1567+
let Some(planner) = planner_any.downcast_ref::<PythonQueryPlanner>() else {
1568+
return Arc::clone(&self.ctx);
1569+
};
1570+
1571+
let planner = Arc::new(planner.with_codecs(logical_codec, physical_codec));
1572+
let state = SessionStateBuilder::new_from_existing(self.ctx.state())
1573+
.with_query_planner(planner)
1574+
.build();
1575+
Arc::new(SessionContext::new_with_state(state))
1576+
}
1577+
14981578
async fn _table(&self, name: &str) -> datafusion::common::Result<DataFrame> {
14991579
self.ctx.table(name).await
15001580
}

docs/source/contributor-guide/ffi.md

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -232,6 +232,31 @@ extension that has been written using this approach and the most thoroughly impl
232232
As we continue to expose more of the DataFusion features, we intend to follow this same
233233
design pattern.
234234

235+
## Query Planners Across Multiple Libraries
236+
237+
A query can involve three independent native libraries: `datafusion-python`, a library
238+
that owns table providers or functions, and a library that owns the query planner. The
239+
examples use two separate extension crates so each role has a distinct shared-library
240+
identity:
241+
242+
- [`datafusion-ffi-example`] owns providers, functions, and their codecs.
243+
- [`datafusion-ffi-query-planner-example`] owns the planner and its configuration.
244+
245+
The `SessionContext` owns the codecs used for the exchange and supplies them to the
246+
foreign planner. This lets the planner decode provider-owned objects and lets
247+
`datafusion-python` decode the physical plan returned by the planner. The examples use
248+
process-local tokens to demonstrate ownership; production codecs should serialize
249+
durable metadata instead.
250+
251+
The current Python API has one external logical codec and one external physical codec.
252+
Installing another codec replaces the prior codec rather than composing a registry.
253+
The example therefore has one external codec owner, and the planner uses built-in
254+
physical nodes. Install the provider codecs before the planner where possible.
255+
256+
The current FFI logical codec supports providers and UDFs but not arbitrary custom
257+
`LogicalPlan::Extension` nodes. See both example READMEs for the supported flow and
258+
local build commands.
259+
235260
## Alternative Approach
236261

237262
Suppose you needed to expose some other features of DataFusion and you could not wait
@@ -257,3 +282,5 @@ At the time of this writing, the FFI features are under active development. To s
257282
the latest status, we recommend reviewing the code in the [datafusion-ffi] crate.
258283

259284
[datafusion-ffi]: https://crates.io/crates/datafusion-ffi
285+
[`datafusion-ffi-example`]: https://github.com/apache/datafusion-python/tree/main/examples/datafusion-ffi-example
286+
[`datafusion-ffi-query-planner-example`]: https://github.com/apache/datafusion-python/tree/main/examples/datafusion-ffi-query-planner-example

examples/README.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,15 @@ Here is a direct link to the file used in the examples:
4949
- [Fan out distinct expressions to a multiprocessing pool](./multiprocessing_pickle_expr.py)
5050
- [Distribute expression evaluation across Ray actors](./ray_pickle_expr.py)
5151

52+
### Rust FFI Extensions
53+
54+
- [Table providers, functions, and codecs](./datafusion-ffi-example/)
55+
- [Independent query planner and planner configuration](./datafusion-ffi-query-planner-example/)
56+
57+
These two crates form a three-library interoperability example with
58+
`datafusion-python`. They are separate shared libraries so the tests exercise real FFI
59+
type and codec boundaries rather than same-library Rust downcasts.
60+
5261
### Substrait Support
5362

5463
- [Serialize query plans using Substrait](./substrait.py)

examples/datafusion-ffi-example/Cargo.toml

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,6 @@ 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 }
3837

3938
arrow = { workspace = true }
4039
arrow-array = { workspace = true }
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
# DataFusion Python FFI provider example
2+
3+
This crate is the **provider library** in the three-library query-planning example. It exports table providers, functions, and the logical and physical codecs needed to serialize objects owned by this library. The companion planner is in [`../datafusion-ffi-query-planner-example`](../datafusion-ffi-query-planner-example/).
4+
5+
The example intentionally uses separate `cdylib` crates for these roles:
6+
7+
1. **A — `datafusion-python`:** owns the `SessionContext` and executes the result.
8+
2. **B — this crate:** owns table providers, functions, and provider execution plans.
9+
3. **C — the planner crate:** receives the logical plan and returns a physical plan.
10+
11+
Separate shared libraries guarantee distinct DataFusion library markers. This catches type-identity mistakes that a planner and provider compiled into one shared library would hide.
12+
13+
## Codec behavior
14+
15+
`MyLogicalExtensionCodec` serializes this example's in-memory table providers, and `MyPhysicalExtensionCodec` serializes provider-owned memory scans and opaque FFI wrappers around them. Both use documented, process-local, one-shot token registries. The registries make ownership and callback routing visible without pretending to be a portable format. They assume trusted in-process payloads and consume each token during decoding. A production provider should instead encode durable metadata from which its provider and plans can be reconstructed.
16+
17+
The example codecs do not inspect the callback `TaskContext`. A production codec that depends on session configuration or registered functions must ensure its exported FFI codec is bound to, and retains, the appropriate host `TaskContextProvider`.
18+
19+
The current Python API installs one external logical codec and one external physical codec. It does not yet compose codecs from several independent plugin owners. This example therefore makes the provider library the sole external codec owner; the planner uses built-in physical nodes and receives the provider codecs from the host.
20+
21+
Register both provider codecs before installing the planner:
22+
23+
```python
24+
ctx = ctx.with_logical_extension_codec(provider_logical_codec)
25+
ctx = ctx.with_physical_extension_codec(provider_physical_codec)
26+
ctx = ctx.with_query_planner(planner)
27+
```
28+
29+
Derived contexts also rebind an installed planner when codecs change, but planner-last order is recommended because it states the ownership flow clearly.
30+
31+
Arbitrary custom `LogicalPlan::Extension` nodes are not supported by the current DataFusion FFI logical codec. This example covers foreign table providers, UDFs, and physical execution plans only.

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

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,6 @@ 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;
2726
use crate::scalar_udf::IsNullUDF;
2827
use crate::table_function::MyTableFunction;
2928
use crate::table_provider::MyTableProvider;
@@ -36,7 +35,6 @@ pub(crate) mod config;
3635
pub(crate) mod logical_extension_codec;
3736
pub(crate) mod physical_extension_codec;
3837
pub(crate) mod physical_optimizer;
39-
pub(crate) mod query_planner;
4038
pub(crate) mod scalar_udf;
4139
pub(crate) mod table_function;
4240
pub(crate) mod table_provider;
@@ -60,6 +58,5 @@ fn datafusion_ffi_example(m: &Bound<'_, PyModule>) -> PyResult<()> {
6058
m.add_class::<MyLogicalExtensionCodec>()?;
6159
m.add_class::<MyPhysicalExtensionCodec>()?;
6260
m.add_class::<MyPhysicalOptimizerRule>()?;
63-
m.add_class::<MyQueryPlanner>()?;
6461
Ok(())
6562
}

0 commit comments

Comments
 (0)