Skip to content

feat(python/sedonadb): Add a native scalar UDF import path for plugins - #1146

Merged
james-willis merged 4 commits into
apache:mainfrom
james-willis:udf-native-import
Aug 14, 2026
Merged

feat(python/sedonadb): Add a native scalar UDF import path for plugins#1146
james-willis merged 4 commits into
apache:mainfrom
james-willis:udf-native-import

Conversation

@james-willis

@james-willis james-willis commented Aug 10, 2026

Copy link
Copy Markdown
Contributor

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 a PyCapsule, the same way sedona-extension's SedonaCScalarKernel ABI is already used to statically link in kernels at build time (see c/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 is arrow_udf/sedona_scalar_udf -- a Python callable invoked once per batch. There's no way for an out-of-tree crate (depending on sedona-schema/sedona-expr directly, like a prototype extension building a real SedonaScalarKernel with its own ArgMatcher-based dispatch) to register that kernel and get real Rust dispatch from a Python SedonaContext -- 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 a PyCapsule wrapping a SedonaCScalarKernel 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): a standalone pyfunction building a PySedonaScalarUdf from 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 existing sedona_scalar_udf. Also the documented escape hatch for a kernel that needs Volatile/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 to register()'s supported_interfaces and docstring.

Two real limitations, documented rather than hidden

  • Registration replaces, it doesn't accumulate. Grouping by name is scoped to one __sedonadb_native_scalar_udfs__() call's own capsule list -- it does not accumulate 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 -- a plain HashMap insert, 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 of register_scalar_kernels.
  • Volatility is always Immutable through this protocol -- a bare capsule has nowhere to carry a volatility value, and this is not because every native kernel is Immutable (RS_FromPath is Volatile). A plugin kernel that needs Volatile/Stable should call sedona_native_scalar_udf(kernels, volatility=...) directly and return the resulting PySedonaScalarUdf via the existing __sedonadb_internal_udf__ protocol instead.

What's NOT in it

  • Aggregate UDFs. 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. This PR is scoped to the scalar case; the aggregate ABI is a separate design question.
  • A Python-exposed way to build an ArgMatcher::is_extension(name)-style matcher (import_arg_matcher still only recognizes the fixed literal set). Not a blocker here: a plugin's own ArgMatcher lives entirely inside its compiled kernel's return_type() and never crosses the Python boundary, so this only matters for someone trying to express that matcher from pure Python via arrow_udf.

Verification

  • 10 real, executed tests (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) and probe_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 live SedonaContext against an imported native kernel.
  • Getting real coverage 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 (pyproject.toml), never by this crate's Cargo.toml -- so it's never present during cargo test, including cargo test --all-features (CI's actual invocation). Confirmed directly that the two configurations don't collide: explicitly compiling with --features pyo3/extension-module does fail to link (undefined libpython symbols), but that's never what cargo test requests.
  • cargo check/clippy --all-targets --all-features -- -D warnings/fmt --all -- --check for the whole workspace (excluding the environment-only gdal-sys bindgen issue on this machine, unrelated to this change) -- clean.
  • Built the real wheel (maturin develop --release) and ran the full existing python/sedonadb pytest suite against it: 2543 passed, 1753 skipped (optional deps not installed in this verification venv) -- zero regressions from this purely additive change.

@github-actions
github-actions Bot requested a review from paleolimbot August 10, 2026 21:19
@james-willis
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.

@paleolimbot paleolimbot left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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!

Comment thread python/sedonadb/src/context.rs Outdated
Comment thread python/sedonadb/src/context.rs Outdated
Comment thread python/sedonadb/src/import_from.rs
Comment thread python/sedonadb/src/udf.rs
Comment thread python/sedonadb/Cargo.toml
Comment thread python/sedonadb/python/sedonadb/context.py Outdated

@paleolimbot paleolimbot left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread python/sedonadb/tests/test_udf.py Outdated
Comment thread python/sedonadb/python/sedonadb/context.py Outdated
Comment thread python/sedonadb/src/context.rs Outdated

@paleolimbot paleolimbot left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thank you!

@paleolimbot paleolimbot changed the title Add a native scalar UDF import path for plugins feat(python/sedonadb): Add a native scalar UDF import path for plugins Aug 14, 2026
@james-willis
james-willis merged commit 820dbc4 into apache:main Aug 14, 2026
17 checks passed
@james-willis
james-willis deleted the udf-native-import branch August 14, 2026 17:12
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants