Extension API for third-party DataFusion integrations (UDFs, optimizer rules, planner extensions) #2001
Replies: 6 comments 5 replies
|
Nice write-up! It would likely take some time to finalize the design but it's great to see the concrete proposal here. Let me share my thoughts. Indeed, there are many extension points we need to have (functions, logical/physical plan nodes, and optimizers) to support the full query execution logic. (I also noticed it here in a previous discussion.) Most of these interfaces are from DataFusion, so they are relatively stable fortunately. The session mutator is something not very stable and I'd consider it a deep implementation detail, so we need to think more around the best way to alter session config. Using Python package for distribution and the discovery mechanism make sense to me. However, since DataFusion has an FFI, we won't use Rust trait as the API, but we'll have an extension FFI built on top of DataFusion so that we won't need to recompile the extension on every Sail version or Rust version change. This allows Sail and the extension to release under different schedule. See my comment here. I would love to see end-to-end proof of concept around FFI. Open questions:
|
This is the main trick. If I've kept up with the DataFusion FFI work, the main thing missing is that the logical plan is not part of the extension FFI (so our current spatial join optimization is tricky to generically plug in to another DataFusion based engine that doesn't already know about spatial joins and predicates). We haven't tried very hard to plug in SedonaDB to sedona-python but it would be roughly the same work. FWIW my main complaint with the DataFusion FFI is that the major version is not part of the FFI, so you get a crash if you try to mix datafusion-python versions with whatever your extension was compiled against but you could work around that here. Also FWIW, DuckDB handles this by providing the CI job that aligns everything so that no FFI is needed. This is super powerful (allows access to literally all of DuckDB's internal APIs) but it means extension authors have to keep up (I have three DuckDB extensions that are all out of date as a result of this 🙂 ). It took them quite a long time to get this working. |
|
These are great discussions! I'm going to move this to a GitHub discussion thread and we can continue there. |
|
Friends -- great to join this effort, leading the community at LakeSail. Saw the Sedona presentation at the DataFusion meetup recently. Sedona is an excellent project and a great extension to think through and add, as it exercises all of the components of Sail. I'm fairly new to Sail and Rust, but with an extensive background in similar languages, functional programming, and distributed systems (and a Spark pioneer). So I'll structure it as a learning process for those who want to understand how Sail works, and we can confirm the right touchpoints for extensions. We wrote a book, with Codex, reviewing all parts of Sail with an eye on their participation in the extensions: https://github.com/alexy/sail-rust-book We also have a community Slack, and I've just started a #sail-extensions channel there. Please join and chime in, so we have an informal way to bounce off ideas before they become a part of the record here. Since @james-willis has a full prototype, one question is: what prevents us from using it as is? I believe we need to answer @linhr's questions, correct? |
|
@paleolimbot and I (mostly Dewey) are working through this plugin feature now in sedonaDB (ie plugging into sedonadb from some other datafusion based library). Give us a few weeks and we might have some more battle-tested knowledge about this work. |
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
Summary
There is no public API for plugging a third-party extension into Sail. Today, integrating something like Apache SedonaDB — which contributes scalar UDFs, aggregate UDAFs, a session-config extension, logical optimizer rules, and a physical planner extension — requires forking Sail and editing internals across at least four crates.
This issue proposes one composable extension API that covers every dimension a real-world extension contributes (
SailExtensiontrait), plus a Python entry-points discovery layer so end users onpip install pysailcan consume extensions without rebuilding pysail. One small prerequisite fix toExtensionPhysicalPlanneris folded in.Motivating example
Apache SedonaDB is a representative case because it exercises most of the extension dimensions at once. To make
ST_Intersectsjoins plan asSpatialJoinExecinstead ofNestedLoopJoinExec, an embedder needs Sail to:ST_*scalar UDFs at plan time.SedonaOptionson theSessionConfigso Sedona's optimizer rules can read their config.SpatialJoinPlanNodeextension nodes.SpatialJoinExec.Items 1–4 are all required to plan a single SQL query. Item 5 is required the moment that query runs in a multi-node cluster. There is no way to contribute any of them from outside the
sail-*workspace. (Sedona also ships aggregate UDAFs, window UDFs, and table functions — those are separate dimensions covered by the inventory below, but they aren't on the critical path for the spatial-join example.)Where each dimension is hardcoded today
This is the inventory an embedder discovers after attempting the integration. All of these are currently
sail-*-internal touch points that any third-party extension would need to mutate.crates/sail-plan/src/function/mod.rs—BUILT_IN_SCALAR_FUNCTIONSlazy_staticHashMap. Sedona functions are merged in via a direct call tosail_sedona::sedona_scalar_udfs().BUILT_IN_GENERATOR_FUNCTIONS,BUILT_IN_TABLE_FUNCTIONS.crates/sail-plan/src/function/aggregate.rs:541— direct call tosail_sedona::sedona_aggregate_udfs()populatesBUILT_IN_AGGREGATE_FUNCTIONS.crates/sail-plan/src/function/window.rs—BUILT_IN_WINDOW_FUNCTIONS.crates/sail-execution/src/codec.rs:1539and:1741— hardcoded fallback lookups againstsail_sedona::get_sedona_scalar_udf/get_sedona_aggregate_udfso workers can rebuild UDFs from a serialized plan.crates/sail-session/src/session_factory/server.rs::create_session_config— Sedona requiresadd_sedona_option_extension(config)here.crates/sail-session/src/session_factory/server.rs::create_session_state— Sedona requiresregister_spatial_join_logical_optimizer(builder)here.crates/sail-session/src/planner.rs::ExtensionQueryPlanner::create_physical_plan— Sedona'sSpatialJoinExtensionPlannermust be pushed onto theextension_plannerschain (see prerequisite below for ordering constraints).The
ServerSessionMutatortrait exists and is the closest existing extension hook, but it only exposes mutation ofSessionConfig,SessionStateBuilder, andRuntimeEnvBuilder— it doesn't help with the plan-time function registries, the codec fallbacks, or theExtensionQueryPlannerchain. And it requires the embedder to use Sail as a library, not as thesailCLI binary or pysail.Proposal
Prerequisite: stop short-circuiting on unknown extension nodes
crates/sail-session/src/planner.rsends its node match with:DataFusion's
DefaultPhysicalPlannerwalks each registeredExtensionPlannerand stops at the first one that returnsOk(Some(plan)). The convention is that planners which don't recognize a node returnOk(None)and the chain continues. Sail'sExtensionPhysicalPlannerinstead returnsErr(...), so any planner that needs to handle a node Sail doesn't know about must be listed beforeExtensionPhysicalPlanner— otherwise the chain short-circuits with a confusing internal error.This took non-trivial debugging to diagnose for the Sedona case: the logical rule fired correctly, the new node appeared in the plan, but the physical build failed with
unsupported logical extension node: SpatialJoin.Five-line change, no public API impact, but mandatory for the rest of the proposal to compose cleanly.
Part 1: a unified
SailExtensiontraitIntroduce a single trait that an embedder implements once, replacing the need for any per-extension bridge crate inside
sail-*. The trait covers every dimension listed above:Sail merges these contributions in well-defined order:
Registration via the existing
ServerSessionMutatorboundary or a newwith_extension(Arc<dyn SailExtension>)builder method onServerSessionFactory. The embedder'smainbecomes:sail-sedonabecomes a regular external crate that implementsSailExtension. No edits to anysail-*crate.Part 2: Python entry-points discovery for pysail
The trait alone unblocks anyone who can build a binary, but it does nothing for
pip install pysailusers. To close that gap, pysail discovers extensions at startup via Python entry points:At session-create time, pysail iterates
importlib.metadata.entry_points(group="pysail.extensions")and calls eachregister()function, which hands back anArc<dyn SailExtension>(via a thin pyo3 bridge) that gets passed towith_extension.End-user UX:
pip install pysail pysail-sedona sail spark server --port 50051 # Sedona is just thereNo rebuild of pysail. This is the closest practical analog to Spark's "drop a jar in
$SPARK_HOME/jars, call the registrator" story.Coverage
What each part of the proposal enables, by use case:
pip installpluginSailExtensiontraitAll three parts ship together; the entry-points layer is what makes the end-user UX comparable to Spark.
Outstanding decisions
The proposal supports loading multiple extensions composably (multiple
with_extensioncalls, or multiple plugin wheels installed alongside each other). What it doesn't yet specify is how Sail should resolve collisions between extensions. These need to be decided before implementation:ST_Distanceand a custom geo library'sST_Distance). Should this error at registration, error at function-resolution, last-registered wins, first-registered wins, or require namespacing (sedona.ST_Distance)? Same question for UDAFs, window UDFs, generators, and table functions.SessionConfigextension collisions. DataFusion'sadd_extensionis keyed by RustTypeId, so two extensions contributing different config types compose freely; two contributing the same type silently replace. Is silent replace acceptable, or should Sail detect and surface this?PushDownFilter", "must run after Sedona'sMergeSpatialFilterIntoJoin")?extension_plannerschain. Two extensions could both claim to handle the same logical extension node; today the first one in the chain wins by virtue of returningOk(Some(...))first.pysail.extensionsentry point with the same name, what should pysail do at startup? Error, warn, or load both (and then face the function-collision question above)?SET sail.extensions = 'sedona,comet')?Confidence and prior art
Roughly 60–70% confident the entry-points layer works end-to-end without unpleasant surprises. The closest demonstrated case is Polars plugins, which use the same pattern (compile-time-coupled native extension wheels discovered at runtime). It works, but with strict version coupling: every plugin must be rebuilt on every Polars upgrade because Rust has no stable ABI for trait objects.
For Sail, "version coupling" means each plugin wheel must be built against matching versions of
pysail,datafusion,arrow, andpyo3— the types crossing the pyo3 boundary (Arc<dyn SailExtension>,Arc<dyn ScalarUDF>,Arc<dyn OptimizerRule>, …) all come from those crates. The plugin's own internal dependencies (e.g. whichsedona-*versions apysail-sedonawheel pulls in) are private to the plugin; pysail never sees those types. Worth prototyping theArc<dyn SailExtension>handoff across two independently-built pyo3 cdylibs before fully committing to this layer — that's the part with no direct precedent I'm aware of.The trait itself (Part 1) has no such risk and can ship independently if Part 2 turns out to need more design work.
Reference: a working but forked Sedona integration
A complete Sedona integration that would have been roughly:
under this proposal currently exists as a fork at
james-willis/sailbranchsedona-integration. The diff againstsail-session,sail-plan,sail-execution, and the workspaceCargo.tomlis the inventory in the table above made concrete.All reactions