| <!--- | |
| 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. | |
| --> | |
| (extension_capsule_protocol)= | |
| # The capsule protocol | |
| Every integration point in this section works the same way. Your library | |
| exposes a dunder method, datafusion-python calls it, and it hands back a | |
| `PyCapsule` wrapping an FFI-safe struct. This page describes that mechanism | |
| once; the pages after it describe what goes inside the capsule for each kind | |
| of component. | |
| 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. | |
| ## FFI-safe types | |
| Our FFI implementation is narrowly focused on sharing data and functions with | |
| Rust backed libraries. This allows us to use the | |
| [stabby crate](https://crates.io/crates/stabby), which converts between Rust | |
| native types and FFI-safe alternatives. For example, if you needed to pass a | |
| `Vec<String>` via FFI, you can convert it to a | |
| `stabby::vec::Vec<stabby::string::String>` — the crate's own examples alias | |
| these as `SVec` and `SString`, which is the convention [datafusion-ffi] follows | |
| too. | |
| For `Option` and `Result`, [datafusion-ffi] defines its own `FFI_Option<T>` and | |
| `FFI_Result<T>` rather than using stabby's. Stabby's versions require | |
| `T: IStable` for niche optimization, and many of the `FFI_*` structs hold | |
| self-referential function pointers that cannot implement it. | |
| ## `FFI_` on the provider, `Foreign` on the receiver | |
| 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 = Arc::new(MyTableProvider::default()); | |
| let ffi_provider = FFI_TableProvider::new_with_ffi_codec(my_provider, false, None, codec); | |
| ``` | |
| where `codec` is the host's logical codec, read off the argument your getter | |
| was handed — see {ref}`extension_getter_argument`. | |
| If you were interfacing with a library that provided the above | |
| `FFI_TableProvider` and you needed a usable `TableProvider` back, you convert | |
| it into an `Arc<dyn TableProvider>`: | |
| ```rust | |
| let provider: Arc<dyn TableProvider> = (&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 an `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. | |
| Convert to the trait object rather than naming `ForeignTableProvider` yourself. | |
| The conversion compares the provider's library marker against the receiver's: | |
| when both sides turn out to be the same shared library it hands back the | |
| original `Arc` and skips the boundary entirely, and only otherwise wraps it in | |
| a `ForeignTableProvider`. Which one you get is an implementation detail, and | |
| both implement `TableProvider`. | |
| ## Wrapping it in a capsule | |
| 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 {ref}`extension_why_ffi`, we use `PyCapsule`. We can create a | |
| `PyCapsule` for our provider thusly: | |
| ```rust | |
| PyCapsule::new_with_value(py, ffi_provider, cr"datafusion_table_provider") | |
| ``` | |
| On the receiving side, read the `FFI_TableProvider` back out of the capsule and | |
| convert it, which is what `table_provider_from_pycapsule` in `crates/util` does: | |
| ```rust | |
| validate_pycapsule(capsule, "datafusion_table_provider")?; | |
| let data: NonNull<FFI_TableProvider> = capsule | |
| .pointer_checked(Some(c"datafusion_table_provider"))? | |
| .cast(); | |
| let ffi_provider = unsafe { data.as_ref() }; | |
| check_ffi_version("table provider", unsafe { (ffi_provider.version)() })?; | |
| let provider: Arc<dyn TableProvider> = ffi_provider.into(); | |
| ``` | |
| ## The naming rule | |
| The getter's name and the capsule's name are both fixed by the protocol, and | |
| they follow one rule with no exceptions: | |
| - The method is `__datafusion_<thing>__`. | |
| - The capsule it returns is named `datafusion_<thing>` — the same string | |
| without the underscores. | |
| So a table provider is reached by calling `__datafusion_table_provider__` and | |
| must return a capsule named `datafusion_table_provider`. Return a capsule with | |
| the wrong name and the import fails with an error naming both the name found | |
| and the name expected, rather than reading the pointer as the wrong type. | |
| The full list of hooks is in the | |
| {ref}`hook reference <extension_guide>`. `TableProvider` was the first | |
| extension written this way and is the most thoroughly implemented; every hook | |
| added since follows the same pattern. | |
| ## Version checking | |
| Objects imported through this protocol are checked against the major version of | |
| `datafusion-ffi` that datafusion-python was built with. A component produced by | |
| a library built against a different DataFusion major version raises an | |
| `ImportError` naming the version found and the version expected. | |
| This is a diagnostic rather than a soundness guarantee — reading the version | |
| out of the struct already assumes the local field layout — but it turns the | |
| common "extension library built against the wrong DataFusion" mistake into a | |
| clear message rather than undefined behaviour on first use. See | |
| {ref}`extension_version_mismatch`. | |
| Three FFI structs carry no version field and so cannot be checked: | |
| `FFI_TaskContextProvider`, `FFI_TableProviderFactory`, and | |
| `FFI_ExtensionOptions`. | |
| (extension_getter_argument)= | |
| ## What your getter receives | |
| Most getters take one positional argument beyond `py`. The | |
| {ref}`hook reference <extension_guide>` says which, and the group that does | |
| looks like this: | |
| ```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 | |
| argument 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. | |
| ### It is not always a session | |
| The parameter is conventionally named `session`, and for the codec and planner | |
| hooks it genuinely is one. For the provider and catalog hooks it may instead be | |
| a bare `datafusion_logical_extension_codec` capsule: `SessionContext.register_table` | |
| passes the session, while `Schema.register_table`, | |
| `SessionContext.register_catalog_provider`, `register_catalog_provider_list`, | |
| and `register_table_factory` pass the host's codec directly. | |
| This is why the helpers accept either. `ffi_logical_codec_from_pycapsule` | |
| calls the codec getter if the object has one and returns the object untouched | |
| if it does not, so the same line works in both cases: | |
| ```rust | |
| let codec = ffi_logical_codec_from_pycapsule(session, None)?; | |
| ``` | |
| The rule to hold onto is therefore about capability rather than type: **the | |
| argument is something you can read the host's logical extension codec off.** | |
| The hooks that need more than a codec — the two extension codecs and the query | |
| planner, which need a task-context provider — are exactly the hooks that are | |
| always handed a real session. | |
| ### Duck-type it | |
| Do not check the argument's type. Beyond the codec-capsule case above, even | |
| when it *is* a session it is the PyO3 context the binding installs through, not | |
| the `datafusion.context.SessionContext` wrapper. It carries every capsule | |
| getter and `__datafusion_codec_id__` — everything the protocol asks of it — but | |
| `isinstance(session, SessionContext)` is `False` in Python even though its | |
| `repr` reads `datafusion.SessionContext`. | |
| The two bundle hooks are the exception: `__datafusion_session_components__` and | |
| `__datafusion_session_planner__` are dispatched from Python by | |
| {py:meth}`~datafusion.SessionContext.with_extensions`, so they receive the | |
| wrapper. See {doc}`bundles`. | |
| `SessionContext` accepts the argument on its own codec and planner getters and | |
| ignores it, so a session satisfies the same protocol an extension library | |
| implements. When you export the current planner in order to wrap it, | |
| `ctx.__datafusion_query_planner__()` and `ctx.__datafusion_query_planner__(ctx)` | |
| are both fine. | |
| (extension_task_context_provider)= | |
| ## `__datafusion_task_context_provider__` | |
| This is the one hook implemented by the **host** rather than by your library. | |
| You never define it; you read it off the session you were handed, which is what | |
| `ffi_task_context_provider_from_pycapsule` does. Nothing in datafusion-python | |
| calls it on a foreign object. | |
| Taking the host's provider means your decode callbacks resolve names against | |
| the session that is actually running the query — see | |
| {ref}`extension_codec_decode_session` — and it removes any need for your | |
| library to construct a `SessionContext` of its own. | |
| [datafusion-ffi]: https://crates.io/crates/datafusion-ffi |