blob: b9f2f541c2e2013c1fecd89a0433b3fb564f5438 [file] [view]
<!---
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)=
# Python Extensions
The DataFusion in Python project is designed to allow users to extend its functionality in a few core
areas. Ideally many users would like to package their extensions as a Python package and easily
integrate that package with this project. This page serves to describe some of the challenges we face
when doing these integrations and the approach our project uses.
## The Primary Issue
Suppose you wish to use DataFusion and you have a custom data source that can produce tables that
can then be queried against, similar to how you can register a {ref}`CSV <io_csv>` or
{ref}`Parquet <io_parquet>` file. In DataFusion terminology, you likely want to implement a
{ref}`Custom Table Provider <io_custom_table_provider>`. In an effort to make your data source
as performant as possible and to utilize the features of DataFusion, you may decide to write
your source in Rust and then expose it through [PyO3](https://pyo3.rs) as a Python library.
At first glance, it may appear the best way to do this is to add the `datafusion-python`
crate as a dependency, provide a `PyTable`, and then to register it with the
`SessionContext`. Unfortunately, this will not work.
When you produce your code as a Python library and it needs to interact with the DataFusion
library, at the lowest level they communicate through an Application Binary Interface (ABI).
The acronym sounds similar to API (Application Programming Interface), but it is distinctly
different.
The ABI sets the standard for how these libraries can share data and functions between each
other. One of the key differences between Rust and other programming languages is that Rust
does not have a stable ABI. What this means in practice is that if you compile a Rust library
with one version of the `rustc` compiler and I compile another library to interface with it
but I use a different version of the compiler, there is no guarantee the interface will be
the same.
In practice, this means that a Python library built with `datafusion-python` as a Rust
dependency will generally **not** be compatible with the DataFusion Python package, even
if they reference the same version of `datafusion-python`. If you attempt to do this, it may
work on your local computer if you have built both packages with the same optimizations.
This can sometimes lead to a false expectation that the code will work, but it frequently
breaks the moment you try to use your package against the released packages.
You can find more information about the Rust ABI in their
[online documentation](https://doc.rust-lang.org/reference/abi.html).
## The FFI Approach
Rust supports interacting with other programming languages through it's Foreign Function
Interface (FFI). The advantage of using the FFI is that it enables you to write data structures
and functions that have a stable ABI. The allows you to use Rust code with C, Python, and
other languages. In fact, the [PyO3](https://pyo3.rs) library uses the FFI to share data
and functions between Python and Rust.
The approach we are taking in the DataFusion in Python project is to incrementally expose
more portions of the DataFusion project via FFI interfaces. This allows users to write Rust
code that does **not** require the `datafusion-python` crate as a dependency, expose their
code in Python via PyO3, and have it interact with the DataFusion Python package.
Early adopters of this approach include [delta-rs](https://delta-io.github.io/delta-rs/)
who has adapted their Table Provider for use in `` `datafusion-python` `` with only a few lines
of code. Also, the DataFusion Python project uses the existing definitions from
[Apache Arrow CStream Interface](https://arrow.apache.org/docs/format/CStreamInterface.html)
to support importing **and** exporting tables. Any Python package that supports reading
the Arrow C Stream interface can work with DataFusion Python out of the box! You can read
more about working with Arrow sources in the {ref}`Data Sources <user_guide_data_sources>`
page.
To learn more about the Foreign Function Interface in Rust, the
[Rustonomicon](https://doc.rust-lang.org/nomicon/ffi.html) is a good resource.
## Inspiration from Arrow
DataFusion is built upon [Apache Arrow](https://arrow.apache.org/). The canonical Python
Arrow implementation, [pyarrow](https://arrow.apache.org/docs/python/index.html) provides
an excellent way to share Arrow data between Python projects without performing any copy
operations on the data. They do this by using a well defined set of interfaces. You can
find the details about their stream interface
[here](https://arrow.apache.org/docs/format/CStreamInterface.html). The
[Rust Arrow Implementation](https://github.com/apache/arrow-rs) also supports these
`C` style definitions via the Foreign Function Interface.
In addition to using these interfaces to transfer Arrow data between libraries, `pyarrow`
goes one step further to make sharing the interfaces easier in Python. They do this
by exposing PyCapsules that contain the expected functionality.
You can learn more about PyCapsules from the official
[Python online documentation](https://docs.python.org/3/c-api/capsule.html). PyCapsules
have excellent support in PyO3 already. The
[PyO3 online documentation](https://pyo3.rs/main/doc/pyo3/types/struct.pycapsule) is a good source
for more details on using PyCapsules in Rust.
Two lessons we leverage from the Arrow project in DataFusion Python are:
- We reuse the existing Arrow FFI functionality wherever possible.
- We expose PyCapsules that contain a FFI stable struct.
## Implementation Details
The bulk of the code necessary to perform our FFI operations is in the upstream
[DataFusion](https://datafusion.apache.org/) core repository. You can review the code and
documentation in the [datafusion-ffi] crate.
Our FFI implementation is narrowly focused at sharing data and functions with Rust backed
libraries. This allows us to use the [abi_stable crate](https://crates.io/crates/abi_stable).
This is an excellent crate that allows for easy conversion between Rust native types
and FFI-safe alternatives. For example, if you needed to pass a `Vec<String>` via FFI,
you can simply convert it to a `RVec<RString>` in an intuitive manner. It also supports
features like `RResult` and `ROption` that do not have an obvious translation to a
C equivalent.
The [datafusion-ffi] crate has been designed to make it easy to convert from DataFusion
traits into their FFI counterparts. For example, if you have defined a custom
[TableProvider](https://docs.rs/datafusion/45.0.0/datafusion/catalog/trait.TableProvider.html)
and you want to create a sharable FFI counterpart, you could write:
```rust
let my_provider = MyTableProvider::default();
let ffi_provider = FFI_TableProvider::new(Arc::new(my_provider), false, None);
```
(ffi_pyclass_mutability)=
## PyO3 class mutability guidelines
PyO3 bindings should present immutable wrappers whenever a struct stores shared or
interior-mutable state. In practice this means that any `#[pyclass]` containing an
`Arc<RwLock<_>>` or similar synchronized primitive must opt into `#[pyclass(frozen)]`
unless there is a compelling reason not to.
The execution context illustrates the preferred pattern. `PySessionContext` in
{file}`src/context.rs` stays frozen even though it shares mutable state internally via
`SessionContext`. This ensures PyO3 tracks borrows correctly while Python-facing APIs
clone the inner `SessionContext` or return new wrappers instead of mutating the
existing instance in place:
```rust
#[pyclass(from_py_object, frozen, name = "SessionContext", module = "datafusion", subclass)]
#[derive(Clone)]
pub struct PySessionContext {
pub ctx: SessionContext,
}
```
Occasionally a type must remain mutable—for example when PyO3 attribute setters need to
update fields directly. In these rare cases add an inline justification so reviewers and
future contributors understand why `frozen` is unsafe to enable. `DataTypeMap` in
{file}`src/common/data_type.rs` includes such a comment because PyO3 still needs to track
field updates:
```rust
// TODO: This looks like this needs pyo3 tracking so leaving unfrozen for now
#[derive(Debug, Clone)]
#[pyclass(from_py_object, name = "DataTypeMap", module = "datafusion.common", subclass)]
pub struct DataTypeMap {
#[pyo3(get, set)]
pub arrow_type: PyDataType,
#[pyo3(get, set)]
pub python_type: PythonType,
#[pyo3(get, set)]
pub sql_type: SqlType,
}
```
When reviewers encounter a mutable `#[pyclass]` without a comment, they should request
an explanation or ask that `frozen` be added. Keeping these wrappers frozen by default
helps avoid subtle bugs stemming from PyO3's interior mutability tracking.
If you were interfacing with a library that provided the above `FFI_TableProvider` and
you needed to turn it back into an `TableProvider`, you can turn it into a
`ForeignTableProvider` with implements the `TableProvider` trait.
```rust
let foreign_provider: ForeignTableProvider = ffi_provider.into();
```
If you review the code in [datafusion-ffi] you will find that each of the traits we share
across the boundary has two portions, one with a `FFI_` prefix and one with a `Foreign`
prefix. This is used to distinguish which side of the FFI boundary that struct is
designed to be used on. The structures with the `FFI_` prefix are to be used on the
**provider** of the structure. In the example we're showing, this means the code that has
written the underlying `TableProvider` implementation to access your custom data source.
The structures with the `Foreign` prefix are to be used by the receiver. In this case,
it is the `datafusion-python` library.
In order to share these FFI structures, we need to wrap them in some kind of Python object
that can be used to interface from one package to another. As described in the above
section on our inspiration from Arrow, we use `PyCapsule`. We can create a `PyCapsule`
for our provider thusly:
```rust
let name = CString::new("datafusion_table_provider")?;
let my_capsule = PyCapsule::new_bound(py, provider, Some(name))?;
```
On the receiving side, turn this pycapsule object into the `FFI_TableProvider`, which
can then be turned into a `ForeignTableProvider` the associated code is:
```rust
let capsule = capsule.cast::<PyCapsule>()?;
let data: NonNull<FFI_TableProvider> = capsule
.pointer_checked(Some(name))?
.cast();
let codec = unsafe { data.as_ref() };
```
By convention the `datafusion-python` library expects a Python object that has a
`TableProvider` PyCapsule to have this capsule accessible by calling a function named
`__datafusion_table_provider__`. You can see a complete working example of how to
share a `TableProvider` from one python library to DataFusion Python in the
[repository examples folder](https://github.com/apache/datafusion-python/tree/main/examples/datafusion-ffi-example).
This section has been written using `TableProvider` as an example. It is the first
extension that has been written using this approach and the most thoroughly implemented.
As we continue to expose more of the DataFusion features, we intend to follow this same
design pattern.
## Query Planners Across Multiple Libraries
A query can involve three independent native libraries: `datafusion-python`, a library
that owns table providers or functions, and a library that owns the query planner. The
examples use two separate extension crates so each role has a distinct shared-library
identity:
- [`datafusion-ffi-example`] owns providers, functions, and their codecs.
- [`datafusion-ffi-query-planner-example`] owns the planner and its configuration.
The `SessionContext` owns the codecs used for the exchange and supplies them to the
foreign planner. This lets the planner decode provider-owned objects and lets
`datafusion-python` decode the physical plan returned by the planner. The examples use
process-local tokens to demonstrate ownership; production codecs should serialize
durable metadata instead.
### Composable codecs
Extension codecs compose. Each call to `with_logical_extension_codec` or
`with_physical_extension_codec` appends the codec to the session's codec chain
rather than replacing prior codecs.
**Nothing is asked of the codec itself.** Implement `LogicalExtensionCodec` or
`PhysicalExtensionCodec` exactly as you would for a session that installs only
yours. When your codec writes bytes into a serialized plan, datafusion-python
records which codec wrote them, and strips that record off again before handing the
bytes back. So your codec receives, byte for byte, the payload it wrote, and is
never offered a payload another codec wrote.
A codec that also ships to hosts which dispatch differently may still want its own
guard against foreign payloads. Keeping one is fine; it is simply not needed for the
datafusion-python path.
That record is the codec's **id**: a short string stored inside the plan, naming the
codec that wrote each payload. Because plans are decoded in another process — or
another program — the id has to name the same codec there as it did where the plan
was written.
Ids are assigned for you. A codec's id is normally its exporting class's import
path, such as `my_library.Codec`, which is what you will see in
`logical_extension_codec_ids()` and in decode errors. You choose one yourself in
three cases:
- **Two instances of one class.** Both get the same id, so the second install
raises `ValueError`. Pass `codec_id=` to tell them apart.
- **A bare `PyCapsule`.** A capsule has no class to take a name from. Installed
through `with_extensions`, it is named after the extension that contributed it —
an extension is a plain object, so its import path is library-owned and just as
stable across processes as a codec class's. Installed directly through
`with_logical_extension_codec` or `with_physical_extension_codec` there is nothing
to fall back on, so it gets an id private to the session that installed it; plans
it encodes fail with a clear error on any other session rather than being decoded
by the wrong codec. Pass `codec_id=` if those plans have to cross sessions.
One extension contributing two bare capsules of the same kind is refused, because
both resolve to that one extension's id. Numbering them by position would be an id
another library can mint the same value from, and would break stored plans the
first time the extension reordered what it returns — so name one of them by
wrapping it in an object declaring `__datafusion_codec_id__`.
- **A class you intend to rename.** The id follows the class name, so renaming stops
older plans from decoding. Declare `__datafusion_codec_id__` on the exporting
object to pin an id that survives the rename.
`SessionContext.logical_extension_codec_ids()` and its physical counterpart list the
ids installed on a session, which is also what a decode failure names.
Installing one context's codec stack on another session composes the two sessions
rather than copying codecs out of one: the imported codecs resolve their task context
against the original and stop working when it is dropped — see
[One session, one `Arc<SessionContext>`](#one-session-one-arcsessioncontext). Pass
the context itself rather than the capsule it exports, so its codecs get an id that
other sessions can decode.
Because decoding keys off the id rather than install position, registration order
between independent libraries does not affect decoding at all. It is visible only
on encoding, where codecs are consulted in install order and the first to claim an
object wins — so installing a library can claim objects nothing else claimed, but
never takes over an object an earlier codec was already encoding. Two libraries
that each own tables, functions, and a planner register like this:
```python
ctx = SessionContext(config)
# Codecs from both libraries. Order between libraries does not matter.
ctx = ctx.with_logical_extension_codec(lib_a.codec())
ctx = ctx.with_logical_extension_codec(lib_b.codec())
ctx = ctx.with_physical_extension_codec(lib_a.physical_codec())
ctx = ctx.with_physical_extension_codec(lib_b.physical_codec())
# A session holds one planner, so layering is explicit delegation. Install the
# codecs first: the fallback captured here keeps the codecs it was exported
# with. See "Rebinding a planner's codecs is one level deep" below.
ctx.set_query_planner(lib_a.Planner())
ctx.set_query_planner(lib_b.Planner(fallback=ctx.__datafusion_query_planner__()))
# Tables and functions — any time before the first query.
ctx.register_table("t", lib_a.TableProvider())
ctx.register_udf(udf(lib_b.SomeUDF()))
```
A codec may own functions that need no payload at all, where the name is the whole
encoding: `try_encode_udf` writes nothing and `try_decode_udf` rebuilds the function
from `name`. That is supported and needs no id, because an `Ok` with an empty
buffer is read as "no opinion" and passes the object to the next codec.
`NameOnlyUdfCodec` in the FFI example is the worked case. Anything no installed
codec claims falls through to `Default{Logical,Physical}ExtensionCodec`.
This is the one case where your decoder is consulted about something you may not
own, because an empty payload has no id to route on. `try_decode_udf` and
its aggregate and window siblings can therefore be called with an empty `buf` and a
`name` belonging to another library. Decide from `name` and return an error if it is
not yours; do not assume `buf` is non-empty.
The framing itself — how an id is stored alongside a payload and routed back, and the two cases
that stay unframed — is internal to datafusion-python and documented in
`crates/core/src/codec.rs` for anyone changing it.
The current FFI logical codec supports providers and UDFs but not arbitrary custom
`LogicalPlan::Extension` nodes. See both example READMEs for the supported flow and
local build commands.
### Extension bundles: `with_extensions`
The chaining above works, but it makes the caller responsible for ordering: the codecs
have to be installed before the planner, because a planner is built against whatever
codec chains exist when it is installed, and a codec added afterwards rebinds it. Get
that wrong and the planner encodes through a chain that is missing a library.
`SessionContext.with_extensions` removes the ordering question. An extension library
exposes a bundle object implementing `__datafusion_session_extension__`:
```python
class MyEngineExtension:
def __datafusion_session_extension__(self, ctx: SessionContext) -> SessionExtensionComponents:
# Create fresh components bound to `ctx` on every call. `ctx` is the
# session the components will run on.
return SessionExtensionComponents(
logical_extension_codecs=(self._make_logical_codec(ctx),),
physical_extension_codecs=(self._make_physical_codec(ctx),),
query_planner=self._make_planner(ctx),
)
```
The host passes the context to every factory, installs all the codecs, binds the
planner against the final codec chains, and returns a handle on that session in a
single step:
```python
ctx = SessionContext(config).with_extensions(lib_a.Extension(), lib_b.Extension())
ctx.register_table("t", lib_a.TableProvider())
ctx.register_udf(udf(lib_b.SomeUDF()))
```
Extensions are processed left to right and their codecs are appended to the chain in
that order. As above, order affects only encoding — decoding routes by id. At most one
extension per call may supply a query planner.
Nothing is written to the session until every factory has returned and every capsule
has been validated, so a factory that raises leaves the session exactly as it was. A
factory that mutates the context it is handed — registering a table, say — is not
rolled back, which is why bundle objects must be configuration-only: create fresh
components on each call, never cache bound components, and do not retain the context
passed in.
Like every other derivation, the returned context is a handle on the *same* session as
the receiver — see [What a derived context shares](#what-a-derived-context-shares).
Only the Python-side codec chains belong to the returned handle; the planner is
installed on the shared session and takes effect even if that handle is discarded.
The session owns every installed component's task-context provider, and dependent
objects do not extend its lifetime. A `DataFrame`, logical plan, or capsule can outlive
every context on the session, but any operation that reaches an FFI codec after the
last one is collected fails with `TaskContextProvider went out of scope over FFI
boundary`. Keep a context alive for as long as objects derived from it are in use.
`MyPlannerExtension` in [`datafusion-ffi-query-planner-example`] is a complete Rust
implementation of the protocol, including taking the task-context provider off the
supplied context and constructing a Python `SessionExtensionComponents`.
### Capsule getters receive the session they are installed on
`__datafusion_query_planner__`, `__datafusion_logical_extension_codec__`, and
`__datafusion_physical_extension_codec__` all take the `SessionContext` the object is
being installed on, the same way `__datafusion_table_provider__` does:
```rust
fn __datafusion_physical_extension_codec__<'py>(
&self,
py: Python<'py>,
session: Bound<'py, PyAny>,
) -> PyResult<Bound<'py, PyCapsule>> {
let runtime = get_tokio_runtime().handle().clone();
let ctx_provider = ffi_task_context_provider_from_pycapsule(&session)?;
let ffi = FFI_PhysicalExtensionCodec::new(inner, Some(runtime), ctx_provider);
PyCapsule::new_with_value(py, ffi, cr"datafusion_physical_extension_codec")
}
```
This exists because the FFI constructors need things an extension library does not
have. `FFI_{Logical,Physical}ExtensionCodec::new` needs a `TaskContextProvider` for the
decode callbacks the codec will receive, and `FFI_QueryPlanner::new` needs both codecs
on top of that. Taking them from the session is what keeps a library from constructing
a `SessionContext` purely to satisfy a parameter — an empty one resolves nothing, and
`FFI_TaskContextProvider` holds it weakly, so a context built inline in the getter is
already dropped by the time the capsule is used.
A planner uses `FFI_QueryPlanner::new_with_ffi_codecs` with the two codecs it takes off
the session, and never touches a provider directly. That also matches what installation
does anyway: `set_query_planner` builds the planner against the codecs of the session
that will run the query.
`SessionContext` accepts the argument on all three getters and ignores it, so a session
satisfies the same protocol an extension library implements. When you export the current
planner to wrap it, `ctx.__datafusion_query_planner__()` and
`ctx.__datafusion_query_planner__(ctx)` are both fine.
### A codec decodes against the session that is running the query
Because the provider comes from the host, a decode callback running inside an extension
library resolves names against the session running the query. A function registered with
`ctx.register_udf(...)` is visible to a foreign codec decoding a node that references it
by name, and the handle is live rather than a snapshot, so a registration made after the
codec is installed is visible too.
This is covered in
`examples/datafusion-ffi-query-planner-example/python/tests/_test_three_library_query_planner.py`,
where the example codecs take a `require_udf_on_decode` name and resolve it out of the
task context they are handed.
### One session, one `Arc<SessionContext>`
Every codec handed to a foreign object carries an `FFI_TaskContextProvider`, and that
type holds its provider **weakly**. A registered catalog provider upgrades the handle on
every `supports_filters_pushdown` and every `scan`. Those handles are bound to one
particular `Arc<SessionContext>` allocation, not to the logical session, so anything that
replaces the allocation orphans all of them and the next query fails with
`TaskContextProvider went out of scope over FFI boundary`.
So a `PySessionContext` keeps the `Arc<SessionContext>` it was created with for its whole
life. Installing a query planner writes the new `SessionState` back through
`state_ref()`, exactly as `add_physical_optimizer_rule` does, rather than deriving a
replacement context. The session id is carried across that rewrite — `SessionStateBuilder`
mints a fresh one otherwise — so `session_id()` and every `TaskContext` the session hands
out keep agreeing.
Repairing the damage instead of avoiding it does not work in general. A context can
rebuild the codecs it holds in its own fields, but a codec already embedded in a
registered `FFI_CatalogProvider` — and in every `FFI_SchemaProvider` and
`FFI_TableProvider` minted from it — is not reachable from Python at all. Nor can the
codec simply retain the session that built it: a codec handed to a provider is routinely
registered straight back into that same session, which would close the cycle
`SessionContext -> catalog -> FFI provider -> FFI codec -> SessionContext` and leak it.
`SessionContext.enable_url_table` is the one exception. It clones the underlying
`SessionContext`, so the returned context has an allocation of its own and must not
outlive the receiver. It also forks the session's state while keeping its id, so two
handles report one `session_id()` with divergent configuration. That is a bug rather
than a design, tracked in
[apache/datafusion-python#1708](https://github.com/apache/datafusion-python/issues/1708);
do not copy the pattern.
### What a derived context shares
`with_logical_extension_codec`, `with_physical_extension_codec`,
`with_python_udf_inlining`, and `with_extensions` return a new `SessionContext` wrapping
the *same* underlying session. Only the Python-side codec settings differ; catalogs,
tables, registered functions, and configuration are the one shared session, so a
registration on either side is visible to both.
There is one `Arc<SessionContext>` per session, which is what makes the weak
`FFI_TaskContextProvider` scheme work: a component bound through any handle stays valid
while *any* handle on that session is alive, so there is no way to bind a component to
an intermediate handle and have it dangle when that handle is dropped.
`set_query_planner` does not return anything. The query planner lives in `SessionState`,
so it is a property of the session rather than of a handle on it, and installing one is
visible to every context sharing that session — including ones a `with_*` call returned
earlier. Installing a codec on a session that already has a foreign planner rebuilds
that planner against the new chain for the same reason: there is one planner, and it has
to carry the codecs currently in force. This happens on the shared session, so it takes
effect even if the returned context is discarded — `ctx.with_python_udf_inlining(...)`
whose result is thrown away still leaves the session's planner carrying the codecs of
that discarded handle. A call that changes nothing is exempt: asking for the inlining
setting a context already has returns a handle without touching the session.
The rule that falls out of this is worth stating on its own, because it is the one thing
that surprises people:
> The session's query planner carries the codecs of the handle that most recently
> installed one. Every other path — `Expr.to_bytes(ctx)`, `ExecutionPlan.to_bytes(ctx)`,
> registering a provider — uses the codecs of the handle you call it on.
Those can be different handles, and then one session has two codec chains in effect at
once:
```python
ctx = ctx.with_logical_extension_codec(codec_a)
ctx.set_query_planner(planner)
ctx.with_logical_extension_codec(codec_b) # discarded
Expr.to_bytes(expr, ctx) # encodes with [codec_a, default] -- ctx's own field
ctx.sql(...).collect() # plans with [codec_b, codec_a, default] -- the discarded
# handle's chain, installed on the shared session
```
Chaining `ctx = ctx.with_...(...)`, as the example below does, keeps the two in step.
`test_the_planner_and_the_handle_can_hold_different_codecs` pins the divergence.
```python
ctx = SessionContext(config)
ctx = ctx.with_logical_extension_codec(provider_logical_codec)
ctx = ctx.with_physical_extension_codec(provider_physical_codec)
ctx.set_query_planner(planner)
ctx.register_udf(my_udf)
```
Order is a readability preference rather than a requirement — installing a codec after a
planner rebuilds the planner against it.
A session holds exactly one query planner. Calling `set_query_planner` again replaces the
installed planner instead of layering another one. To chain planners, have the new
planner wrap the capsule returned by `SessionContext.__datafusion_query_planner__()`,
captured before the new planner is installed, and delegate to it explicitly.
### Rebinding a planner's codecs is one level deep
The rebuild above swaps the codecs on the installed `ForeignQueryPlanner` handle, and
only that handle. A planner that wraps a fallback resolved that fallback when *it* was
installed, and holds the result inside its own library's private data — behind a
`create_physical_plan` function pointer, with no Python-side handle. A codec installed
afterwards therefore reaches the outer planner and not the fallback, which keeps
whichever codecs were in force when it was imported.
Neither side can repair that:
- **The host cannot reach it.** `FFI_QueryPlanner::new_with_ffi_codecs` unwraps exactly
one `ForeignQueryPlanner` layer. There is no deeper handle to unwrap — the same
situation as a codec embedded in a registered `FFI_CatalogProvider`.
- **The planner library cannot re-derive it.** `FFI_QueryPlanner` holds its codecs by
value, and `Session` exposes no accessor for the ones the host currently has, so
`create_physical_plan` cannot pick them up from the session it is handed. The rebuild
has to be eager, and an eager rebuild only sees the top layer.
A fix has to come from upstream, and is tracked in
[apache/datafusion#24762](https://github.com/apache/datafusion/issues/24762).
The stale codecs stay usable rather than dangling — they hold weak handles to the one
`Arc<SessionContext>` that Rule 6 keeps alive — so the effect is a fallback hop
serializing with an older codec, not a failure. It is also invisible to the examples
here, which use one fallback in the same cdylib as its wrapper; `datafusion-ffi`
short-circuits a same-library hop rather than serializing, so no codec runs. A fallback
in a *different* library would serialize, and would do it with the codecs it was
imported with.
So install the codecs before a layered planner. If a codec has to go in afterwards,
install the outer planner again *on the handle that holds the new codec* — that re-runs
its getter, which re-imports the fallback against that handle's codecs. Re-installing on
the original handle rebinds the session's planner back to the original handle's codecs
instead, which is the trap
`test_reinstalling_a_planner_rebinds_the_session_to_that_handles_codecs` pins.
`with_extensions` sidesteps the ordering question entirely: it installs every codec
before it binds the planner, so there is no "afterwards" for a bundle's own planner.
## Alternative Approach
Suppose you needed to expose some other features of DataFusion and you could not wait
for the upstream repository to implement the FFI approach we describe. In this case
you decide to create your dependency on the `datafusion-python` crate instead.
As we discussed, this is not guaranteed to work across different compiler versions and
optimization levels. If you wish to go down this route, there are two approaches we
have identified you can use.
1. Re-export all of `datafusion-python` yourself with your extensions built in.
2. Carefully synchronize your software releases with the `datafusion-python` CI build
system so that your libraries use the exact same compiler, features, and
optimization level.
We currently do not recommend either of these approaches as they are difficult to
maintain over a long period. Additionally, they require a tight version coupling
between libraries.
## Status of Work
At the time of this writing, the FFI features are under active development. To see
the latest status, we recommend reviewing the code in the [datafusion-ffi] crate.
[datafusion-ffi]: https://crates.io/crates/datafusion-ffi
[`datafusion-ffi-example`]: https://github.com/apache/datafusion-python/tree/main/examples/datafusion-ffi-example
[`datafusion-ffi-query-planner-example`]: https://github.com/apache/datafusion-python/tree/main/examples/datafusion-ffi-query-planner-example