feat: custom Rust UDFs via arrow-ffi [experimental] - #4459
Conversation
…ip ci] Adds custom Rust scalar UDF support using arrow-only FFI surfaces, as suggested by paleolimbot and timsaucer in feedback on apache#4283. This is an alternative implementation for comparison; see apache#4283 for the bespoke-ABI version. Two ABI flavors are provided side-by-side so reviewers can compare: 1. C ABI (sedona-style): pure C-callable struct of function pointers, parameterized only by Arrow C Data Interface (FFI_ArrowSchema / FFI_ArrowArray). Decoupled from datafusion versions; future-portable to C/C++. Modeled on apache/sedona-db's SedonaCScalarKernel header. 2. datafusion-ffi (FFI_ScalarUDF): wraps user's ScalarUDFImpl as FFI_ScalarUDF. Inherits full ScalarUDFImpl surface (variadic signatures, type coercion, metadata-aware return types) for free, at the cost of a major-version pin against datafusion-ffi. A single library may export either or both; loader walks both discovery functions. `comet-test-udfs` exposes `add_one_c` (C ABI) and `add_one_df` (datafusion-ffi) and the e2e suite drives both through Spark. Scope is intentionally minimal for comparison: scalar-only, happy-path e2e tests only (no panic/error/signature-mismatch coverage). The Scala JVM API mirrors apache#4283 exactly so the comparison is apples-to-apples. Pieces: - native/comet-udf-sdk: SDK with both ABI flavors + export macros - native/comet-test-udfs: cdylib exposing one UDF per ABI - native/core/src/execution/rust_udf: loader, cache, ImportedCScalarUdf - native/core/src/comet_rust_udf_bridge.rs: JNI for validateLibrary/listUdfs - native/proto: RustUdfCall message - spark/.../udf: CometRustUDF.register, registry, exception classes, JNI bridge stub - spark/.../serde/CometScalaUDF: dispatch ScalaUDF to RustUdfCall when udfName is in the registry
paleolimbot
left a comment
There was a problem hiding this comment.
Thank you for putting this together!
| // Filesystem path of the cdylib. | ||
| string library_path = 2; |
There was a problem hiding this comment.
I see the motivation for having this primarily live as shared objects! I am not sure if there's an opportunity / whether it's any easier to bundle these UDFs as .jars or Python packages but that could be implemented as a future field of this struct.
There was a problem hiding this comment.
Agreed. The proto struct is intentionally open here so a future variant can carry a RegistrationSource oneof (SharedObject | Jar | PythonPackage) without breaking wire compat. The shared-object form is the shortest path for the Rust ecosystem; the jar / Python paths would fan out from the same registration message.
| // Expected return type, declared at register time on the JVM side. | ||
| DataType return_type = 4; |
There was a problem hiding this comment.
Is a fixed return type a requirement? (DataFusion lets you compute this on demand but perhaps there's a limiation here)
There was a problem hiding this comment.
Not strictly. This PR pins it because the current dispatch resolves the return type at plan time (so we can allocate the output vector once per batch) and Spark expects a fixed DataType for scalar UDFs. Widening to a compute-on-demand return type would let a single UDF handle polymorphic outputs, at the cost of a per-batch call across the FFI to resolve. If that becomes valuable I can follow up with an optional return_type_fn slot.
There was a problem hiding this comment.
The other issue with the definition is that it can't handle an extension type, and some Iceberg/Spark types do map to Arrow extension types (notably: Geometry, Geography, and Variant). That is a larger issue with many of the definitions in this file and is probably better suited to a standalone change.
There was a problem hiding this comment.
Revisiting this now that the ABI has settled, because my earlier answer undersold what it does.
The return type is computed on demand. CometCScalarUdf::return_field(&self, args: &[Field]) is called with the actual argument types and derives the output type per call site, so a kernel has no fixed return type of its own. The test library now leans on that: a single echo_c kernel serves all 19 types in the suite, from Boolean through decimal(10,2) to array<struct<a:int>>, returning whatever it is handed.
What is fixed is the type declared to CometRustUDF.register, and that is a Spark constraint rather than an ABI one: Spark needs a concrete DataType at analysis time to plan the query and to install the catalog entry. So the declaration is per-registration, not per-kernel. Re-registering the same function under a different signature works, and the kernel computes the matching return type each time. There is a test covering exactly that.
The proto field is therefore best read as "what Spark was told", not "the only type this UDF can return". Comet now checks the two against each other at planning time and fails with both types named if they disagree, which replaced a bare DataFusion type assertion that used to fire partway through execution. That check turned out to earn its keep immediately: Spark widens decimal(10,2) + 0.25 to decimal(11,2), and registering the un-widened type is an easy mistake to make.
One wrinkle worth recording, since it also shaped the check. Spark carries containsNull and struct field nullability inside the type, so array<int> with containsNull = false converts to a List with a non-nullable child, while the array actually delivered to the UDF has that child normalized to nullable. The comparison erases nested nullability for that reason, but stays strict about everything that changes how bytes are read: decimal precision and scale, timestamp unit, and struct field names and order.
On extension types: agreed, and I have not tried to address it here. Variant and Geometry would need the type representation in expr.proto to grow an extension-type concept, which affects far more than this path, so it seems right as a standalone change. I have noted it in the PR's follow-on list so it does not get lost.
| /// Per-execution instance produced by [`CometCScalarKernel::new_impl`]. | ||
| /// | ||
| /// Not thread-safe; the caller must serialize access. Typically used on | ||
| /// one thread for one batch then dropped. | ||
| #[repr(C)] | ||
| pub struct CometCScalarKernelImpl { |
| //! datafusion-ffi flavor. | ||
| //! | ||
| //! Discovery returns a list of `FFI_ScalarUDF` values produced by | ||
| //! `datafusion_ffi`. The host imports each via | ||
| //! `ForeignScalarUDF::try_from`, yielding a `ScalarUDFImpl` it can plug | ||
| //! straight into its existing planner — no further adaptation needed. |
There was a problem hiding this comment.
Also cool! (And hard to argue with the compact implementation!)
Adapts the Rust UDF work to changes on main: - DataFusion 53 -> 54: ScalarUDFImpl no longer declares as_any - RustUdfCall proto field renumbered 71 -> 75 to avoid colliding with PreciseTimestampConversion/Shuffle/RandStr/Uuid - withInfo -> withFallbackReason in CometScalaUDF - clippy: clone_on_ref_ptr now denied in core; new cast_slice_from_raw_parts and needless_range_loop lints in the SDK - native/core/build.rs keeps both the libjvm search path from main and the COMET_TEST_UDFS_LIB export
… add user guide Keep a single UDF ABI built only on the Arrow C Data Interface. Carrying two public ABIs would mean two permanent compatibility promises; the datafusion-ffi flavor pins every user cdylib to Comet's DataFusion major version, which the 53 -> 54 upgrade demonstrated by removing as_any from ScalarUDFImpl. The rationale is recorded in comet-udf-sdk's crate docs. Contain panics at every extern "C" entry point. A panic escaping one aborts the process, taking down the executor JVM and every task on it. User UDF code is arbitrary and panicking is idiomatic Rust, so a panic is now converted to a query error via the existing get_last_error channel. Previously only execute was guarded; init, new_impl, the discovery entry point and the release callbacks were not. Add a user guide page describing the feature as experimental, including the executor-side library placement requirement and the trust implications of loading native code.
propagateConf wrote the registered UDF set into spark.comet.rustUdfs on every register call, and nothing ever read it. Executors resolve the library from the library_path carried in the RustUdfCall proto, so the conf implied a propagation mechanism that does not exist. Document what actually happens on the register scaladoc instead. CometRustUdfRegistry.snapshot had no other caller and goes with it.
…type Add echo_c (identity over any type) and stringify_c ((any) -> Utf8) to the test cdylib, and drive both over every non-nested Spark type with a null row: bool, the four int widths, float, double, decimal, string, binary, date, timestamp and timestamp_ntz. echo_c proves the array survives the round trip with its type and nulls; stringify_c forces the UDF to decode the values rather than hand the array straight back. No ABI change was needed, since the FFI surface is type-agnostic, but nothing had demonstrated that beyond Int64. Also validate at plan time that the return type declared through CometRustUDF.register matches what the kernel's return_field reports, naming both types and pointing at register. Registering decimal(10,2) for a column Spark had widened to decimal(11,2) previously surfaced as a bare DataFusion type assertion partway through execution. Complex types (array, struct, map) remain future work and are documented as unsupported.
…lability Arrays, maps and structs work over the ABI, including nesting (array of struct, struct of array), so extend the type matrix to cover them alongside the non-nested types. No ABI change was needed; the FFI surface carries child arrays already. The new plan-time return type check rejected them, and was right to look: Spark carries containsNull and field nullability inside the declared type, so array<int> with containsNull=false converts to a List with a non-nullable child, while the array actually delivered to the UDF has that child normalized to nullable. Compare with nested nullability erased, and promise DataFusion the kernel's own type rather than the declared one so its exact-match assertion compares like with like. The comparison stays strict about everything that changes how bytes are read: decimal precision and scale, timestamp unit, struct field names and order. Also document the return type model, which came up in review. A kernel has no fixed return type: return_field derives one from the argument types on every call, and echo_c serves all 19 types in the matrix. What is fixed is the type declared to register, because Spark needs a concrete DataType to plan against, and that is per-registration rather than per-kernel.
|
Thanks for the review @paleolimbot. The PR has changed quite a bit since you looked, so here is a summary of where it stands. It is also rebased onto current The datafusion-ffi flavor is goneThis is the one I want to flag directly, because you were positive about that implementation and it is the thing I removed. Carrying two public ABIs would have meant two permanent compatibility promises, so I picked one, and the rebase is what decided it. Merging current So the surviving ABI is the arrow-only one. Panic containment at every FFI entry pointPreviously only Full type coverage, including complex typesArguments and return values now cover every non-nested Spark type plus Two test UDFs cover the matrix: Return type validationComet now checks the type declared to User guideAdded Still outstandingListed in the PR description under "Follow-on work". The ones I would most want a reviewer's opinion on: the suite is not yet wired into CI, so the ABI has never been exercised against a Linux |
The suite cancelled itself whenever -Dcomet.test.udfs.lib was unset, and nothing set it, so it had never run in CI and the ABI had never been exercised against a Linux .so. Have the suite locate the cdylib under native/target instead of requiring the flag, keeping the property as an override. When the library is missing the suite still skips locally, but fails in CI, where its absence means the native build or the artifact upload changed rather than that someone forgot a flag. An undefined Maven property arrives in the forked JVM as the literal string "null", which is why the override needs filtering rather than a plain null check. Upload libcomet_test_udfs alongside libcomet from both native build jobs: the same cargo invocation already produces it, since the crate is a workspace default member, and the test jobs download the artifact rather than building. Register the suite in the expressions bucket of both workflows, as dev/ci/check-suites.py requires.
scalafix RedundantSyntax flags s-prefixed literals with nothing to interpolate. These were latent: the previous head commit on this branch carried [skip ci], so the lint jobs had never run against these files.
paleolimbot
left a comment
There was a problem hiding this comment.
Thank you!
I took a read through and flagged anything I saw but they may well be me misunderstanding the current code...take or leave!
| /// Free the array of kernels. Implementations must invoke each | ||
| /// kernel's `release` first, then release the array storage. | ||
| pub release: Option<unsafe extern "C" fn(*mut CometCScalarKernelList)>, |
There was a problem hiding this comment.
Optional, but this seems like it would be easy to forget to do...if it works with the use of this, it would probably be less error prone to force a caller to "move" the kernel (i.e., set the release callback of the array version wrapped here to NULL and force the caller to take responsibility of the C struct. Then this release callback would drop any valid kernels that remained.
There was a problem hiding this comment.
Agreed, and the current shape only works because the host remembers to write a default back into each slot it moves out of. That is exactly the kind of thing that stays correct until someone edits the loader.
Not doing it here: making the taker NULL the source release changes the ABI contract itself, so it wants to land deliberately rather than as a review fix. Filed as #5250, which also covers the mid-import question you raised further down.
| unsafe extern "C" fn c_factory_function_name(this: *const CometCScalarKernel) -> *const c_char { | ||
| debug_assert!(!this.is_null()); | ||
| let this = unsafe { &*this }; | ||
| debug_assert!(!this.private_data.is_null()); |
There was a problem hiding this comment.
The release callback might also be good to check here (and in other debug asserts)
| debug_assert!(!this.private_data.is_null()); | |
| debug_assert!(!this.private_data.is_null() && this.release.is_some()); |
There was a problem hiding this comment.
Applied in ceb9365, and extended to the other three entry points that reach private_data: c_factory_new_impl, c_kernel_init and c_kernel_execute. A released struct has release: None, so checking both catches a call made after release rather than only an uninitialized one, which is the more likely mistake.
| /// Run an infallible `f` (typically a release/cleanup callback), swallowing | ||
| /// any panic. Used where the ABI gives us no way to report an error and | ||
| /// aborting would be a worse outcome than leaking. | ||
| fn catch_panic_infallible(f: impl FnOnce()) { | ||
| let _ = std::panic::catch_unwind(std::panic::AssertUnwindSafe(f)); | ||
| } |
There was a problem hiding this comment.
Can this type of error be logged in some way or is that not safe/possible here?
There was a problem hiding this comment.
Possible, and worth doing. catch_panic_infallible now prints to stderr with a context string naming the callback that panicked (ceb9365).
Not a logging facade, though: arrow is deliberately the SDK's only dependency, and pulling in log for this would put a facade with no subscriber inside every user cdylib. stderr is the honest option at this layer. The cases it covers are all cleanup paths where the ABI gives no way to return an error, so previously a panicking user Drop left a leaked allocation and no trace at all.
| //! The Comet UDF C ABI — sedona-style. | ||
| //! | ||
| //! The wire format is two `#[repr(C)]` structs of function pointers, | ||
| //! parameterized only by Arrow's C Data Interface | ||
| //! (`FFI_ArrowSchema` / `FFI_ArrowArray`). No DataFusion types appear in | ||
| //! the FFI surface, so the user's cdylib only needs a matching `arrow` | ||
| //! crate, not a matching `datafusion` version. |
There was a problem hiding this comment.
You have this in the document above, but perhaps worth reiterating here that these are comet version specific (i.e., these are not yet ABI stable between comet versions).
I believe that neither the arrow nor DataFusion version have to match here (but one may have to relax the arrow version compatibility in Cargo.toml for Rust dependents of this crate to be able to compile it).
There was a problem hiding this comment.
Added a # Stability section to the module docs in ceb9365, saying plainly that these structs are specific to one Comet version, that they are internal under Comet's versioning policy and may change in any release including a patch, and that the practical consequence is rebuilding the cdylib per Comet release.
Your second point is right and I have written it down too: neither the arrow nor the datafusion version has to match, because only FFI_ArrowArray and FFI_ArrowSchema cross the boundary and those are #[repr(C)] renderings of the C Data Interface. The binding constraint is what comet-udf-sdk itself compiles against, since the SDK is built into the user's cdylib and Cargo has to unify its arrow requirement with theirs. That is the relaxation you raise on Cargo.toml, tracked as #5253.
| publish = false | ||
|
|
||
| [dependencies] | ||
| arrow = { workspace = true } |
There was a problem hiding this comment.
I am not sure if this will wreak havoc the build, but it may be possible to relax the version compatibility here to whenever the FFI_ArrowArray/Schema were added.
There was a problem hiding this comment.
Filed as #5253 rather than done here. Two reasons: the crate is publish = false and consumed by git, so there is no external consumer to unblock yet, and a relaxed range on one workspace member needs an override rather than workspace = true, plus a CI job actually building against the floor. Otherwise the range is a claim nothing tests, which is worse than the current pin.
Noted in the issue that this becomes the blocking constraint the moment the SDK is published.
| let return_field = Field::try_from(&out_schema) | ||
| .map_err(|e| DataFusionError::Internal(format!("decoding return type: {e}")))?; | ||
| Ok(return_field.data_type().clone()) |
There was a problem hiding this comment.
I know you don't support them one level up, but since you're calculating a field here anyway can you implement return_field()?
There was a problem hiding this comment.
Can, and return_type is already building the whole Field and throwing away everything but the DataType, while the planner separately hardcodes the output field as nullable. So it is mostly a matter of returning what is already computed.
Filed as #5251 rather than done here, because it changes what nullability Comet promises: Spark UDF results are nullable in Spark's own schema, and the declared-vs-actual check in planner.rs deliberately erases nested nullability for a related reason. I would rather that land with a test for a kernel reporting a non-nullable field than slip in as part of a docs pass.
| /// Open and validate a UDF cdylib. | ||
| pub fn load(path: impl AsRef<Path>) -> Result<LoadedLibrary, LoaderError> { | ||
| let path = path.as_ref().to_path_buf(); | ||
| // SAFETY: `Library::new` runs the cdylib's static initializers. We | ||
| // accept this risk because user UDF cdylibs are explicitly registered | ||
| // by an operator via `CometRustUDF.register`. | ||
| let library = unsafe { Library::new(&path) }.map_err(|source| LoaderError::Open { |
There was a problem hiding this comment.
This version of loading I believe will also resolve libraries on LD_LIBRARY_PATH (the previous version canonicalizes the path first, so I think only resolves against the working directory or an absolute path)
There was a problem hiding this comment.
Correct. cache::get_or_load canonicalizes with unwrap_or_else(|_| raw), so a path that does not resolve on the filesystem is handed to Library::new unchanged and gets the normal dlopen search. The docs claimed absolute paths were required; they now describe what the code does. Same thread as your cache.rs comment.
| // Replace the slot with a default kernel (no callbacks) so | ||
| // the array's release doesn't double-free. |
There was a problem hiding this comment.
I think the array's release won't double free here as currently implemented (but should!). This probably never leaks because none of this is ever likely to fail but in theory it would if an error occurred mid-import.
There was a problem hiding this comment.
Traced this and I do not think it leaks, though it took following three separate owners to convince myself.
On the ? early return: kernels 0..i are owned by udfs and released through their Arc<ImportedCScalarUdf>; kernel i was moved into the Box passed to try_new, which drops it on the error path; kernels i+1..len are still live in the array, and list's Drop calls c_list_release, which reconstructs the boxed slice and drops each one. The moved-out slots hold defaults with release: None, so they are no-ops rather than double frees.
That said, the fact that it takes a paragraph to establish is your point on the other thread. A test forcing a failure mid-import would make it durable instead of incidental, and it needs a fixture library exporting a deliberately bad kernel, so I have folded it into #5250 alongside the move-semantics change.
| // Whether the call is deterministic (mirrors Spark's deterministic flag). | ||
| bool deterministic = 5; |
There was a problem hiding this comment.
Is this always true because RustUdfs are always immutable?
There was a problem hiding this comment.
It was not always true, and this is the one comment in the review that turned out to be a live bug rather than a rough edge.
CometRustUDF.register takes deterministic as a public parameter, defaulting to true, and calls asNondeterministic() on the Spark catalog stub when it is false. The value then travels in this proto field and is never read again: the planner ignores it, and ImportedCScalarUdf::try_new hardcodes Volatility::Immutable. So a UDF the caller explicitly declared nondeterministic was planned as pure, and DataFusion was free to fold it over constants, evaluate it once and reuse the result, or drop a repeated call as a common subexpression.
Honoring the flag properly is not a one-liner, because the signature is built once per library load and cached process-wide, while determinism is declared per registration. Two registrations of the same kernel with different determinism would need different volatility out of one cached ScalarUDFImpl. That is #5249.
For this PR, 3e999c4 makes register reject deterministic = false with an explicit not-yet-supported error, so the parameter cannot quietly lie about what Comet does with it, with a test. The field comment now records that it is always true today and why it is still carried on the wire.
| /// won't be discoverable by name. (Comet always sets this; field is | ||
| /// optional for parity with sedona's design.) | ||
| pub function_name: Option<unsafe extern "C" fn(*const CometCScalarKernel) -> *const c_char>, | ||
|
|
There was a problem hiding this comment.
No pressure to do this here, but you could do something like this to pass the volatility, Display, Debug, etc. over FFI in a forward-flexible way. I haven't added this for UDFs yet but I'm using it for table providers, exec plans, and expressions. The annoying part is returning a variable length string (I use a FFI_ArrowArray for this at the moment, which is possibly overkill).
| pub get_property: Option< | |
| unsafe extern "C" fn( | |
| *mut CometCScalarKernelImpl, | |
| property: *const c_char, | |
| args: *const c_char, | |
| out: *mut FFI_ArrowArray, | |
| ) -> c_int, | |
| >, |
There was a problem hiding this comment.
This is the right shape for it, and it would have paid for itself already: volatility (#5249) is exactly a property that today would need a new struct field and an ABI version bump, breaking every existing cdylib to add one boolean.
Filed as #5254 with your sketch. Not taking it here because adding the field is itself an ABI change, so it wants to land before anyone depends on the current layout or ride along with the next bump, rather than being appended to a PR that is already large. Agreed the variable-length string return is the awkward part; noted the FFI_ArrowArray approach and that it may be heavier than needed.
|
Thanks for the detailed review @paleolimbot! I'm going to address some of these items in this PR and will file follow on issues to discuss the rest. |
LoadedLibrary declared `library: Arc<Library>` ahead of `udfs`. Struct fields drop in declaration order, so dropping a LoadedLibrary ran dlclose first and then invoked each kernel's `release` function pointer, which lives in the text of the library just unloaded. Nothing else holds a clone of that Arc. Unreachable in production, since the process-wide cache never drops a LoadedLibrary, but the loader tests build one via `load()` and drop it. Reorder the fields and say why in a comment, so a later edit does not quietly reintroduce it.
The rust-test job runs `cargo nextest run` and nothing else. comet-test-udfs
is `crate-type = ["cdylib"]` with no test targets, so a test build compiles
the crate without ever emitting libcomet_test_udfs, and the three rust_udf
tests that dlopen it failed on a clean checkout:
execution::rust_udf::cache::tests::same_path_returns_same_arc
execution::rust_udf::loader::tests::all_exported_kernels_are_discovered
execution::rust_udf::loader::tests::load_test_udfs_succeeds
It passed locally only because a previous `cargo build` had left the
artifact in target/debug.
Build the crate explicitly before the test run, and replace the bare
`expect("load")` with a message naming that command, so the next person to
hit this reads the fix instead of a dlopen error.
`CometRustUDF.register` accepted a `deterministic` flag, installed a nondeterministic Spark stub for it, and carried it in the RustUdfCall proto, but nothing on the native side ever read it: ImportedCScalarUdf hardcodes Volatility::Immutable. A UDF the caller declared nondeterministic was therefore planned as pure and could be constant-folded, evaluated once and reused, or eliminated as a common subexpression. Honoring the flag needs the cached per-library signature to become per registration, which is apache#5249. Until then, fail the registration rather than let the parameter lie about what Comet does with it. Raised in review: does the proto field's value mean RustUdfs are always immutable?
Review follow-ups on the SDK and the user guide, plus two assertion tweaks: - State that the C ABI structs are specific to one Comet version and are internal under the versioning policy, while the host's own arrow and datafusion versions need not match the cdylib's. - Say on CometCScalarUdf that only immutable functions are supported, and add the same to the user guide's limitations. The guide previously told readers to pass `deterministic = false` for impure functions, which is now refused. - Describe what libraryPath actually does: an absolute path is the sensible choice, but a bare name resolves through the platform loader search path, and neither is a security boundary. - Note that CometRustUDF is deliberately not @public, so it carries no compatibility guarantee. - Report rather than swallow a panic caught in a release callback: there is no error channel there, so it goes to stderr with a context string. - Check `release.is_some()` alongside private_data in the FFI debug asserts, which catches a call made after release rather than only an uninitialized one.
Which issue does this PR close?
Closes #747
Rationale for this change
Comet has no way to run a user's own native Rust code as part of a native plan. Today a user-defined function either falls back to Spark or, at best, runs through the JVM codegen dispatcher, which pulls rows back across the JNI boundary.
This PR adds custom Rust scalar UDF support. A user compiles a
cdylibagainst a published SDK crate, points Comet at it, and the function executes natively inside the DataFusion plan, operating directly on Arrow arrays.Why a bespoke C ABI rather than
datafusion-ffiWrapping the user's
ScalarUDFImplasdatafusion_ffi::udf::FFI_ScalarUDFis the obvious alternative and hands the author a much larger surface for free: variadic signatures, type coercion, metadata-aware return types. This PR deliberately does not expose it, because it would couple every user's cdylib to Comet's DataFusion major version.That is not a hypothetical cost. An earlier revision of this PR carried both ABIs side by side. Rebasing onto current
mainmoved Comet from DataFusion 53 to 54, which removedas_anyfromScalarUDFImpl: every user library built against thedatafusion-ffiflavor would have needed a source edit and a recompile. The C ABI needed no change, because no DataFusion type appears in it.Comet tracks DataFusion closely and upgrades often, so a UDF ABI pinned to the DataFusion version would break users on a cadence they neither control nor can opt out of. Keeping the FFI surface to Arrow's C Data Interface means a UDF compiled today keeps working across Comet upgrades, and the same ABI is implementable from C, C++, or any language that speaks the Arrow C Data Interface.
The tradeoff is a smaller surface: authors implement
CometCScalarUdfand get scalar functions over Arrow arrays, not the fullScalarUDFImplfeature set. The rationale is recorded in thecomet-udf-sdkcrate docs so it does not have to be rediscovered.What changes are included in this PR?
native/comet-udf-sdk— the public SDK users compile against. Provides theCometCScalarUdftrait and thecomet_c_udf_export!macro. Depends only onarrow, with no DataFusion dependency at all.native/comet-test-udfs— test cdylib:add_one_cplus two deliberately panicking UDFs used to verify panic containment.native/core/src/execution/rust_udf—loader(libloading + ABI version probe + discovery), process-widecache, and theImportedCScalarUdfadapter that wraps a C-ABI kernel as aScalarUDFImpl.RustUdfCallproto inexpr.proto+ planner branch increate_exprresolving (library_path, name) against the cache.CometRustUdfBridge/comet_rust_udf_bridge.rs) for driver-sidevalidateLibrary/listUdfs.CometRustUDF.register,CometRustUdfRegistry, typed exception classes). InQueryPlanSerde,CometScalaUDF.convertfirst checks the registry: if the udfName is registered it emitsRustUdfCall, otherwise it falls through to the existing JVM codegen dispatcher.Boolean,Byte,Short,Integer,Long,Float,Double,Decimal,String,Binary,Date,Timestamp,TimestampNTZ) plusArrayType,MapTypeandStructTypewith arbitrary nesting, nulls preserved in both directions. No ABI change was needed, since the FFI surface is type-agnostic and already carries child arrays, but nothing had demonstrated it beyondInt64.return_fieldreports, naming both types. Registeringdecimal(10,2)for a column Spark had widened todecimal(11,2)previously surfaced as a bare DataFusion type assertion partway through execution. The comparison erases nested nullability, because Spark carriescontainsNullinside the declared type while the delivered array normalizes child fields to nullable; it stays strict about everything that changes how bytes are read.extern "C"entry point. A panic escaping one of these aborts the process, which for Comet means killing the executor JVM and losing every task on it, not just the offending query. User UDF code is arbitrary and panicking is idiomatic Rust (unwrap, slice indexing, debug overflow), so a panic is now caught at the boundary and reported through the sameget_last_errorchannel as a returnedErr. The query fails; the executor survives.CometRustUdfSuitenow runs in both the Linux and macOS PR builds. The suite locates the cdylib undernative/targetrather than requiring a flag, and both native build jobs uploadlibcomet_test_udfsalongsidelibcomet. If the library is ever missing the suite skips locally but fails in CI, so it cannot silently stop running again.docs/source/user-guide/latest/rust_udfs.md), which describes the feature as experimental and documents the current limitations, including that the library must already exist on every executor at the given path, and that loading a UDF library means running untrusted native code in the executor process.There is no impact on existing users: every new code path is reachable only after an explicit
CometRustUDF.registercall. With no registered UDFs,CometScalaUDF.converttakes exactly the branch it takes today, no library is loaded, and no new configuration is introduced.How are these changes tested?
return_fieldandinvoke, asserting the error code and that the panic message reachesget_last_error.CometRustUdfSuite), 44 tests —add_one_cover a range;echo_candstringify_cover all 19 supported types with a null row, nested types included; one test per panic site asserting the query fails with the panic message rather than the executor dying; and the declared-vs-actual return type mismatch error. Gated on-Dcomet.test.udfs.lib=<path>.The e2e tests are self-guarding on native execution:
registerinstalls a catalog stub that throws if Spark ever evaluates the UDF itself, so a silent fallback to Spark fails the test rather than passing. This caught a real fallback during development, where an unsupported expression in the test data pulled the whole projection back to Spark.Follow-on work
Known gaps, deliberately out of scope here and tracked for follow-ups:
expr.protoconcern rather than something specific to this path.deterministicis carried in the proto but not yet honored natively, and the planner hardcodes the return field as nullable.MutexinImportedCScalarUdfserializes batches through one lock per UDF per process.registerAllis not implemented.