Skip to content

Commit d47055e

Browse files
timsaucerclaude
andcommitted
docs: put each bundle-functions claim in front of its own audience
The docs added alongside the new `udfs`/`udafs`/`udwfs` fields mixed three readerships. Sorting them out: The four-step Collect/Chains/Resolve/Commit list, and the rule it imposes on whoever adds the next `SessionExtensionComponents` field, moves from the extension guide to `contributor-guide/ffi-internals.md`, which already declares itself the page you do *not* need to write an extension library. The extension guide keeps only what an author acts on — declare, do not register — and links across. The comment in `with_extensions` that states the same rule now points at the new label rather than at the extension-facing one. `Two bundles claiming one name` becomes a `##` and moves ahead of `Failure and rollback`, which it had been splitting: the paragraphs closing that section were rendering under the collision heading. The user guide gains the collision as its own entry under what will bite you, with the error text and the two-sessions workaround, since it is raised by a call the user makes and cannot fix in their own code. Its section heading no longer says "two kinds" over three, the functions paragraph moves above the note that closes the section, and the discovery section covers `udfs()` and friends rather than codec ids alone. The bundle snippet in `functions.md` names `MyFunctionExtension` and this crate's real function names, separates the author's class from the caller's line, and says the cdylib can export the getter directly — it had implied a Rust library ships a Python shim. The table's `—` for table functions now says what to do instead. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 0af2a50 commit d47055e

5 files changed

Lines changed: 139 additions & 54 deletions

File tree

docs/source/contributor-guide/ffi-internals.md

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,43 @@ library would serialize, and would do it with the codecs it was imported with.
111111
The extension-facing consequence — install codecs before a layered planner, and
112112
prefer `with_extensions` — is documented at {ref}`planner_codec_rebinding`.
113113

114+
(ffi_internals_commit_order)=
115+
116+
## Why `with_extensions` commits last
117+
118+
`with_extensions` promises that a bundle which raises leaves the session as it
119+
was. Keeping that promise is an ordering constraint on the implementation,
120+
because the components a bundle declares no longer all live on the returned
121+
handle — functions are registered on the shared `SessionState`, and the planner
122+
is bound there too.
123+
124+
A call therefore splits into a part that may fail and a part that may not:
125+
126+
1. **Collect.** Every `__datafusion_session_components__` runs.
127+
2. **Chains.** The codecs are assembled into the returned handle. Codec chains
128+
live on that handle rather than on the session, so this step writes nothing
129+
even though it can fail on a bad capsule or a duplicate id.
130+
3. **Resolve.** Every declared function is wrapped and every name is checked,
131+
and every `__datafusion_session_planner__` runs against the completed
132+
chains.
133+
4. **Commit.** The planner is bound and the functions are registered.
134+
135+
Only step 4 touches the session, and every step that can fail happens before
136+
it. This is a rule for the next field added to
137+
`SessionExtensionComponents`, not only a description of the current code: a new
138+
kind of component must do its fallible work — importing a capsule, resolving a
139+
name — in step 3, so that step 4 cannot raise part-way through.
140+
141+
There is nothing to roll back to if it does. The returned handle shares one
142+
session with the receiver, so the damage is visible from every other handle;
143+
and undoing a registration is not the same as restoring what it displaced,
144+
because deregistering a function that shadowed a built-in removes the built-in
145+
too. The split is cheaper than an undo log that cannot be written correctly.
146+
147+
The extension-facing statement of this is
148+
{ref}`extension_bundles_transaction`, which says only that declaring a
149+
component is safe where registering one during the hook is not.
150+
114151
## Two argument kinds for one convention
115152

116153
`CapsuleGetterArg` in `crates/util/src/lib.rs` distinguishes three cases: no

docs/source/extension-guide/bundles.md

Lines changed: 24 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -289,41 +289,9 @@ for direct ones. The wrapper travels with the codec; the bundle does not.
289289
The query planner is exempt — it carries no wire id, so it may be an object or
290290
a capsule.
291291

292-
(extension_bundles_transaction)=
293-
294-
## Failure and rollback
295-
296-
Nothing is written to the session until every factory has returned and every
297-
component has been validated, so a factory that raises leaves the session
298-
exactly as it was. A factory that mutates the context it is handed —
299-
registering a table, say — is **not** rolled back, which is why bundle objects
300-
must be configuration-only: create fresh components on each call, never cache
301-
bound components, and do not retain the context passed in.
302-
303-
That guarantee is why the installation runs in the order it does. A call splits
304-
into a part that may fail and a part that may not:
305-
306-
1. **Collect.** Every `__datafusion_session_components__` runs.
307-
2. **Chains.** The codecs are assembled into the returned handle. Codec chains
308-
live on that handle rather than on the session, so this step writes nothing
309-
even though it can fail on a bad capsule or a duplicate id.
310-
3. **Resolve.** Every declared function is wrapped and every name is checked,
311-
and every `__datafusion_session_planner__` runs against the completed
312-
chains.
313-
4. **Commit.** The planner is bound and the functions are registered.
314-
315-
Only step 4 touches the session, and every step that can fail happens before
316-
it. This is a rule for anyone extending `with_extensions`, not only a
317-
description: a new kind of component must do its fallible work — importing a
318-
capsule, resolving a name — in step 3, so that step 4 cannot raise part-way
319-
through. There is nothing to roll back to if it does. The returned handle
320-
shares one session with the receiver, and undoing a registration is not the
321-
same as restoring what it displaced: deregistering a function that shadowed a
322-
built-in removes the built-in too.
323-
324292
(extension_bundles_collisions)=
325293

326-
### Two bundles claiming one name
294+
## Two bundles claiming one name
327295

328296
Within a single call, two extensions declaring a function of the same kind
329297
under the same name is a `ValueError` naming both. Codec ids dispatch on
@@ -336,6 +304,29 @@ Shadowing a name the session *already* has is allowed and is not a collision.
336304
The registry holds every DataFusion built-in, and overriding built-ins by name
337305
is a supported thing to do — `enable_spark_functions` is built on it.
338306

307+
Since the caller cannot repair a collision from their own code, name your
308+
functions so this does not arise: a prefix tying them to your library is the
309+
usual answer.
310+
311+
(extension_bundles_transaction)=
312+
313+
## Failure and rollback
314+
315+
Nothing is written to the session until every factory has returned and every
316+
component has been validated, so a factory that raises leaves the session
317+
exactly as it was. A factory that mutates the context it is handed —
318+
registering a table, say — is **not** rolled back, which is why bundle objects
319+
must be configuration-only: create fresh components on each call, never cache
320+
bound components, and do not retain the context passed in.
321+
322+
Declaring a component is what buys you that guarantee, and it is the whole
323+
reason to prefer `udfs=(...)` over a `register_udf` call inside your hook.
324+
Anything you declare is resolved and checked while a failure still costs
325+
nothing, and is written only after every bundle in the call has succeeded.
326+
Anything you register yourself is written immediately, before the other bundles
327+
have even run. The ordering that makes this hold is recorded at
328+
{ref}`ffi_internals_commit_order`.
329+
339330
Like every other derivation, the returned context is a handle on the *same*
340331
session as the receiver — see {ref}`extension_sessions`. Only the Python-side
341332
codec chains belong to the returned handle; the planner is installed on the

docs/source/extension-guide/functions.md

Lines changed: 31 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,11 @@ the same registration methods.
3333
| `__datafusion_window_udf__` | window function | {py:func}`datafusion.udwf` | {py:meth}`~datafusion.SessionContext.register_udwf` | `udwfs` |
3434
| `__datafusion_table_function__` | function returning a table | {py:func}`datafusion.udtf` | {py:meth}`~datafusion.SessionContext.register_udtf` ||
3535

36-
All four are implemented in [`datafusion-ffi-example`], one per file.
36+
All four are implemented in [`datafusion-ffi-example`], one per file. The last
37+
column is the {py:class}`~datafusion.SessionExtensionComponents` field a
38+
{ref}`bundle <extension_bundles>` declares the function in; table functions
39+
have no such field yet, so they are always registered by the caller with
40+
{py:meth}`~datafusion.SessionContext.register_udtf`.
3741

3842
## The three scalar-shaped hooks
3943

@@ -67,21 +71,38 @@ from datafusion import udf
6771
ctx.register_udf(udf(my_library.MyScalarUDF()))
6872
```
6973

70-
If your library ships more than a function or two, declare them on a bundle
71-
instead and let one call install everything:
74+
If your library ships more than a function or two, do not make your users write
75+
that line once per function. Ship a {ref}`bundle <extension_bundles>` declaring
76+
them, so one call installs the lot:
7277

7378
```python
74-
class MyLibraryExtension:
75-
def __datafusion_session_components__(self, ctx):
76-
return SessionExtensionComponents(udfs=(my_library.MyScalarUDF(),))
79+
ctx = SessionContext().with_extensions(my_library.MyFunctionExtension())
80+
```
81+
82+
The bundle is yours to write, and like the rest of the protocol it is an object
83+
exposing a getter — which your cdylib can export directly. That is what
84+
`MyFunctionExtension` in [`datafusion-ffi-example`] does for this crate's three
85+
functions; spelled in Python, it is:
86+
87+
```python
88+
from datafusion import SessionExtensionComponents
7789

7890

79-
ctx = SessionContext().with_extensions(MyLibraryExtension())
91+
class MyFunctionExtension:
92+
def __datafusion_session_components__(self, ctx):
93+
return SessionExtensionComponents(
94+
udfs=(IsNullUDF(),),
95+
udafs=(MySumUDF(),),
96+
udwfs=(MyRankUDF(),),
97+
)
8098
```
8199

82-
Either the raw exportable or an already-wrapped
83-
{py:class}`~datafusion.user_defined.ScalarUDF` is accepted; the name comes off
84-
the capsule either way. See {ref}`extension_bundles`.
100+
Declare either the raw exportable, as here, or an already-wrapped
101+
{py:class}`~datafusion.user_defined.ScalarUDF`; the registered name comes off
102+
the function either way. Declare rather than calling `register_udf` inside the
103+
hook — see {ref}`extension_bundles_transaction` for why — and pick names that
104+
will not collide with another library's
105+
({ref}`extension_bundles_collisions`).
85106

86107
## Table functions
87108

docs/source/user-guide/extensions.md

Lines changed: 46 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -32,9 +32,9 @@ which exposes Delta Lake tables to DataFusion, and the two worked examples in
3232
this repository under
3333
[`examples/`](https://github.com/apache/datafusion-python/tree/main/examples).
3434

35-
## Two kinds of extension
35+
## How an extension reaches your session
3636

37-
Which one you have determines how much setup you do.
37+
Which route your library takes determines how much setup you do.
3838

3939
**Tables register directly.** If the library gives you a table, register it
4040
the same way you would register a CSV file. No extra setup:
@@ -63,17 +63,25 @@ ctx.register_table("events", my_engine.TableProvider("s3://bucket/events"))
6363
ctx.sql("SELECT count(*) FROM events").show()
6464
```
6565

66+
**Functions arrive by whichever route their library chose.** A library
67+
offering one or two functions hands you the functions themselves, and you wrap
68+
and register each:
69+
70+
```python
71+
from datafusion import udf
72+
73+
ctx.register_udf(udf(my_library.MyScalarUDF()))
74+
```
75+
76+
A library shipping a set of them packages them in its `Extension` object
77+
instead, so `with_extensions` installs them all along with everything else it
78+
provides, and there is nothing per-function for you to do. Its documentation
79+
says which.
80+
6681
`with_extensions` returns a context; use the returned one. It shares
6782
everything else with the context you called it on, so tables you registered
6883
before the call are still there.
6984

70-
**Functions can arrive either way.** A single function is registered directly
71-
with {py:func}`~datafusion.udf` and
72-
{py:meth}`~datafusion.SessionContext.register_udf`. A library shipping a set of
73-
them usually packages them in the same `Extension` object instead, so
74-
`with_extensions` installs them along with everything else it provides. Follow
75-
whichever the library documents.
76-
7785
## Using more than one library
7886

7987
Pass them all to a single call:
@@ -91,7 +99,24 @@ rarely matters. When a library needs a particular position — usually "list me
9199
last" for something that wraps the others — it says so in its own
92100
documentation.
93101

94-
## Two things that will bite you
102+
## Three things that will bite you
103+
104+
**Two libraries can claim one function name.** If both ship a function of the
105+
same kind under the same name, the call raises a `ValueError` naming both,
106+
rather than letting one silently replace the other:
107+
108+
```text
109+
ValueError: Two extensions declare a scalar function 'normalize': ...
110+
```
111+
112+
You cannot rename another library's function from your own code, so the fix is
113+
to use two sessions, one per library, and query each for what only it provides.
114+
Worth reporting upstream too: the library whose names are the less specific
115+
should be prefixing them. A function shadowing a *built-in* is not a collision
116+
and raises nothing — that is a supported thing for a library to do. See
117+
{ref}`extension_bundles_collisions`.
118+
119+
95120

96121
**Keep your context alive.** A `DataFrame` or a plan does not keep its session
97122
alive on its own. If a context is garbage-collected while something built from
@@ -139,6 +164,17 @@ ctx.logical_extension_codec_ids()
139164

140165
An empty list means nothing extra is installed.
141166

167+
For functions, {py:meth}`~datafusion.SessionContext.udfs`,
168+
{py:meth}`~datafusion.SessionContext.udafs` and
169+
{py:meth}`~datafusion.SessionContext.udwfs` return the names a session knows.
170+
Both the library's and every DataFusion built-in are in there, so look for the
171+
name rather than reading the whole list:
172+
173+
```python
174+
"my_engine_normalize" in ctx.udfs()
175+
# True
176+
```
177+
142178
## Next steps
143179

144180
- {ref}`distributed_query_engines` — running your queries across several

python/datafusion/context.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2178,7 +2178,7 @@ def with_extensions(
21782178
# commit can fail belongs above, split into an import step that returns
21792179
# a resolved object and an insert step that cannot raise -- there is one
21802180
# session here, shared with the receiver, so a failure part-way through
2181-
# has nothing to roll back to. See :ref:`extension_bundles_transaction`.
2181+
# has nothing to roll back to. See :ref:`ffi_internals_commit_order`.
21822182
if planner is not None or logical_codecs or physical_codecs:
21832183
new.ctx._install_extension_planner(planner)
21842184
for function in resolved_udfs:

0 commit comments

Comments
 (0)