feat(python/sedonadb): Add a native scalar UDF import path for plugins - #1146
Merged
Conversation
james-willis
marked this pull request as ready for review
August 10, 2026 21:32
An out-of-tree plugin can now hand SedonaDB a real SedonaScalarKernel
(compiled Rust, no Python callback per invocation) via a PyCapsule,
using the same SedonaCScalarKernel ABI sedona-extension already uses to
statically link in kernels at build time (see c/sedona-s2geography) --
this is the runtime counterpart of that mechanism.
SedonaContext.register() gains a new protocol,
__sedonadb_native_scalar_udfs__(self) -> list[PyCapsule], alongside the
existing __sedonadb_internal_udf__ (Python-callable kernels only).
- import_sedona_ffi_scalar_kernel (import_from.rs): imports a capsule
into a real ScalarKernelRef, reading the kernel's own declared name.
Mirrors import_sedona_ffi_table_provider's existing pattern exactly,
including the double-free-prevention ptr::read + ptr::write_bytes
zeroing.
- sedona_native_scalar_udf (udf.rs): builds a PySedonaScalarUdf from one
or more same-named kernel capsules -- errors on a name mismatch rather
than silently registering under the wrong name. Also the documented
escape hatch for a kernel needing Volatile/Stable, since
__sedonadb_native_scalar_udfs__ always registers Immutable.
- register_component()'s new branch groups a plugin's capsules by each
kernel's own declared name before registering. This grouping is scoped
to one call's own capsule list, not accumulated across calls the way
SedonaContext::register_scalar_kernels accumulates statically-linked
kernels -- registering under a name already in use (a plugin's own, a
different plugin's, or a built-in's) replaces it outright, the same
as the existing __sedonadb_internal_udf__ path already does.
Real, executed tests (10, all passing under `cargo test -p sedonadb
--all-features`, matching CI's invocation): capsule import + functional
kernel invocation, rejecting a wrong capsule name, rejecting a second
import of an already-consumed capsule, rejecting non-capsule input,
building a working UDF, grouping two disjoint-type kernels into one
overloaded UDF and dispatching both via real SQL, rejecting mismatched
kernel names, rejecting an empty kernel list, an explicit name override,
and a full SQL query executed through a live SedonaContext against an
imported native kernel.
Getting real coverage here needed one addition:
`pyo3 = { workspace = true, features = ["auto-initialize"] }` under
`[dev-dependencies]`. `extension-module` (needed for the real wheel
build) is only ever added by maturin's own build flags, never by this
crate's Cargo.toml, so it's never present during `cargo test` --
confirmed directly, including that the two configurations don't
collide (explicitly compiling with `--features pyo3/extension-module`
fails to link, as expected, but that's never what `cargo test`/`cargo
test --all-features` request).
Aggregate UDFs remain out of scope: SedonaCScalarKernel has no
aggregate equivalent yet -- an accumulator's stateful lifecycle
(create/update/merge/evaluate/state/size, to participate correctly in
DataFusion's own parallel aggregation) needs a materially larger C ABI
than a stateless scalar kernel's. Separate design question.
Verified directly: cargo check/clippy --all-features --all-targets
-D warnings/fmt clean across the workspace, and the full existing
python/sedonadb pytest suite (2543 passed, 1753 skipped for optional
deps not installed in this verification venv) against the real built
wheel shows zero regressions from this purely additive change.
james-willis
force-pushed
the
udf-native-import
branch
from
August 10, 2026 22:32
8232d23 to
6fd9732
Compare
paleolimbot
reviewed
Aug 12, 2026
paleolimbot
left a comment
Member
There was a problem hiding this comment.
Thank you!
I think we should do individual instead of the list of functions and ensure we can export as well (which enables us to write a roundtrip test at the Python level), but this will be great!
paleolimbot
reviewed
Aug 13, 2026
paleolimbot
left a comment
Member
There was a problem hiding this comment.
Two loose ends worth wrapping up here...I think this is a good API (i.e., caller groups kernels instead of context) but there are references to internals/protected members that should get cleaned up first.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What
Adds a runtime import path for natively-compiled scalar UDFs: an out-of-tree plugin can now hand SedonaDB a real
SedonaScalarKernel(real compiled Rust, no Python callback per invocation) via aPyCapsule, the same waysedona-extension'sSedonaCScalarKernelABI is already used to statically link in kernels at build time (seec/sedona-s2geography) -- this is the runtime counterpart of that same mechanism.Concretely:
SedonaContext.register()gains a new protocol,__sedonadb_native_scalar_udfs__(self) -> list[PyCapsule], alongside the existing__sedonadb_internal_udf__/__sedonadb_internal_aggregate_udf__/__sedonadb_external_format__/__sedonadb_raster_loader__.Why
Today,
SedonaContext.register()'s only path for a scalar function isarrow_udf/sedona_scalar_udf-- a Python callable invoked once per batch. There's no way for an out-of-tree crate (depending onsedona-schema/sedona-exprdirectly, like a prototype extension building a realSedonaScalarKernelwith its ownArgMatcher-based dispatch) to register that kernel and get real Rust dispatch from a PythonSedonaContext-- only a Python-callback wrapper around it. This closes that gap using the ABI SedonaDB already has, rather than inventing a new one.What's in it
import_sedona_ffi_scalar_kernel(import_from.rs): imports aPyCapsulewrapping aSedonaCScalarKernelinto a realScalarKernelRef, reading the kernel's own declared name. Mirrorsimport_sedona_ffi_table_provider's existing pattern exactly, including the double-free-preventionptr::read+ptr::write_byteszeroing.sedona_native_scalar_udf(udf.rs): a standalone pyfunction building aPySedonaScalarUdffrom one or more same-named kernel capsules (errors on a name mismatch rather than silently registering under the wrong name) -- usable directly, the native-kernel analog of the existingsedona_scalar_udf. Also the documented escape hatch for a kernel that needsVolatile/Stable(see below).register_component()(context.rs): the new__sedonadb_native_scalar_udfs__branch. A plugin's capsules can span multiple distinct function names in one call; grouped by each kernel's own declared name before registering.context.py:__sedonadb_native_scalar_udfs__added toregister()'ssupported_interfacesand docstring.Two real limitations, documented rather than hidden
__sedonadb_native_scalar_udfs__()call's own capsule list -- it does not accumulate across calls the waySedonaContext::register_scalar_kernelsaccumulates statically-linked kernels. Registering under a name already in use (a plugin's own, a different plugin's, or a built-in's) replaces it outright -- a plainHashMapinsert, the same as the existing__sedonadb_internal_udf__path already does. Not a new risk this PR introduces, but worth being explicit about in the code rather than implying false parity with the accumulate-behavior ofregister_scalar_kernels.Immutablethrough this protocol -- a bare capsule has nowhere to carry a volatility value, and this is not because every native kernel isImmutable(RS_FromPathisVolatile). A plugin kernel that needsVolatile/Stableshould callsedona_native_scalar_udf(kernels, volatility=...)directly and return the resultingPySedonaScalarUdfvia the existing__sedonadb_internal_udf__protocol instead.What's NOT in it
SedonaCScalarKernelhas no aggregate equivalent yet -- an accumulator's stateful lifecycle (create/update/merge/evaluate/state/size, to participate correctly in DataFusion's own parallel aggregation) needs a materially larger C ABI than a stateless scalar kernel's. This PR is scoped to the scalar case; the aggregate ABI is a separate design question.ArgMatcher::is_extension(name)-style matcher (import_arg_matcherstill only recognizes the fixed literal set). Not a blocker here: a plugin's ownArgMatcherlives entirely inside its compiled kernel'sreturn_type()and never crosses the Python boundary, so this only matters for someone trying to express that matcher from pure Python viaarrow_udf.Verification
import_from.rs,udf.rs): capsule import + functional kernel invocation, rejecting a wrong capsule name, rejecting a second import of an already-consumed capsule, rejecting non-capsule input, building a working UDF, grouping two disjoint-type kernels into one overloaded UDF and dispatching both correctly via real SQL (probe_grouped(42)andprobe_grouped(4.5)each hitting their own kernel), rejecting mismatched kernel names, rejecting an empty kernel list, an explicit name override, and a full SQL query executed through a liveSedonaContextagainst an imported native kernel.pyo3 = { workspace = true, features = ["auto-initialize"] }under[dev-dependencies].extension-module(needed for the real wheel build) is only ever added bymaturin's own build flags (pyproject.toml), never by this crate'sCargo.toml-- so it's never present duringcargo test, includingcargo test --all-features(CI's actual invocation). Confirmed directly that the two configurations don't collide: explicitly compiling with--features pyo3/extension-moduledoes fail to link (undefined libpython symbols), but that's never whatcargo testrequests.cargo check/clippy --all-targets --all-features -- -D warnings/fmt --all -- --checkfor the whole workspace (excluding the environment-onlygdal-sysbindgen issue on this machine, unrelated to this change) -- clean.maturin develop --release) and ran the full existingpython/sedonadbpytest suite against it: 2543 passed, 1753 skipped (optional deps not installed in this verification venv) -- zero regressions from this purely additive change.