docs: user guide + runnable examples for distributing expressions (#1547)
* docs: user guide page + runnable examples for distributing expressions
Wraps up the Expr-pickle work with the user-facing material:
* docs/source/user-guide/io/distributing_work.rst — new user guide
page covering the multiprocessing, Ray, and datafusion-distributed
patterns. Includes the Security section that is the canonical home
for the cloudpickle / pickle.loads threat model.
* docs/source/user-guide/io/index.rst — toctree entry.
* examples/multiprocessing_pickle_expr.py — runnable example: a
Pool.map of a closure-capturing UDF across processes, with worker
context registration in the initializer.
* examples/ray_pickle_expr.py — Ray actor analogue.
* examples/datafusion-ffi-example/python/tests/_test_pickle_strict_ffi.py
— exercises the strict-mode refusal end to end against an FFI
capsule scalar UDF (kept under the FFI example crate because the
test needs that crate's compiled artifacts).
* examples/README.md — index entries for the new files.
Also tightens three docstrings that previously duplicated the
security warning so they point at the canonical Security section
instead:
* PythonLogicalCodec::with_python_udf_inlining (rustdoc): one-line
summary plus a relative pointer to distributing_work.rst and the
upstream Python pickle module security warning.
* SessionContext.with_python_udf_inlining: one-sentence summary plus
:doc: link to the user guide.
* datafusion.ipc module docstring: cross-reference to the user guide
for the full pattern.
The crate-level codec.rs module rustdoc also updates "pure-Python
scalar UDFs" to "scalar / aggregate / window UDFs" now that all three
are covered.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* docs: document Python-version and import portability caveats for inline UDFs
Reviewer feedback on the Expr-pickle PRs (#1544) asked that the
cloudpickle portability caveats be discoverable on the user-facing
page, not only in docstrings. The distributing_work.rst page is the
designated canonical home for the distribution story, so add them here:
* New 'Portability requirements for inline Python UDFs' subsection
covering the matching-Python-minor-version requirement and the
by-value vs by-reference import-capture rule (imported modules must
be importable on the worker).
* Qualify the 'fully portable' Python-UDF bullet to point at the new
requirements.
* Cross-reference the new subsection from the closure-capture note.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* docs: restore version-byte and cloudpickle-cache rustdoc wording
Two codec.rs docstrings were reworded in PR4 in ways that dropped
information:
* try_encode_python_scalar_udf: restore the `DFPYUDF` family prefix +
version byte description of the payload framing (PR4 had collapsed it
to `DFPYUDF1` prefix, dropping the version-byte mention).
* cloudpickle cached-handle comment: restore "The encode/decode helpers
above" wording.
* docs: fix reversed tuple order in multiprocessing example docstring
The 'Worker layout' docstring described tasks as `(expr, label)` but
the code builds and unpacks them as `(label, expr)`. Correct the doc
to match.
* Respond to first batch of reviewer comments
* docs: relocate and restructure distributing-work guide
Move the page from user-guide/io/ to the top level of user-guide/ — distributing work is a runtime/operational concern, not a file-format topic, and the shorter "Distributing work" title fits the sidebar cleanly.
Restructure the body to lead with the practical worker-setup pattern instead of the four-slot SessionContext taxonomy. The taxonomy survives at the bottom as a reference subsection; the worker-init example and portability rules now reach the reader before they need it. Also addresses reviewer NIT: wrap the `if __name__ == "__main__":` guidance in a `.. note::` admonition and link to the Python multiprocessing docs.
Add a header paragraph to each runnable example pointing to the user-guide page so a reader who jumps straight to the example gets the surrounding context.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
diff --git a/crates/core/src/codec.rs b/crates/core/src/codec.rs
index b1b9f99..a6ea667 100644
--- a/crates/core/src/codec.rs
+++ b/crates/core/src/codec.rs
@@ -19,11 +19,11 @@
//!
//! Datafusion-python plans can carry references to Python-defined
//! objects that the upstream protobuf codecs do not know how to
-//! serialize: pure-Python scalar UDFs, Python query-planning
-//! extensions, and so on. Their state lives inside `Py<PyAny>`
-//! callables and closures rather than being recoverable from a name
-//! in the receiver's function registry. To ship a plan across a
-//! process boundary (pickle, `multiprocessing`, Ray actor,
+//! serialize: pure-Python scalar / aggregate / window UDFs, Python
+//! query-planning extensions, and so on. Their state lives inside
+//! `Py<PyAny>` callables and closures rather than being recoverable
+//! from a name in the receiver's function registry. To ship a plan
+//! across a process boundary (pickle, `multiprocessing`, Ray actor,
//! `datafusion-distributed`, etc.) those payloads have to be encoded
//! into the proto wire format itself.
//!
@@ -256,7 +256,12 @@
/// `cloudpickle.loads` on the inline `DFPY*` payload. It does
/// **not** make `pickle.loads(untrusted_bytes)` safe; treat every
/// `pickle.loads` on untrusted input as unsafe regardless of this
- /// setting.
+ /// setting. See `docs/source/user-guide/io/distributing_work.rst`
+ /// (Security section) for the full threat model, and Python's
+ /// [pickle module security warning][1] for why `pickle.loads` is
+ /// unsafe in general.
+ ///
+ /// [1]: https://docs.python.org/3/library/pickle.html#module-pickle
pub fn with_python_udf_inlining(mut self, enabled: bool) -> Self {
self.python_udf_inlining = enabled;
self
@@ -433,7 +438,7 @@
/// encoding on this layer too — otherwise a plan with a Python UDF
/// would round-trip at the logical level but break at the physical
/// level. Both layers reuse the shared payload framing
-/// ([`PY_SCALAR_UDF_FAMILY`]) so the wire format is identical.
+/// ([`PY_SCALAR_UDF_FAMILY`] et al.) so the wire format is identical.
#[derive(Debug)]
pub struct PythonPhysicalCodec {
inner: Arc<dyn PhysicalExtensionCodec>,
diff --git a/docs/source/index.rst b/docs/source/index.rst
index 0007cc4..7edb698 100644
--- a/docs/source/index.rst
+++ b/docs/source/index.rst
@@ -76,6 +76,7 @@
user-guide/common-operations/index
user-guide/io/index
user-guide/configuration
+ user-guide/distributing-work
user-guide/sql
user-guide/upgrade-guides
user-guide/ai-coding-assistants
diff --git a/docs/source/user-guide/distributing-work.rst b/docs/source/user-guide/distributing-work.rst
new file mode 100644
index 0000000..03b5ca0
--- /dev/null
+++ b/docs/source/user-guide/distributing-work.rst
@@ -0,0 +1,368 @@
+.. Licensed to the Apache Software Foundation (ASF) under one
+.. or more contributor license agreements. See the NOTICE file
+.. distributed with this work for additional information
+.. regarding copyright ownership. The ASF licenses this file
+.. to you under the Apache License, Version 2.0 (the
+.. "License"); you may not use this file except in compliance
+.. with the License. You may obtain a copy of the License at
+
+.. http://www.apache.org/licenses/LICENSE-2.0
+
+.. Unless required by applicable law or agreed to in writing,
+.. software distributed under the License is distributed on an
+.. "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+.. KIND, either express or implied. See the License for the
+.. specific language governing permissions and limitations
+.. under the License.
+
+Distributing work
+=================
+
+DataFusion supports splitting work across processes by shipping
+serialized expressions to workers: the driver builds an
+:py:class:`~datafusion.Expr`, each worker evaluates it against its
+own slice of data. This pattern suits embarrassingly-parallel
+workloads where the driver decides partitioning up front.
+
+Query-level distribution — where the runtime partitions a single
+logical or physical plan across worker nodes — is in progress
+upstream via `datafusion-distributed
+<https://github.com/apache/datafusion-distributed>`_ and `Apache
+Ballista <https://github.com/apache/datafusion-ballista>`_. Both
+have short sections at the end of this page; integration details
+will land as those projects become usable from datafusion-python.
+
+Expression-level distribution
+-----------------------------
+
+DataFusion expressions support distribution directly: pass one to a
+worker process and Python's standard
+`pickle <https://docs.python.org/3/library/pickle.html>`_ machinery
+serializes it transparently — the same machinery
+:py:meth:`multiprocessing.pool.Pool.map`, Ray's ``@ray.remote``, and
+similar libraries already use to ship function arguments. Python UDFs
+— scalar, aggregate, and window — travel inside the serialized
+expression; the receiver does not need to pre-register them.
+
+Basic worker-pool example
+~~~~~~~~~~~~~~~~~~~~~~~~~
+
+Define a worker function that takes the expression plus a batch and
+returns the evaluated result:
+
+.. code-block:: python
+
+ import pyarrow as pa
+ from datafusion import SessionContext
+
+
+ def evaluate(expr, batch):
+ # `expr` arrived here via the pool's automatic pickling —
+ # no manual serialization needed in user code.
+ ctx = SessionContext()
+ df = ctx.from_pydict({"a": batch})
+ return df.with_column("result", expr).select("result").to_pydict()["result"]
+
+Then build the expression in the driver and fan it out:
+
+.. code-block:: python
+
+ import multiprocessing as mp
+ from datafusion import col, udf
+
+ double = udf(
+ lambda arr: pa.array([(v.as_py() or 0) * 2 for v in arr]),
+ [pa.int64()], pa.int64(), volatility="immutable", name="double",
+ )
+ expr = double(col("a"))
+
+ mp_ctx = mp.get_context("forkserver")
+ with mp_ctx.Pool(processes=4) as pool:
+ results = pool.starmap(
+ evaluate,
+ [(expr, [1, 2, 3]), (expr, [10, 20, 30])],
+ )
+ print(results) # [[2, 4, 6], [20, 40, 60]]
+
+.. note::
+
+ When saved to a ``.py`` file and executed with the ``spawn`` or
+ ``forkserver`` start method, wrap the driver block in
+ ``if __name__ == "__main__":`` so worker processes can re-import
+ the module without re-running it. This is a standard Python
+ :py:mod:`multiprocessing` requirement, not DataFusion-specific —
+ see `Safe importing of main module
+ <https://docs.python.org/3/library/multiprocessing.html#the-spawn-and-forkserver-start-methods>`_
+ in the Python docs.
+
+
+What travels with the expression
+~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+
+* **Built-in functions** (``abs``, ``length``, arithmetic, comparisons,
+ etc.) — fully portable. Worker needs nothing pre-registered.
+* **Python UDFs** — travel inline (subject to the two portability
+ requirements below). The callable, its signature, and any state
+ captured in closures travel inside the serialized expression and are
+ reconstructed on the worker automatically. Applies equally to:
+
+ * **scalar UDFs** (:py:func:`datafusion.udf`)
+ * **aggregate UDFs** (:py:func:`datafusion.udaf`)
+ * **window UDFs** (:py:func:`datafusion.udwf`)
+* **UDFs imported via the FFI capsule protocol** — travel **by name
+ only**. The worker must already have a matching registration on its
+ :py:class:`SessionContext`. Without that registration, evaluation
+ raises an error.
+
+Portability requirements for inline Python UDFs
+~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+
+Inline Python UDFs ride on `cloudpickle
+<https://github.com/cloudpipe/cloudpickle>`_, which imposes two
+requirements on the worker environment:
+
+* **Matching Python minor version.** cloudpickle serializes Python
+ bytecode, which is not stable across minor versions. A UDF pickled
+ on 3.12 cannot be reconstructed on 3.11 or 3.13. The wire format
+ stamps the sender's ``(major, minor)``; mismatches raise a clear
+ error naming both versions. Align the Python version on driver and
+ workers.
+* **Imported modules must be importable on the worker.** cloudpickle
+ captures the callable *by value* (bytecode and closure cells travel
+ whole), but names resolved through ``import`` are captured *by
+ reference* — module path only. A UDF doing
+ ``from mylib import transform`` requires ``mylib`` installed on the
+ worker. Same applies to bound methods of imported classes.
+ Self-contained UDFs (no imports beyond what the worker already has,
+ e.g. ``pyarrow``) avoid this entirely.
+
+Registering shared UDFs on workers
+~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+
+When an expression references an FFI capsule UDF (or any UDF the
+worker must resolve from its registered functions), set up the
+worker's :py:class:`SessionContext` once per process and install it
+as the *worker context*:
+
+.. code-block:: python
+
+ from datafusion import SessionContext
+ from datafusion.ipc import set_worker_ctx
+
+
+ def init_worker():
+ ctx = SessionContext()
+ ctx.register_udaf(my_ffi_aggregate)
+ set_worker_ctx(ctx)
+
+
+ with mp.get_context("forkserver").Pool(
+ processes=4, initializer=init_worker
+ ) as pool:
+ ...
+
+Inside a worker, expressions arriving from the driver resolve their
+by-name references against the installed worker context. If no worker
+context is installed, the global :py:class:`SessionContext` is used —
+fine for expressions that only reference built-ins and Python UDFs,
+but FFI-capsule-backed registrations must be installed on the global
+context to resolve.
+
+Python 3.14 default change
+~~~~~~~~~~~~~~~~~~~~~~~~~~
+
+Python 3.14 changed the Linux default start method for
+:py:mod:`multiprocessing` from ``fork`` to ``forkserver`` (macOS has
+defaulted to ``spawn`` since Python 3.8; Windows has always used
+``spawn``). With ``fork``, any state set in the parent was visible in
+workers via copy-on-write; with ``forkserver`` and ``spawn`` it is
+not. The :py:func:`~datafusion.ipc.set_worker_ctx` pattern works on
+every start method — prefer it over relying on inherited state.
+
+Practical considerations
+~~~~~~~~~~~~~~~~~~~~~~~~
+
+* **Serialized size scales with what travels inline.** A serialized
+ expression of just built-ins is small (tens of bytes). An
+ expression carrying a Python UDF is hundreds of bytes (the callable
+ and its signature). When the same UDF is shipped many times,
+ registering an equivalent FFI-capsule UDF on each worker via
+ :py:func:`~datafusion.ipc.set_worker_ctx` and referring to it by
+ name cuts the per-trip overhead.
+* **Closure capture.** When a Python UDF closes over surrounding
+ state — local variables, module-level objects, file paths — that
+ state is captured at serialization time. Surprises are possible if
+ the captured state is large, mutable, or not portable to the
+ worker's environment. See `Portability requirements for inline
+ Python UDFs`_ for the Python-version and imported-module rules.
+
+Disabling Python UDF inlining
+~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+
+For a stricter wire format, call
+:py:meth:`SessionContext.with_python_udf_inlining(enabled=False)
+<datafusion.SessionContext.with_python_udf_inlining>` on the session
+producing or consuming the bytes. With inlining disabled, Python
+UDFs travel by name only — the same way FFI-capsule UDFs do — and
+the receiver must have a matching registration.
+
+Two use cases:
+
+* **Cross-language portability.** A non-Python decoder cannot
+ reconstruct a cloudpickled payload. Senders aimed at Java, C++,
+ or another Rust binary disable inlining and rely on the receiver
+ having compatible UDF registrations.
+* **Untrusted-source decode.** With inlining disabled,
+ :py:meth:`Expr.from_bytes` never calls ``cloudpickle.loads`` on
+ the incoming bytes — an inline payload from a misbehaving sender
+ raises a clear error instead of executing arbitrary Python code.
+
+Mismatched configurations raise a descriptive error: an inline blob
+fed to a strict receiver fails fast rather than silently dropping
+into ``cloudpickle.loads``.
+
+To make the toggle apply through :py:func:`pickle.dumps` (which
+calls :py:meth:`Expr.to_bytes` with no context), install the strict
+session as the driver's *sender context*:
+
+.. code-block:: python
+
+ from datafusion import SessionContext
+ from datafusion.ipc import set_sender_ctx
+
+ set_sender_ctx(SessionContext().with_python_udf_inlining(enabled=False))
+ # Every subsequent pickle.dumps(expr) on this thread encodes
+ # without inlining the Python callable.
+
+Pair with a matching strict worker context
+(:py:func:`~datafusion.ipc.set_worker_ctx`) so the ``pickle.loads``
+side also refuses inline payloads. Explicit
+:py:meth:`Expr.to_bytes(ctx) <Expr.to_bytes>` and
+:py:meth:`Expr.from_bytes(blob, ctx=ctx) <Expr.from_bytes>` calls
+honor the supplied ``ctx`` directly and ignore the sender / worker
+contexts.
+
+The toggle only narrows the :py:meth:`Expr.from_bytes` surface;
+:py:func:`pickle.loads` on untrusted bytes remains unsafe regardless
+of this setting. See the `Security`_ section below for the full
+threat model.
+
+Security
+~~~~~~~~
+
+.. warning::
+
+ Reconstructing an expression containing a Python UDF executes
+ arbitrary Python code on the receiver — pickle is doing the work
+ under the hood and pickle is unsafe on untrusted input (see the
+ `pickle module security warning
+ <https://docs.python.org/3/library/pickle.html#module-pickle>`_
+ in the Python standard library docs). Only accept expressions
+ from trusted sources. For untrusted-source workflows, disable
+ Python UDF inlining (see above), restrict senders to built-in
+ functions and pre-registered Rust-side UDFs, and avoid
+ :py:func:`pickle.loads` on externally supplied bytes entirely.
+
+Reference: session context slots
+~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+
+There is only one type — :py:class:`SessionContext`. It can occupy
+up to four *slots* in a running program:
+
+.. list-table::
+ :header-rows: 1
+ :widths: 12 18 40 30
+
+ * - Slot
+ - Lifetime
+ - Purpose
+ - Set how
+ * - User-held
+ - Local variable / attribute
+ - Build and run queries
+ - ``ctx = SessionContext(...)``
+ * - Global
+ - Process singleton (lazy-init)
+ - Backs module-level
+ :py:func:`~datafusion.io.read_parquet`,
+ :py:func:`~datafusion.io.read_csv`,
+ :py:func:`~datafusion.io.read_json`,
+ :py:func:`~datafusion.io.read_avro`; final fallback for
+ :py:meth:`Expr.from_bytes`
+ - Implicit; access via
+ :py:meth:`SessionContext.global_ctx`
+ * - Sender
+ - Thread-local on the driver
+ - Codec settings for outbound :py:func:`pickle.dumps` /
+ :py:meth:`Expr.to_bytes` without ``ctx``
+ - :py:func:`~datafusion.ipc.set_sender_ctx`
+ * - Worker
+ - Thread-local on the worker
+ - Function registry for inbound :py:func:`pickle.loads` /
+ :py:meth:`Expr.from_bytes` without ``ctx``
+ - :py:func:`~datafusion.ipc.set_worker_ctx`
+
+The same :py:class:`SessionContext` object may occupy more than one
+slot simultaneously — installing it into a slot is a reference, not
+a copy. A non-distributed program only ever uses the user-held slot;
+the global slot is invisible unless you call top-level ``read_*``
+helpers.
+
+Resolution order on the worker side is *explicit argument →
+worker context → global context.* Explicit ``ctx=`` on
+:py:meth:`Expr.from_bytes` always wins; the sender slot is ignored
+on decode and the worker slot is ignored on encode.
+
+Sharp edges:
+
+* Sender and worker slots are **thread-local**. Background threads
+ on either side see ``None`` until they install their own.
+* Under the ``fork`` start method, the parent's ``threading.local()``
+ values are copied into the child by copy-on-write — a forked
+ worker initially observes whatever sender / worker slot the parent
+ had set, until the worker writes its own value (or calls the
+ matching ``clear_*_ctx``). ``spawn`` and ``forkserver`` workers
+ start with empty thread-local slots. Treat the slot as
+ uninitialized on worker entry and install (or clear) it explicitly
+ in the worker initializer; do not rely on inherited state.
+* The global slot persists across ``fork`` workers (copy-on-write
+ memory inherit) but not across ``spawn`` / ``forkserver`` workers
+ (fresh process — register or install a worker context on
+ start-up).
+* The inlining toggle is per-context state, not a global switch.
+ Two contexts with different toggles can coexist in one process.
+
+Query-level distribution via datafusion-distributed
+---------------------------------------------------
+
+🚧 *Work in progress upstream — not yet usable from datafusion-python.*
+
+`datafusion-distributed <https://github.com/apache/datafusion-distributed>`_
+splits a single physical plan into stages and runs each stage on a
+different worker node. The driver writes a SQL or DataFrame query
+once; the runtime handles partitioning, shuffles, and reassembly.
+
+A datafusion-python integration is in development. This section will
+document the integration once it lands. In the meantime, the
+expression-level approach above covers most use cases that do not
+require automatic plan partitioning.
+
+Query-level distribution via Apache Ballista
+--------------------------------------------
+
+🚧 *Work in progress upstream — not yet usable from datafusion-python.*
+
+`Apache Ballista <https://github.com/apache/datafusion-ballista>`_
+provides distributed query execution on top of DataFusion with a
+scheduler / executor model better suited to long-lived cluster
+deployments. A datafusion-python integration is on the roadmap; this
+section will fill in once the integration is usable.
+
+See also
+--------
+
+* :py:mod:`datafusion.ipc` — worker context API.
+* ``examples/multiprocessing_pickle_expr.py`` — runnable
+ ``multiprocessing.Pool`` example that ships a different parametric
+ expression to each worker and collects results back.
+* ``examples/ray_pickle_expr.py`` — runnable Ray actor example.
diff --git a/examples/README.md b/examples/README.md
index 3024c78..e0e3056 100644
--- a/examples/README.md
+++ b/examples/README.md
@@ -44,6 +44,11 @@
- [Register a Python UDF with DataFusion](./python-udf.py)
- [Register a Python UDAF with DataFusion](./python-udaf.py)
+### Distributing DataFusion expressions
+
+- [Fan out distinct expressions to a multiprocessing pool](./multiprocessing_pickle_expr.py)
+- [Distribute expression evaluation across Ray actors](./ray_pickle_expr.py)
+
### Substrait Support
- [Serialize query plans using Substrait](./substrait.py)
diff --git a/examples/datafusion-ffi-example/python/tests/_test_pickle_strict_ffi.py b/examples/datafusion-ffi-example/python/tests/_test_pickle_strict_ffi.py
new file mode 100644
index 0000000..67c0b24
--- /dev/null
+++ b/examples/datafusion-ffi-example/python/tests/_test_pickle_strict_ffi.py
@@ -0,0 +1,127 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+"""Strict-mode Expr round-trip with an FFI-capsule scalar UDF.
+
+Verifies the by-name path: an FFI-imported UDF (no
+``PythonFunctionScalarUDF`` downcast on the codec) serializes by name
+and resolves from the receiver's function registry on decode. Covers
+both the explicit ``Expr.to_bytes(ctx)`` / ``Expr.from_bytes(ctx=...)``
+API and the ``pickle.dumps`` / ``pickle.loads`` route through the
+sender / worker context slots.
+"""
+
+from __future__ import annotations
+
+import pickle
+
+import pyarrow as pa
+import pytest
+from datafusion import Expr, SessionContext, col, udf
+from datafusion.ipc import (
+ clear_sender_ctx,
+ clear_worker_ctx,
+ set_sender_ctx,
+ set_worker_ctx,
+)
+from datafusion_ffi_example import IsNullUDF
+
+
+@pytest.fixture(autouse=True)
+def _reset_thread_locals():
+ """Ensure no sender / worker context leaks across tests."""
+ clear_worker_ctx()
+ clear_sender_ctx()
+ yield
+ clear_worker_ctx()
+ clear_sender_ctx()
+
+
+def _strict_session_with_ffi_udf():
+ """Build a strict-mode session with the FFI ``IsNullUDF`` registered."""
+ ctx = SessionContext().with_python_udf_inlining(enabled=False)
+ my_udf = udf(IsNullUDF())
+ ctx.register_udf(my_udf)
+ return ctx, my_udf
+
+
+def test_strict_ffi_udf_expr_roundtrip_via_to_bytes():
+ """Strict-mode encode emits a by-name payload; receiver resolves
+ ``my_custom_is_null`` from its registered functions and the decoded
+ expression evaluates to the same result as the original."""
+ sender, my_udf = _strict_session_with_ffi_udf()
+ receiver, _ = _strict_session_with_ffi_udf()
+
+ expr = my_udf(col("a"))
+ blob = expr.to_bytes(sender)
+ restored = Expr.from_bytes(blob, ctx=receiver)
+
+ assert "my_custom_is_null" in restored.canonical_name()
+
+ batch = pa.RecordBatch.from_arrays(
+ [pa.array([1, 2, None, 4], type=pa.int64())], names=["a"]
+ )
+ receiver.register_record_batches("t", [[batch]])
+ out = receiver.table("t").select(restored.alias("r")).collect()
+ expected = pa.array([False, False, True, False], type=pa.bool_())
+ assert out[0].column(0) == expected
+
+
+def test_strict_ffi_udf_pickle_roundtrip_via_thread_locals():
+ """Driver installs a strict sender context; worker installs a
+ matching strict receiver. ``pickle.dumps`` / ``pickle.loads`` route
+ through them and the FFI UDF resolves by name on decode."""
+ sender, my_udf = _strict_session_with_ffi_udf()
+ receiver, _ = _strict_session_with_ffi_udf()
+
+ expr = my_udf(col("a"))
+
+ set_sender_ctx(sender)
+ try:
+ blob = pickle.dumps(expr)
+ finally:
+ clear_sender_ctx()
+
+ set_worker_ctx(receiver)
+ try:
+ restored = pickle.loads(blob) # noqa: S301
+ finally:
+ clear_worker_ctx()
+
+ assert "my_custom_is_null" in restored.canonical_name()
+
+
+def test_strict_ffi_udf_smaller_than_inline_python_udf():
+ """Sanity-check the wire size claim: strict-mode FFI UDF bytes are
+ a small by-name payload, dramatically smaller than the inline form
+ of a Python UDF with the same arity. Confirms the encode path
+ actually took the by-name branch instead of falling through to an
+ inline path."""
+ sender, my_udf = _strict_session_with_ffi_udf()
+ ffi_blob = my_udf(col("a")).to_bytes(sender)
+
+ inline_ctx = SessionContext()
+ py_udf = udf(
+ lambda arr: pa.array([v.as_py() is None for v in arr]),
+ [pa.int64()],
+ pa.bool_(),
+ volatility="immutable",
+ name="py_is_null",
+ )
+ py_blob = py_udf(col("a")).to_bytes(inline_ctx)
+
+ assert len(ffi_blob) < len(py_blob) // 4
diff --git a/examples/multiprocessing_pickle_expr.py b/examples/multiprocessing_pickle_expr.py
new file mode 100644
index 0000000..73a99c2
--- /dev/null
+++ b/examples/multiprocessing_pickle_expr.py
@@ -0,0 +1,172 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+"""Distribute different DataFusion expressions to worker processes.
+
+For background — the shipped-expression model, what travels inline vs
+by name, portability requirements, and the security threat model —
+see ``docs/source/user-guide/distributing-work.rst``.
+
+Builds a list of parametric expressions in the driver — each closing
+over a different threshold value — ships one per worker via
+``multiprocessing.Pool``, and collects the results back. The closure
+state forces the cloudpickle path (a by-name registration would lose
+the captured threshold), so this is a real test of the expression-
+pickling story rather than a same-expression fan-out.
+
+Worker layout:
+
+* Each worker receives a different ``(label, expr)`` task.
+* Each worker materializes the shared dataset locally and runs its
+ own expression against it.
+* The result and the worker's PID travel back to the driver, so the
+ output makes it visible that the work was spread across processes.
+
+Run:
+ python examples/multiprocessing_pickle_expr.py
+"""
+
+from __future__ import annotations
+
+import multiprocessing as mp
+import os
+
+import pyarrow as pa
+from datafusion import Expr, SessionContext, col, udaf, udf
+from datafusion import functions as F
+from datafusion.user_defined import Accumulator, AggregateUDF, ScalarUDF
+
+# A shared input dataset. In a production pipeline this would live on
+# object storage; here we hand-roll a small batch so the example runs
+# without any I/O setup.
+DATASET = {
+ "value": [3, 17, 42, 5, 88, 21, 9, 56, 4, 73, 12, 31],
+}
+
+
+def make_above_threshold_udf(threshold: int) -> ScalarUDF:
+ """Build a scalar UDF that returns 1 where ``value > threshold`` else 0.
+
+ The threshold is captured in the closure, so cloudpickle has to
+ walk into the function body to ship the value across processes —
+ a by-name registration on the worker would collapse every
+ threshold into the same callable and lose the per-task state.
+ """
+
+ def above(arr: pa.Array) -> pa.Array:
+ # `v.as_py() or 0` coerces nulls to 0 — the demo dataset has no
+ # nulls, but real-world code should decide explicitly how nulls
+ # compare against the threshold.
+ return pa.array([1 if (v.as_py() or 0) > threshold else 0 for v in arr])
+
+ return udf(
+ above,
+ [pa.int64()],
+ pa.int64(),
+ volatility="immutable",
+ name=f"above_{threshold}",
+ )
+
+
+class _SumAccumulator(Accumulator):
+ """Tiny aggregate UDF state used to demonstrate UDAFs travel too."""
+
+ def __init__(self) -> None:
+ self._total = 0
+
+ def state(self) -> list[pa.Scalar]:
+ return [pa.scalar(self._total, type=pa.int64())]
+
+ def update(self, values: pa.Array) -> None:
+ for v in values:
+ self._total += v.as_py() or 0
+
+ def merge(self, states: list[pa.Array]) -> None:
+ for s in states:
+ self._total += s[0].as_py()
+
+ def evaluate(self) -> pa.Scalar:
+ return pa.scalar(self._total, type=pa.int64())
+
+
+def _build_sum_udaf() -> AggregateUDF:
+ return udaf(
+ _SumAccumulator,
+ [pa.int64()],
+ pa.int64(),
+ [pa.int64()],
+ "immutable",
+ name="my_sum",
+ )
+
+
+def evaluate_in_worker(task: tuple[str, Expr]) -> tuple[str, int, int]:
+ """Run one expression against the shared dataset.
+
+ ``task`` arrived here via the pool's automatic pickling. The Python
+ callable inside the expression (including its captured threshold)
+ was reconstructed by the codec — the worker did not have to
+ register anything before this call.
+ """
+ label, expr = task
+ ctx = SessionContext()
+ df = ctx.from_pydict(DATASET)
+ # ``expr`` is an aggregate over the whole batch; ``aggregate`` keeps
+ # a single row of output, which we read as a Python int.
+ result_df = df.aggregate([], [expr.alias("result")])
+ result = result_df.to_pydict()["result"][0]
+ return label, result, os.getpid()
+
+
+def build_tasks() -> list[tuple[str, Expr]]:
+ """Return ``(label, expr)`` pairs — one task per worker invocation.
+
+ Mixes scalar-UDF-in-aggregate and pure-aggregate work to show both
+ UDF kinds round-tripping through pickle.
+ """
+ sum_udaf = _build_sum_udaf()
+ tasks: list[tuple[str, Expr]] = []
+
+ # Three "count values strictly above threshold T" tasks built from
+ # closure-capturing scalar UDFs.
+ for threshold in (10, 30, 60):
+ above_udf = make_above_threshold_udf(threshold)
+ tasks.append((f"count_above_{threshold}", F.sum(above_udf(col("value")))))
+
+ # One pure aggregate UDF task.
+ tasks.append(("custom_sum", sum_udaf(col("value"))))
+
+ return tasks
+
+
+def main() -> None:
+ tasks = build_tasks()
+
+ # ``forkserver`` works on every POSIX platform and is the Python 3.14
+ # default for POSIX. ``spawn`` would also work; ``fork`` is unsafe
+ # with pyarrow/tokio on macOS.
+ mp_ctx = mp.get_context("forkserver")
+ with mp_ctx.Pool(processes=min(4, len(tasks))) as pool:
+ results = pool.map(evaluate_in_worker, tasks)
+
+ print(f"driver pid: {os.getpid()}")
+ for label, value, worker_pid in results:
+ print(f" [{label:>16}] = {value:>6} (worker pid: {worker_pid})")
+
+
+if __name__ == "__main__":
+ main()
diff --git a/examples/ray_pickle_expr.py b/examples/ray_pickle_expr.py
new file mode 100644
index 0000000..04cea46
--- /dev/null
+++ b/examples/ray_pickle_expr.py
@@ -0,0 +1,86 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+"""Distribute DataFusion expressions to Ray actors.
+
+For background — the shipped-expression model, what travels inline vs
+by name, portability requirements, and the security threat model —
+see ``docs/source/user-guide/distributing-work.rst``.
+
+Build an expression in the driver, ship it to a pool of Ray actors, and
+have each actor evaluate it against its own slice of data. Python UDFs
+travel with the shipped expression — no actor-side registration needed.
+
+Prerequisites:
+ pip install ray
+
+Run:
+ python examples/ray_pickle_expr.py
+"""
+
+import pyarrow as pa
+import ray
+from datafusion import Expr, SessionContext, col, lit, udf
+
+
+def _build_double_udf():
+ """Return the demo UDF used by the driver."""
+ return udf(
+ lambda arr: pa.array([(v.as_py() or 0) * 2 for v in arr]),
+ [pa.int64()],
+ pa.int64(),
+ volatility="immutable",
+ name="double",
+ )
+
+
+@ray.remote
+class DataFusionWorker:
+ """A Ray actor with a private :class:`SessionContext`."""
+
+ def __init__(self) -> None:
+ self._ctx = SessionContext()
+
+ def evaluate(self, expr: Expr, batch_pylist: list[int]) -> list[int]:
+ """Run the expression against an in-memory batch."""
+ # `expr` arrived here via Ray's automatic argument serialization;
+ # the Python UDF inside it was reconstructed from the bytes — no
+ # pre-registration on this actor required.
+ df = self._ctx.from_pydict({"a": batch_pylist})
+ out = df.with_column("result", expr).select("result")
+ return out.to_pydict()["result"]
+
+
+def main() -> None:
+ ray.init(ignore_reinit_error=True)
+
+ expr = _build_double_udf()(col("a")) + lit(1)
+
+ workers = [DataFusionWorker.remote() for _ in range(2)]
+ batches = [[1, 2, 3], [10, 20, 30], [100, 200, 300]]
+ futures = [
+ workers[i % len(workers)].evaluate.remote(expr, batch)
+ for i, batch in enumerate(batches)
+ ]
+ for batch, result in zip(batches, ray.get(futures), strict=True):
+ print(f"input {batch} -> {result}")
+
+ ray.shutdown()
+
+
+if __name__ == "__main__":
+ main()