blob: f2793d18c2ed5068f33c504123368d32c4f2b0b8 [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.
-->
(extension_bundles)=
# Extension bundles
If your library ships codecs, functions, or a query planner, expose a **bundle**
and let callers install it with
{py:meth}`~datafusion.SessionContext.with_extensions`. This is the recommended
way to package an extension, and the rest of this page explains what the
bundle protocol asks of you and why.
## Why not have callers install the pieces
Installing the pieces by hand 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.
`with_extensions` removes the ordering question. An extension library exposes a
bundle object implementing one or both of two hooks:
```python
class MyEngineExtension:
def __datafusion_session_components__(self, ctx: SessionContext) -> SessionExtensionComponents:
# Phase one. Create fresh components bound to `ctx` on every call.
return SessionExtensionComponents(
logical_extension_codecs=(self._make_logical_codec(ctx),),
physical_extension_codecs=(self._make_physical_codec(ctx),),
udfs=(MyScalarUDF(),),
)
def __datafusion_session_planner__(self, ctx: SessionContext, fallback):
# Phase two. `ctx` now carries every bundle's codecs, and `fallback` is
# the planner built so far. Wrapping it is what makes this library
# compose with the other planners in the call.
return self._make_planner(ctx, fallback=fallback)
```
Implement only the hooks you need. Codecs and functions both go in
`__datafusion_session_components__`, with the fields you do not use left empty,
so a codec-only library and a function-only library each define that one alone;
a library shipping nothing but an optimizing planner defines only
`__datafusion_session_planner__`. The caller then writes:
```python
ctx = SessionContext(config).with_extensions(lib_a.Extension(), lib_b.Extension())
ctx.register_table("t", lib_a.TableProvider())
```
Return your functions rather than calling `register_udf` on the `ctx` you were
handed — see {ref}`extension_bundles_transaction`.
`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, wrapping its codecs in `BundledLogicalCodec` /
`BundledPhysicalCodec` so they carry declared ids, and constructing a Python
{py:class}`~datafusion.SessionExtensionComponents`.
## A bundle you can run
Before wiring up a cdylib, it is worth seeing the protocol work end to end in
pure Python. This runs against the plain wheel — the codec here re-exports the
host session's own capsule, where a real one would return its library's:
```python
from datafusion import SessionContext, SessionExtensionComponents
class Codec:
"""Wraps a capsule so it carries an id. A Rust library ships this shape."""
def __init__(self, codec_id, capsule):
self.__datafusion_codec_id__ = codec_id
self._capsule = capsule
def __datafusion_logical_extension_codec__(self, session=None):
return self._capsule
class Bundle:
def __init__(self, codec_id):
self.codec_id = codec_id
def __datafusion_session_components__(self, ctx):
# Fresh components on every call, bound to the `ctx` handed in.
# Never cache these, and never retain `ctx`.
return SessionExtensionComponents(
logical_extension_codecs=(
Codec(self.codec_id, ctx.__datafusion_logical_extension_codec__()),
)
)
ctx = SessionContext().with_extensions(Bundle("tables.v1"), Bundle("engine.v1"))
ctx.logical_extension_codec_ids()
# ['tables.v1', 'engine.v1']
```
Three things this makes observable, each pinned by a test in
`python/tests/test_context.py`:
- **Ids accumulate in bundle order.** Decoding does not depend on that order;
only encoding does. See {ref}`extension_codec_order`.
- **Two bundles claiming one id are refused**, with a `ValueError` naming the
id — not resolved by position, since a positional id would break stored plans
the first time a bundle reordered what it returns.
- **A hook that raises leaves the receiving session untouched.** Add a bundle
whose hook raises and the source context's
{py:meth}`~datafusion.SessionContext.logical_extension_codec_ids` is still
empty afterwards.
(extension_bundles_two_phases)=
## Two phases, because codecs and planners compose differently
A session chains **many** codecs and dispatches between them by id. Codecs
therefore just accumulate: order affects encoding only, and decoding always
routes to the codec that wrote the payload. A session holds exactly **one**
query planner, so planners cannot accumulate — they compose by *nesting*, each
wrapping the one before it and delegating to it for work it does not handle.
So `with_extensions` runs every `__datafusion_session_components__` and installs
all the codecs, and only then runs each `__datafusion_session_planner__`, in
argument order, handing each the planner built so far. Two consequences worth
holding onto:
- **Bundle order matters differently for each.** For planners it sets the
nesting: the last extension listed ends up outermost and is consulted first.
For codecs it never affects decoding, and affects encoding only when two
codecs would claim the same node — see {ref}`extension_codec_order`.
- **A planner is always built against the complete codec set**, including
codecs from bundles listed after it. This is what the low-level chaining
cannot give you, and it matters most for a nested planner: the rebuild that
follows a later codec install reaches only the outermost layer (see
{ref}`planner_codec_rebinding`), so a fallback captured before the codecs
were complete would stay stale forever.
The two hooks therefore see the same session through different chains. Both
receive a handle on the one session, so the task-context provider taken off
either is the same and stays valid — but the `ctx` in phase one still carries
the chains the receiver had, since nothing is installed yet, while the `ctx` in
phase two carries every bundle's codecs. A bundle that reads the host's codec
chains — `MyPlannerExtension` does, to give its planner the host's codecs
rather than minting its own — must do that in the planner hook. Reading them in
phase one gets the chains from before the call, missing even the bundle's own
codecs.
## Returning a planner, or not
An extension that ignores `fallback` and returns an unrelated planner replaces
every layer beneath it, including any planner the session already had. That is
legal — a library that must be the only planner does it deliberately — but it
is not composable, and nothing detects it. Returning `None` contributes no
planner and leaves `fallback` in place.
`None` is the no-op, not `fallback`. The capsule handed to the first bundle
wraps the session's planner for export, so returning it unchanged installs that
planner as a foreign one and every plan built afterwards crosses an FFI
boundary it did not before. A bundle that decides at runtime it has nothing to
contribute returns `None`.
Three libraries that each ship a planner therefore install like this, with the
outermost last:
```python
ctx = SessionContext(config).with_extensions(
tables.Extension(), # codecs only
functions.Extension(), # codecs only
optimizer.Extension(), # planner, wrapping the session default
distributed.Extension(), # planner, wrapping the optimizer
)
```
(extension_bundles_order_conflict)=
## When the two orders conflict
Because codec position and planner position both come from one argument list, a
library can in principle need to be early for one and late for the other: its
codec must precede a broad claimer, while its planner must nest outside that
library's planner.
Do not try to satisfy both by reordering — contribute each half at its own
position. The two hooks are independent, so a three-line adapter each is
enough:
```python
class CodecsOf:
"""Contribute only the codec half of a bundle, at this position."""
def __init__(self, inner):
self.inner = inner
def __datafusion_session_components__(self, ctx):
return self.inner.__datafusion_session_components__(ctx)
class PlannerOf:
"""Contribute only the planner half of a bundle, at this position."""
def __init__(self, inner):
self.inner = inner
def __datafusion_session_planner__(self, ctx, fallback):
return self.inner.__datafusion_session_planner__(ctx, fallback)
ctx = SessionContext(config).with_extensions(
CodecsOf(engine), CodecsOf(tables), # engine's codec first
PlannerOf(tables), PlannerOf(engine), # engine's planner outermost
)
```
This keeps everything `with_extensions` guarantees: one transaction, codecs
complete before any planner is built, codec ids untouched — an id is read off
the codec object, not off the extension that contributed it, so splitting a
bundle cannot re-tag its payloads. A library that expects to be composed this
way should expose the halves itself rather than make callers write the
adapters.
There is no attempt here to make every permutation expressible from one call.
Two positions per bundle covers the cases that arise; anything stranger is a
sign the libraries disagree about what they own, which is better fixed there.
The low-level
{py:meth}`~datafusion.SessionContext.with_logical_extension_codec` /
{py:meth}`~datafusion.SessionContext.with_physical_extension_codec` /
{py:meth}`~datafusion.SessionContext.set_query_planner` sequence also works,
and it is the right answer when the pieces do not come as bundles at all. But
it is a real downgrade, not just a more verbose spelling: you take back
responsibility for installing every codec before every planner, and a planner
you layer by hand keeps the codecs it captured — the
{ref}`one-level rebind <planner_codec_rebinding>` does not reach inside it.
Reach for it last.
(extension_bundles_codecs_are_objects)=
## Codecs are objects, not capsules
`with_extensions` requires each codec to be an object exposing the capsule
getter, and refuses a bare `PyCapsule`. A codec's id is read off the object it
is handed over as, and a capsule has no type to read one from; since this
method takes no `codec_id=`, there would be nothing left to name it by. A
library holding a raw capsule — which is what a Rust implementation has —
wraps it:
```python
class MyLogicalCodec:
# Optional. Without it the id is this class's import path, which is already
# stable; declare it if you may rename the class and need old plans to decode.
__datafusion_codec_id__ = "my_library.logical.v1"
def __init__(self, capsule):
self._capsule = capsule
def __datafusion_logical_extension_codec__(self, session=None):
return self._capsule
```
Wrapping is not just bookkeeping. It ties the id to the codec rather than to
the bundle that contributed it, and that difference is load-bearing: an
application commonly presents several libraries as one bundle of its own, and
the id has to survive that. Were the id taken from the contributing bundle,
wrapping `my_engine.Extension` inside `my_app.Extension` would silently re-tag
the engine's payloads, and a scheduler that installs the engine's codec by its
documented id would fail to decode plans from composed clients while succeeding
for direct ones. The wrapper travels with the codec; the bundle does not.
The query planner is exempt — it carries no wire id, so it may be an object or
a capsule.
(extension_bundles_collisions)=
## Two bundles claiming one name
One `with_extensions` call may not declare a function of the same kind under
the same name twice. Doing so raises:
```text
ValueError: A scalar function named 'normalize' is declared twice: argument 0
(<lib_a.Extension>) and argument 1 (<lib_b.Extension>). ...
```
Codecs get away with sharing a chain because a payload carries the id of the
codec that wrote it, so decode routes to the right one. A function registry has
no such fall-through — one name holds one function — so the second registration
would quietly replace the first. The call refuses instead.
The argument each claim came from is in the message because it is what picks
the remedy, and the reader works it out from the two positions. Two different
positions means two bundles, which only the caller can resolve — by dropping a
repeat, or by installing them on separate sessions. One position named twice
means a single bundle claimed the name twice, which is its author's own bug and
the one case a rename fixes. Collisions are keyed on position rather than on
object identity so that passing one extension twice reads as the caller's
duplicate that it is, rather than as a bundle colliding with itself.
Four cases this does *not* catch:
- **Different kinds never collide.** Names are compared within a kind, so a
scalar function and an aggregate may both be called `normalize`.
- **Shadowing a built-in is allowed.** The registry already holds every
DataFusion function, and replacing one by name is a supported thing to do —
`enable_spark_functions` works that way.
- **One call at a time is checked.** The names compared are the ones declared
in a single `with_extensions` call. Two calls on the same session, or a call
following a hand-written `register_udf`, land in the registry one after the
other, and the later one silently replaces the earlier — that is the
shadowing rule above, applied to something you may not have meant to shadow.
Splitting colliding bundles across two calls therefore does not resolve the
collision; it hides it. Use two sessions.
- **Aliases are not compared.** DataFusion registers a function under each of
its aliases as well as its name, but only the names are checked. A function
whose alias matches another declared function's name replaces it, or is
replaced by it, with nothing raised.
Your caller cannot rename your function, so stay out of the way: prefix the
names with something tied to your library.
(extension_bundles_transaction)=
## Failure and rollback
Nothing is written to the session until every factory has returned and every
component 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.
Declaring a component is what buys you that guarantee, and it is the whole
reason to prefer `udfs=(...)` over a `register_udf` call inside your hook.
Anything you declare is resolved and checked while a failure still costs
nothing, and is written only after every bundle in the call has succeeded.
Anything you register yourself is written immediately, before the other bundles
have even run. The ordering that makes this hold is recorded at
{ref}`ffi_internals_commit_order`.
The one thing that ordering costs you: functions are registered *after* the
planner hooks run, so `ctx.udfs()` inside your
`__datafusion_session_planner__` will not list a function declared in the same
call — not yours, and not another bundle's. Look one up at plan time instead,
where the registry is complete; a planner is called per query, long after the
install has finished. If you need a function at hook time, you already have the
object, because you are the one declaring it.
Like every other derivation, the returned context is a handle on the *same*
session as the receiver — see {ref}`extension_sessions`. 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.
`with_extensions` sidesteps the {ref}`one-level rebind <planner_codec_rebinding>`
entirely, and for nested planners too: every codec from every bundle is
installed before the first planner hook runs, so no layer — outer or fallback —
is ever captured against a partial chain. There is no "afterwards" within a
call. Prefer it over hand-layering whenever the planners you are composing all
ship as bundles.
[`datafusion-ffi-query-planner-example`]: https://github.com/apache/datafusion-python/tree/main/examples/datafusion-ffi-query-planner-example