Skip to content

Commit ebb8943

Browse files
timsaucerclaude
andcommitted
refactor: fold the planner hooks and the commit into one Rust call
The tail of `with_extensions` was three private pymethods and two Python loops: nest the planner hooks, re-export each return as a capsule, bind the planner, then walk a table of bound `register_*` methods. Replace all of it with one `_commit_extensions` primitive that runs the hooks and commits everything the bundles declared, so the ordering contract lives in a single function next to the reasoning it answers to, and the boundary drops `_export_query_planner` and `_install_extension_planner`. The Python side keeps everything that reads better in Python: protocol dispatch, collision messages naming argument positions, and the resolve step. Each function kind is now its own `_commit_extensions` parameter, so `_FunctionKind` loses its `register` member -- a kind that resolves but never commits cannot be written, because the call's arity refuses it. The hook loop dispatches on the presence of `__datafusion_session_planner__`, which is the same question the runtime-checkable protocol asked. Behaviour is pinned unchanged: no test assertion moves beyond the private-method allowlist and the dropped `register` line in the meta-test. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 5c2b6c7 commit ebb8943

4 files changed

Lines changed: 114 additions & 108 deletions

File tree

crates/core/src/context.rs

Lines changed: 78 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -1663,10 +1663,9 @@ impl PySessionContext {
16631663
/// **Writes nothing.** The codec chains belong to the returned handle
16641664
/// rather than to `SessionState`, so this phase is transactional for free:
16651665
/// a codec that fails to import, or that collides with an installed id,
1666-
/// leaves the caller's context exactly as it was. Binding the planner is
1667-
/// the only step that touches the session, and it is deferred to
1668-
/// [`Self::_install_extension_planner`] so the planner hooks can run
1669-
/// against the final chains.
1666+
/// leaves the caller's context exactly as it was. Everything that touches
1667+
/// the session is deferred to [`Self::_commit_extensions`] so the planner
1668+
/// hooks can run against the final chains.
16701669
///
16711670
/// Codecs must arrive as objects exposing the capsule getter, never as
16721671
/// bare capsules — see [`resolve_bundle_codec_id`].
@@ -1717,49 +1716,87 @@ impl PySessionContext {
17171716
})
17181717
}
17191718

1720-
/// Re-export a planner a `__datafusion_session_planner__` hook returned as
1721-
/// a capsule, so the next hook in the chain receives one either way.
1719+
/// Run the planner hooks and commit a `with_extensions` call.
17221720
///
1723-
/// A hook may hand back an object exposing `__datafusion_query_planner__`
1724-
/// or a raw capsule; the next hook wraps whatever it is given and should
1725-
/// not have to branch on which. Importing here also surfaces a malformed
1726-
/// planner at the hook that produced it rather than at the final install.
1727-
/// Writes nothing.
1728-
pub fn _export_query_planner<'py>(
1729-
slf: &Bound<'py, Self>,
1730-
planner: Bound<'py, PyAny>,
1731-
) -> PyDataFusionResult<Bound<'py, PyCapsule>> {
1732-
let ffi = ffi_query_planner_from_pycapsule(&planner, Some(slf.as_any()))?;
1733-
Ok(create_query_planner_capsule(slf.py(), &ffi)?)
1734-
}
1735-
1736-
/// Commit the query planner for a `with_extensions` call.
1721+
/// The second phase, run on the handle carrying the completed chains —
1722+
/// `session` is that same handle as the Python-level wrapper, which is
1723+
/// what each `__datafusion_session_planner__` hook receives. The hooks
1724+
/// run first, **in argument order**, each handed the planner built so
1725+
/// far as a capsule; a hook may hand back an object exposing
1726+
/// `__datafusion_query_planner__` or a raw capsule, and each return is
1727+
/// imported here so a malformed planner surfaces at the hook that
1728+
/// produced it rather than at the install. Returning `None` contributes
1729+
/// no planner. All of that writes nothing, so a hook that raises leaves
1730+
/// the session exactly as it was.
17371731
///
1738-
/// The second phase, run once every codec is installed and every planner
1739-
/// hook has returned, so the planner is bound against the final chains.
1740-
/// This is the one call in `with_extensions` that writes to the session,
1741-
/// and it goes through this context's own `state_ref()`, so providers
1742-
/// bound to it stay valid.
1732+
/// Everything after the hooks is the commit, and none of it can fail: a
1733+
/// registration whose commit can fail belongs in the resolve step, split
1734+
/// into an import that returns a resolved object and an insert that
1735+
/// cannot raise. There is one session here, shared with the receiver, so
1736+
/// a failure part-way through would have nothing to roll back to. The
1737+
/// reasoning is in docs/source/contributor-guide/ffi-internals.md, under
1738+
/// "Why `with_extensions` commits last".
17431739
///
1744-
/// `None` means no bundle supplied a planner. That still rebuilds
1745-
/// whichever planner the session already holds against the new chains,
1746-
/// exactly as `with_logical_extension_codec` does, and writes nothing at
1747-
/// all if the session has no FFI planner to rebuild.
1740+
/// The planner is bound through this context's own `state_ref()`, so
1741+
/// providers bound to it stay valid. With no planner supplied the bind
1742+
/// still rebuilds whichever planner the session already holds against
1743+
/// the new chains, exactly as `with_logical_extension_codec` does —
1744+
/// unless `rebind_planner` is also false, meaning the call installed no
1745+
/// codec either. Then the bind is skipped entirely, the same way
1746+
/// [`Self::with_python_udf_inlining`] returns early for a no-op toggle:
1747+
/// there is nothing to rebind against, and the rebuild would drag a
1748+
/// planner sitting on another handle's codecs onto this one's.
17481749
///
1749-
/// The caller skips this step entirely when the call installed no codec
1750-
/// and no planner, the same way [`Self::with_python_udf_inlining`] returns
1751-
/// early for a no-op toggle: there is nothing to rebind against, and the
1752-
/// rebuild would drag a planner sitting on another handle's codecs onto
1753-
/// this one's.
1754-
#[pyo3(signature = (planner=None))]
1755-
pub fn _install_extension_planner<'py>(
1750+
/// The functions are registered *after* the planner hooks have run, so a
1751+
/// hook never sees this call's functions in the registry — the
1752+
/// registrations have no fall-through, and a name is free to shadow one
1753+
/// the session already had.
1754+
pub fn _commit_extensions<'py>(
17561755
slf: &Bound<'py, Self>,
1757-
planner: Option<Bound<'py, PyAny>>,
1756+
extensions: Vec<Bound<'py, PyAny>>,
1757+
session: Bound<'py, PyAny>,
1758+
rebind_planner: bool,
1759+
udfs: Vec<PyScalarUDF>,
1760+
udafs: Vec<PyAggregateUDF>,
1761+
udwfs: Vec<PyWindowUDF>,
17581762
) -> PyDataFusionResult<()> {
1759-
let planner = planner
1760-
.map(|planner| ffi_query_planner_from_pycapsule(&planner, Some(slf.as_any())))
1761-
.transpose()?;
1762-
slf.borrow().set_session_query_planner(planner);
1763+
let py = slf.py();
1764+
// Nest the planners, outermost last. `planner` stays `None` when no
1765+
// bundle supplies one, which leaves an already-installed planner in
1766+
// place rather than wrapping the session's default in an FFI hop.
1767+
let mut planner: Option<FFI_QueryPlanner> = None;
1768+
for extension in &extensions {
1769+
if !extension.hasattr("__datafusion_session_planner__")? {
1770+
continue;
1771+
}
1772+
let fallback = match &planner {
1773+
Some(ffi) => create_query_planner_capsule(py, ffi)?,
1774+
None => slf.borrow().__datafusion_query_planner__(py, None)?,
1775+
};
1776+
let supplied =
1777+
extension.call_method1("__datafusion_session_planner__", (&session, fallback))?;
1778+
if supplied.is_none() {
1779+
continue;
1780+
}
1781+
planner = Some(ffi_query_planner_from_pycapsule(
1782+
&supplied,
1783+
Some(slf.as_any()),
1784+
)?);
1785+
}
1786+
1787+
if planner.is_some() || rebind_planner {
1788+
slf.borrow().set_session_query_planner(planner);
1789+
}
1790+
let this = slf.borrow();
1791+
for udf in udfs {
1792+
this.ctx.register_udf(udf.function);
1793+
}
1794+
for udaf in udafs {
1795+
this.ctx.register_udaf(udaf.function);
1796+
}
1797+
for udwf in udwfs {
1798+
this.ctx.register_udwf(udwf.function);
1799+
}
17631800
Ok(())
17641801
}
17651802
}

python/datafusion/context.py

Lines changed: 32 additions & 61 deletions
Original file line numberDiff line numberDiff line change
@@ -162,13 +162,15 @@ def __datafusion_physical_optimizer_rule__(self) -> object: ... # noqa: D105
162162

163163

164164
class _FunctionKind(NamedTuple):
165-
"""How one kind of declared function is resolved and registered.
165+
"""How one kind of declared function is resolved.
166166
167167
One row per function field on
168168
:py:class:`~datafusion.extensions.SessionExtensionComponents`, so adding a
169169
kind is adding a row rather than editing three places. The dataclass
170170
metadata says which fields are collections to normalize; this table says
171-
which of them are functions and what to do with one.
171+
which of them are functions and how to wrap one. Committing is not here:
172+
each kind is its own ``_commit_extensions`` parameter, so a kind that
173+
resolves but never commits cannot be written.
172174
``test_every_component_field_has_an_installer`` pins the two together.
173175
174176
The members naming a ``datafusion.user_defined`` object hold its name
@@ -192,9 +194,6 @@ class _FunctionKind(NamedTuple):
192194
label: str
193195
"""What to call this sort of function in an error message."""
194196

195-
register: str
196-
""":py:class:`SessionContext` method that commits one to the session."""
197-
198197

199198
_FUNCTION_KINDS = (
200199
_FunctionKind(
@@ -203,23 +202,20 @@ class _FunctionKind(NamedTuple):
203202
getter="__datafusion_scalar_udf__",
204203
factory="udf",
205204
label="scalar function",
206-
register="register_udf",
207205
),
208206
_FunctionKind(
209207
field="udafs",
210208
wrapper="AggregateUDF",
211209
getter="__datafusion_aggregate_udf__",
212210
factory="udaf",
213211
label="aggregate function",
214-
register="register_udaf",
215212
),
216213
_FunctionKind(
217214
field="udwfs",
218215
wrapper="WindowUDF",
219216
getter="__datafusion_window_udf__",
220217
factory="udwf",
221218
label="window function",
222-
register="register_udwf",
223219
),
224220
)
225221
"""Every kind of function a bundle can declare, in registration order."""
@@ -2197,63 +2193,38 @@ def with_extensions(
21972193
# Resolve every declared function to the wrapper that registers it, and
21982194
# settle name collisions, while a failure still costs nothing. None of
21992195
# these getters take an argument, so unlike a provider they do not care
2200-
# which handle they are resolved against. The bound `register_*` method
2201-
# is looked up here too, leaving the commit below nothing but calls.
2196+
# which handle they are resolved against.
22022197
from datafusion import user_defined as _user_defined # noqa: PLC0415
22032198

2204-
resolved: list[tuple[Any, list[Any]]] = [
2205-
(
2206-
getattr(new, kind.register),
2207-
_resolve_declared_functions(
2208-
declared[kind.field],
2209-
getattr(_user_defined, kind.wrapper),
2210-
kind.getter,
2211-
getattr(_user_defined, kind.factory),
2212-
kind.label,
2213-
),
2199+
resolved: dict[str, list[Any]] = {
2200+
kind.field: _resolve_declared_functions(
2201+
declared[kind.field],
2202+
getattr(_user_defined, kind.wrapper),
2203+
kind.getter,
2204+
getattr(_user_defined, kind.factory),
2205+
kind.label,
22142206
)
22152207
for kind in _FUNCTION_KINDS
2216-
]
2217-
2218-
# Phase two: nest the planners, outermost last. Each hook runs against
2219-
# `new`, which carries the final chains, so a planner captured here
2220-
# never sees a partial codec set. `planner` stays None when no bundle
2221-
# supplies one, which leaves an already-installed planner in place
2222-
# rather than wrapping the session's default in an FFI hop.
2223-
planner: _PyCapsule | None = None
2224-
for extension in extensions:
2225-
if not isinstance(extension, SessionPlannerExportable):
2226-
continue
2227-
fallback = (
2228-
planner
2229-
if planner is not None
2230-
else new.ctx.__datafusion_query_planner__()
2231-
)
2232-
supplied = extension.__datafusion_session_planner__(new, fallback)
2233-
if supplied is None:
2234-
continue
2235-
planner = new.ctx._export_query_planner(supplied)
2236-
2237-
# Rebinding the session's planner is a side effect on state shared with
2238-
# every other handle, so do not pay it for a call that installs nothing
2239-
# -- the same guard `with_python_udf_inlining` carries. With no codec
2240-
# installed the chains the planner would be rebuilt against are the ones
2241-
# it already holds, so the rebuild is unobservable except in the one case
2242-
# where it does harm: a planner sitting on some *other* handle's codecs
2243-
# gets dragged onto this handle's, silently undoing that install.
2244-
2245-
# Commit. Everything below this line must be infallible. A registration
2246-
# whose commit can fail belongs above, split into an import step that
2247-
# returns a resolved object and an insert step that cannot raise --
2248-
# there is one session here, shared with the receiver, so a failure
2249-
# part-way through has nothing to roll back to. The reasoning is in
2250-
# docs/source/contributor-guide/ffi-internals.md, under "Why
2251-
# `with_extensions` commits last".
2252-
if planner is not None or logical_codecs or physical_codecs:
2253-
new.ctx._install_extension_planner(planner)
2254-
for register, functions in resolved:
2255-
for function in functions:
2256-
register(function)
2208+
}
2209+
2210+
# Phase two: run the planner hooks and commit, in one call. Each hook
2211+
# runs against `new`, which carries the final chains, so a planner
2212+
# captured there never sees a partial codec set. The hook loop, the
2213+
# ordering of the commit, and the guard that skips the planner rebind
2214+
# for a call that installs nothing all live on the Rust side -- see
2215+
# `_commit_extensions` and docs/source/contributor-guide/
2216+
# ffi-internals.md, under "Why `with_extensions` commits last". A new
2217+
# component field must be resolved above and given its own
2218+
# `_commit_extensions` parameter; the call's arity is what keeps a
2219+
# declared component from being quietly dropped.
2220+
new.ctx._commit_extensions(
2221+
list(extensions),
2222+
new,
2223+
bool(logical_codecs or physical_codecs),
2224+
[function._udf for function in resolved["udfs"]],
2225+
[function._udaf for function in resolved["udafs"]],
2226+
[function._udwf for function in resolved["udwfs"]],
2227+
)
22572228
return new
22582229

22592230
def table_provider(self, name: str) -> Table:

python/tests/test_context.py

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1714,7 +1714,6 @@ def test_every_function_kind_names_something_real():
17141714
for kind in _FUNCTION_KINDS:
17151715
assert isinstance(getattr(user_defined, kind.wrapper), type)
17161716
assert callable(getattr(user_defined, kind.factory))
1717-
assert callable(getattr(SessionContext, kind.register))
17181717
assert kind.field in {spec.name for spec in fields(SessionExtensionComponents)}
17191718

17201719

python/tests/test_wrapper_coverage.py

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -33,12 +33,11 @@
3333
# gap in coverage.
3434
PRIVATE_SUPPORT_METHODS = frozenset(
3535
{
36-
# The three steps of SessionContext.with_extensions: install the
37-
# codecs, re-export each planner hook's return value as a capsule,
38-
# commit the planner.
36+
# The two steps of SessionContext.with_extensions: install the
37+
# codecs, then run the planner hooks and commit everything the
38+
# bundles declared.
3939
"_install_extension_codecs",
40-
"_export_query_planner",
41-
"_install_extension_planner",
40+
"_commit_extensions",
4241
}
4342
)
4443

0 commit comments

Comments
 (0)