This is an automated email from the ASF dual-hosted git repository.

timsaucer pushed a commit to branch feat/ffi-query-planner-55
in repository https://gitbox.apache.org/repos/asf/datafusion-ballista.git

commit 0a07776f6b2c3194d743d6c6b3e9e8ed725b1138
Author: Tim Saucer <[email protected]>
AuthorDate: Fri Aug 7 16:07:37 2026 -0400

    feat(python): use atomic Ballista session extensions
    
    AI Disclosure: This code was written in part by an AI agent.:
---
 python/Cargo.lock                     |   4 +-
 python/Cargo.toml                     |   2 +-
 python/examples/getting_started.ipynb |  16 +---
 python/pyproject.toml                 |   4 +-
 python/python/ballista/__init__.py    |   4 +
 python/python/tests/test_context.py   |  32 ++++++++
 python/requirements.txt               |   2 +-
 python/src/lib.rs                     |   1 +
 python/src/query_planner.rs           | 145 +++++++++++++++++++++++-----------
 python/uv.lock                        |   4 +-
 10 files changed, 150 insertions(+), 64 deletions(-)

diff --git a/python/Cargo.lock b/python/Cargo.lock
index 1a2121812..49a856bd5 100644
--- a/python/Cargo.lock
+++ b/python/Cargo.lock
@@ -1681,7 +1681,7 @@ dependencies = [
 [[package]]
 name = "datafusion-python"
 version = "54.0.0"
-source = 
"git+https://github.com/apache/datafusion-python.git?rev=55f43b5e231da5591e7bbd632dac85f304a22cca#55f43b5e231da5591e7bbd632dac85f304a22cca";
+source = 
"git+https://github.com/apache/datafusion-python.git?rev=f1526210338d75ef105246e5c948e0e4a4a0f5ce#f1526210338d75ef105246e5c948e0e4a4a0f5ce";
 dependencies = [
  "arrow",
  "arrow-select",
@@ -1713,7 +1713,7 @@ dependencies = [
 [[package]]
 name = "datafusion-python-util"
 version = "54.0.0"
-source = 
"git+https://github.com/apache/datafusion-python.git?rev=55f43b5e231da5591e7bbd632dac85f304a22cca#55f43b5e231da5591e7bbd632dac85f304a22cca";
+source = 
"git+https://github.com/apache/datafusion-python.git?rev=f1526210338d75ef105246e5c948e0e4a4a0f5ce#f1526210338d75ef105246e5c948e0e4a4a0f5ce";
 dependencies = [
  "arrow",
  "datafusion",
diff --git a/python/Cargo.toml b/python/Cargo.toml
index 10527a71b..3f45c5364 100644
--- a/python/Cargo.toml
+++ b/python/Cargo.toml
@@ -38,7 +38,7 @@ ballista-scheduler = { path = "../ballista/scheduler", 
version = "54.0.0", defau
 datafusion = { git = "https://github.com/apache/datafusion.git";, rev = 
"e08aed1e5de41dcf81d529140dae07723b942a5e", features = ["avro"] }
 datafusion-ffi = { git = "https://github.com/apache/datafusion.git";, rev = 
"e08aed1e5de41dcf81d529140dae07723b942a5e" }
 datafusion-proto = { git = "https://github.com/apache/datafusion.git";, rev = 
"e08aed1e5de41dcf81d529140dae07723b942a5e" }
-datafusion-python = { git = "https://github.com/apache/datafusion-python.git";, 
rev = "55f43b5e231da5591e7bbd632dac85f304a22cca", default-features = false }
+datafusion-python = { git = "https://github.com/apache/datafusion-python.git";, 
rev = "f1526210338d75ef105246e5c948e0e4a4a0f5ce", default-features = false }
 log = { version = "0.4" }
 pyo3 = { version = "0.29", features = ["extension-module", "abi3", 
"abi3-py310"] }
 pyo3-log = "0.13"
diff --git a/python/examples/getting_started.ipynb 
b/python/examples/getting_started.ipynb
index b2b938ba5..60eb5c3dd 100644
--- a/python/examples/getting_started.ipynb
+++ b/python/examples/getting_started.ipynb
@@ -58,11 +58,9 @@
    "outputs": [],
    "source": [
     "import ballista\n",
-    "from ballista._internal_ballista import 
ballista_datafusion_config_defaults\n",
     "from ballista import (\n",
-    "    BallistaQueryPlanner,\n",
-    "    BallistaLogicalExtensionCodec,\n",
-    "    BallistaPhysicalExtensionCodec,\n",
+    "    BallistaExtension,\n",
+    "    ballista_datafusion_config_defaults,\n",
     "    setup_test_cluster,\n",
     ")\n",
     "from datafusion import SessionConfig, SessionContext\n",
@@ -87,14 +85,8 @@
     "address = f\"df://{host}:{port}\"\n",
     "\n",
     "ballista_config = ballista_datafusion_config_defaults()\n",
-    "ctx = SessionContext(SessionConfig(ballista_config))\n",
-    "logical_codec = BallistaLogicalExtensionCodec(ctx)\n",
-    "physical_codec = BallistaPhysicalExtensionCodec(ctx, logical_codec)\n",
-    "\n",
-    "ctx = (\n",
-    "    ctx.with_logical_extension_codec(logical_codec)\n",
-    "    .with_physical_extension_codec(physical_codec)\n",
-    "    .with_query_planner(BallistaQueryPlanner(address, ctx))\n",
+    "ctx = SessionContext(SessionConfig(ballista_config)).with_extensions(\n",
+    "    BallistaExtension(address)\n",
     ")\n",
     "\n",
     "print(f\"Connected to Ballista at {host}:{port}\")\n",
diff --git a/python/pyproject.toml b/python/pyproject.toml
index 4e85fbb07..dbc82006c 100644
--- a/python/pyproject.toml
+++ b/python/pyproject.toml
@@ -41,8 +41,8 @@ classifiers = [
 ]
 dependencies = [
     "pyarrow>=23.0.1",
-    # Temporary PR #1672 pin. Use its released version before publishing.
-    "datafusion @ 
git+https://github.com/apache/datafusion-python.git@55f43b5e231da5591e7bbd632dac85f304a22cca";,
+    # Temporary PR #1679 pin. Use its released version before publishing.
+    "datafusion @ 
git+https://github.com/apache/datafusion-python.git@f1526210338d75ef105246e5c948e0e4a4a0f5ce";,
     "typing-extensions;python_version<'3.13'",
 ]
 dynamic = ["version"]
diff --git a/python/python/ballista/__init__.py 
b/python/python/ballista/__init__.py
index e892ff8ea..97ba8ef65 100644
--- a/python/python/ballista/__init__.py
+++ b/python/python/ballista/__init__.py
@@ -22,10 +22,12 @@ except ImportError:
 
 from ._internal_ballista import (
     BallistaExecutor,
+    BallistaExtension,
     BallistaLogicalExtensionCodec,
     BallistaPhysicalExtensionCodec,
     BallistaQueryPlanner,
     BallistaScheduler,
+    ballista_datafusion_config_defaults,
     setup_test_cluster,
 )
 from .extension import (
@@ -38,8 +40,10 @@ __version__ = importlib_metadata.version(__name__)
 
 __all__ = [
     "setup_test_cluster",
+    "ballista_datafusion_config_defaults",
     "BallistaScheduler",
     "BallistaExecutor",
+    "BallistaExtension",
     "BallistaLogicalExtensionCodec",
     "BallistaPhysicalExtensionCodec",
     "BallistaQueryPlanner",
diff --git a/python/python/tests/test_context.py 
b/python/python/tests/test_context.py
index 7fc016806..ab8a2a759 100644
--- a/python/python/tests/test_context.py
+++ b/python/python/tests/test_context.py
@@ -15,7 +15,10 @@
 # specific language governing permissions and limitations
 # under the License.
 
+import gc
+
 from ballista import (
+    BallistaExtension,
     BallistaLogicalExtensionCodec,
     BallistaPhysicalExtensionCodec,
     BallistaQueryPlanner,
@@ -92,6 +95,35 @@ def test_manual_query_planner_and_codec_composition():
     assert df.collect()[0].column(0).to_pylist() == [5]
 
 
+def test_ballista_extension_uses_atomic_context_installation():
+    address, port = setup_test_cluster()
+    source_ctx = SessionContext()
+    source_ctx.register_csv("registered", "testdata/test.csv", has_header=True)
+
+    configured_ctx = source_ctx.with_extensions(
+        BallistaExtension(f"df://{address}:{port}")
+    )
+
+    df = configured_ctx.sql("SELECT COUNT(*) FROM registered")
+    assert type(df) is DataFrame
+    assert_uses_ballista(df)
+    assert df.collect()[0].column(0).to_pylist() == [5]
+
+
+def test_ballista_extension_context_must_outlive_dataframe():
+    address, port = setup_test_cluster()
+    configured_ctx = SessionContext().with_extensions(
+        BallistaExtension(f"df://{address}:{port}")
+    )
+    df = configured_ctx.sql("SELECT 1")
+
+    del configured_ctx
+    gc.collect()
+
+    with pytest.raises(Exception, match="went out of scope"):
+        df.collect()
+
+
 def test_low_level_helper_returns_standard_distributed_dataframe():
     address, port = setup_test_cluster()
     source_ctx = SessionContext()
diff --git a/python/requirements.txt b/python/requirements.txt
index bc9cfb34d..ae3a415c6 100644
--- a/python/requirements.txt
+++ b/python/requirements.txt
@@ -1,4 +1,4 @@
-datafusion==52.0.0
+datafusion @ 
git+https://github.com/apache/datafusion-python.git@f1526210338d75ef105246e5c948e0e4a4a0f5ce
 pyarrow
 pytest
 maturin>=1.8.0,<2.0.0
diff --git a/python/src/lib.rs b/python/src/lib.rs
index 7e44e2f20..810c2dae9 100644
--- a/python/src/lib.rs
+++ b/python/src/lib.rs
@@ -39,6 +39,7 @@ fn _internal_ballista(_py: Python, m: Bound<'_, PyModule>) -> 
PyResult<()> {
 
     m.add_class::<PyScheduler>()?;
     m.add_class::<PyExecutor>()?;
+    m.add_class::<query_planner::PyBallistaExtension>()?;
     m.add_class::<query_planner::PyBallistaLogicalExtensionCodec>()?;
     m.add_class::<query_planner::PyBallistaPhysicalExtensionCodec>()?;
     m.add_class::<query_planner::PyBallistaQueryPlanner>()?;
diff --git a/python/src/query_planner.rs b/python/src/query_planner.rs
index c934bb9ce..0d10423cc 100644
--- a/python/src/query_planner.rs
+++ b/python/src/query_planner.rs
@@ -36,7 +36,7 @@ use datafusion_proto::physical_plan::PhysicalExtensionCodec;
 use datafusion_proto::protobuf::LogicalPlanNode;
 use pyo3::exceptions::PyValueError;
 use pyo3::prelude::*;
-use pyo3::types::PyCapsule;
+use pyo3::types::{PyCapsule, PyDict};
 use std::collections::HashMap;
 use std::ptr::NonNull;
 use std::sync::{Arc, OnceLock};
@@ -45,6 +45,41 @@ use url::Url;
 
 const DEFAULT_SCHEDULER_PORT: u16 = 50050;
 
+#[pyclass(
+    name = "BallistaExtension",
+    module = "ballista._internal_ballista",
+    skip_from_py_object
+)]
+pub(crate) struct PyBallistaExtension {
+    address: String,
+    config_overrides: Option<HashMap<String, String>>,
+}
+
+#[pymethods]
+impl PyBallistaExtension {
+    #[new]
+    #[pyo3(signature = (address, config_overrides=None))]
+    fn new(address: String, config_overrides: Option<HashMap<String, String>>) 
-> Self {
+        Self {
+            address,
+            config_overrides,
+        }
+    }
+
+    fn __datafusion_session_extension__<'py>(
+        &self,
+        py: Python<'py>,
+        session_ctx: Bound<'py, PyAny>,
+    ) -> PyResult<Bound<'py, PyAny>> {
+        ballista_extension_components(
+            py,
+            session_ctx,
+            self.address.clone(),
+            self.config_overrides.clone(),
+        )
+    }
+}
+
 #[pyclass(
     name = "BallistaLogicalExtensionCodec",
     module = "ballista._internal_ballista",
@@ -53,8 +88,9 @@ const DEFAULT_SCHEDULER_PORT: u16 = 50050;
 pub(crate) struct PyBallistaLogicalExtensionCodec {
     logical_codec: Arc<dyn LogicalExtensionCodec>,
     ffi_logical_codec: FFI_LogicalExtensionCodec,
-    // The FFI codec holds a weak task-context provider.
-    _session_ctx: Py<PyAny>,
+    // Retain the source context for the legacy low-level API. Atomic extension
+    // installation leaves this empty so the returned context owns the 
provider.
+    _session_ctx: Option<Py<PyAny>>,
 }
 
 #[pymethods]
@@ -75,7 +111,7 @@ impl PyBallistaLogicalExtensionCodec {
         Ok(Self {
             logical_codec,
             ffi_logical_codec,
-            _session_ctx: session_ctx.unbind(),
+            _session_ctx: Some(session_ctx.unbind()),
         })
     }
 
@@ -98,8 +134,9 @@ impl PyBallistaLogicalExtensionCodec {
 )]
 pub(crate) struct PyBallistaPhysicalExtensionCodec {
     ffi_physical_codec: FFI_PhysicalExtensionCodec,
-    // The FFI codec holds a weak task-context provider.
-    _session_ctx: Py<PyAny>,
+    // Retain the source context for the legacy low-level API. Atomic extension
+    // installation leaves this empty so the returned context owns the 
provider.
+    _session_ctx: Option<Py<PyAny>>,
     // Retain the Python logical codec as well as its native Arc.
     _logical_codec: Py<PyAny>,
 }
@@ -115,7 +152,7 @@ impl PyBallistaPhysicalExtensionCodec {
 
         Ok(Self {
             ffi_physical_codec,
-            _session_ctx: session_ctx.unbind(),
+            _session_ctx: Some(session_ctx.unbind()),
             _logical_codec: logical_codec_object,
         })
     }
@@ -163,9 +200,9 @@ pub(crate) struct PyBallistaQueryPlanner {
     logical_codec: Arc<dyn LogicalExtensionCodec>,
     fallback_logical_codec: FFI_LogicalExtensionCodec,
     fallback_physical_codec: FFI_PhysicalExtensionCodec,
-    // The fallback FFI codecs hold a weak task-context provider. Retain its
-    // source context for as long as Python retains this planner.
-    _session_ctx: Py<PyAny>,
+    // Retain the source context for the legacy low-level API. Atomic extension
+    // installation leaves this empty so the returned context owns the 
provider.
+    _session_ctx: Option<Py<PyAny>>,
 }
 
 #[pymethods]
@@ -201,7 +238,10 @@ impl PyBallistaQueryPlanner {
                 address: self.address.clone(),
                 config: self.config.clone(),
                 logical_codec: Arc::clone(&self.logical_codec),
-                _session_ctx: self._session_ctx.clone_ref(py),
+                _session_ctx: self
+                    ._session_ctx
+                    .as_ref()
+                    .map(|session_ctx| session_ctx.clone_ref(py)),
             });
         let ffi = FFI_QueryPlanner::new_with_ffi_codecs(
             planner,
@@ -209,7 +249,7 @@ impl PyBallistaQueryPlanner {
             self.fallback_physical_codec.clone(),
         );
 
-        // This exact name is the ABI consumed by datafusion-python 55f43b5e.
+        // This exact name is the ABI consumed by datafusion-python.
         PyCapsule::new_with_value(py, ffi, cr"datafusion_query_planner")
     }
 }
@@ -244,7 +284,7 @@ impl PyBallistaQueryPlanner {
             logical_codec,
             fallback_logical_codec,
             fallback_physical_codec,
-            _session_ctx: session_ctx.unbind(),
+            _session_ctx: Some(session_ctx.unbind()),
         })
     }
 }
@@ -253,7 +293,7 @@ struct LazyBallistaQueryPlanner {
     address: String,
     config: ballista_core::config::BallistaConfig,
     logical_codec: Arc<dyn LogicalExtensionCodec>,
-    _session_ctx: Py<PyAny>,
+    _session_ctx: Option<Py<PyAny>>,
 }
 
 impl std::fmt::Debug for LazyBallistaQueryPlanner {
@@ -317,6 +357,48 @@ pub(crate) fn ballista_datafusion_config_defaults() -> 
HashMap<String, String> {
         .collect()
 }
 
+fn ballista_extension_components<'py>(
+    py: Python<'py>,
+    session_ctx: Bound<'py, PyAny>,
+    address: String,
+    config_overrides: Option<HashMap<String, String>>,
+) -> PyResult<Bound<'py, PyAny>> {
+    let mut logical_codec = 
PyBallistaLogicalExtensionCodec::new(session_ctx.clone())?;
+    let logical_native = Arc::clone(&logical_codec.logical_codec);
+    let fallback_logical_codec = logical_codec.ffi_logical_codec.clone();
+    logical_codec._session_ctx = None;
+    let logical_codec = Py::new(py, logical_codec)?;
+
+    let mut physical_codec = 
PyBallistaPhysicalExtensionCodec::from_logical_codec(
+        session_ctx.clone(),
+        Arc::clone(&logical_native),
+        logical_codec.clone_ref(py).into_any(),
+    )?;
+    let fallback_physical_codec = physical_codec.ffi_physical_codec.clone();
+    physical_codec._session_ctx = None;
+    let physical_codec = Py::new(py, physical_codec)?;
+
+    let mut planner = PyBallistaQueryPlanner::from_codecs(
+        address,
+        session_ctx,
+        config_overrides,
+        logical_native,
+        fallback_logical_codec,
+        fallback_physical_codec,
+    )?;
+    planner._session_ctx = None;
+    let planner = Py::new(py, planner)?;
+
+    let components = py
+        .import("datafusion")?
+        .getattr("SessionExtensionComponents")?;
+    let kwargs = PyDict::new(py);
+    kwargs.set_item("logical_extension_codecs", (logical_codec,))?;
+    kwargs.set_item("physical_extension_codecs", (physical_codec,))?;
+    kwargs.set_item("query_planner", planner)?;
+    components.call((), Some(&kwargs))
+}
+
 #[pyfunction]
 #[pyo3(signature = (session_ctx, address, config_overrides=None))]
 pub(crate) fn with_ballista_query_planner(
@@ -340,37 +422,12 @@ pub(crate) fn with_ballista_query_planner(
         }
     }
 
-    let logical_codec = 
PyBallistaLogicalExtensionCodec::new(session_ctx.clone())?;
-    let logical_native = Arc::clone(&logical_codec.logical_codec);
-    let fallback_logical_codec = logical_codec.ffi_logical_codec.clone();
-    let logical_codec = Py::new(py, logical_codec)?;
-
-    let physical_codec = PyBallistaPhysicalExtensionCodec::from_logical_codec(
-        session_ctx.clone(),
-        Arc::clone(&logical_native),
-        logical_codec.clone_ref(py).into_any(),
+    let extension = Py::new(
+        py,
+        PyBallistaExtension::new(address, Some(ballista_overrides)),
     )?;
-    let fallback_physical_codec = physical_codec.ffi_physical_codec.clone();
-    let physical_codec = Py::new(py, physical_codec)?;
-
-    let planner = PyBallistaQueryPlanner::from_codecs(
-        address,
-        session_ctx.clone(),
-        Some(ballista_overrides),
-        logical_native,
-        fallback_logical_codec,
-        fallback_physical_codec,
-    )?;
-    let planner = Py::new(py, planner)?;
-
-    // Install each Ballista codec once before the planner. datafusion-python
-    // then rebinds the planner to the receiving context's codec chains.
-    let ctx = session_ctx
-        .call_method1("with_logical_extension_codec", 
(logical_codec.bind(py),))?;
-    let ctx =
-        ctx.call_method1("with_physical_extension_codec", 
(physical_codec.bind(py),))?;
-    Ok(ctx
-        .call_method1("with_query_planner", (planner.bind(py),))?
+    Ok(session_ctx
+        .call_method1("with_extensions", (extension.bind(py),))?
         .unbind())
 }
 
diff --git a/python/uv.lock b/python/uv.lock
index f467ad317..4966fc4be 100644
--- a/python/uv.lock
+++ b/python/uv.lock
@@ -52,7 +52,7 @@ dev = [
 
 [package.metadata]
 requires-dist = [
-    { name = "datafusion", git = 
"https://github.com/apache/datafusion-python.git?rev=55f43b5e231da5591e7bbd632dac85f304a22cca";
 },
+    { name = "datafusion", git = 
"https://github.com/apache/datafusion-python.git?rev=f1526210338d75ef105246e5c948e0e4a4a0f5ce";
 },
     { name = "ipython", marker = "extra == 'jupyter'", specifier = ">=8.0.0" },
     { name = "pyarrow", specifier = ">=23.0.1" },
     { name = "typing-extensions", marker = "python_full_version < '3.13'" },
@@ -328,7 +328,7 @@ wheels = [
 [[package]]
 name = "datafusion"
 version = "54.0.0"
-source = { git = 
"https://github.com/apache/datafusion-python.git?rev=55f43b5e231da5591e7bbd632dac85f304a22cca#55f43b5e231da5591e7bbd632dac85f304a22cca";
 }
+source = { git = 
"https://github.com/apache/datafusion-python.git?rev=f1526210338d75ef105246e5c948e0e4a4a0f5ce#f1526210338d75ef105246e5c948e0e4a4a0f5ce";
 }
 dependencies = [
     { name = "cloudpickle" },
     { name = "pyarrow" },


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to