blob: 8f43bdb5edd6157a4891ce62964cdfb25a2082fd [file]
// 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.
//! Python-aware extension codecs.
//!
//! Datafusion-python plans can carry references to Python-defined
//! objects that the upstream protobuf codecs do not know how to
//! 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.
//!
//! [`PythonLogicalCodec`] is the [`LogicalExtensionCodec`] that
//! datafusion-python parks on every `SessionContext`. It wraps a
//! chain of composable codecs and adds Python-aware in-band encoding
//! on top: when the encoder sees a Python-defined UDF, the codec
//! cloudpickles the callable + signature into the `fun_definition`
//! proto field; when the decoder sees a payload it produced, it
//! reconstructs the UDF from the bytes alone — no pre-registration on
//! the receiver. Everything the codec does not recognise is delegated
//! to the chain: each downstream FFI codec installed via
//! `SessionContext.with_logical_extension_codec(...)` is appended, and
//! encoding consults them in install order with
//! `DefaultLogicalExtensionCodec` as the terminal fallback. Decoding
//! does not walk the chain at all — a payload names the codec that
//! wrote it. See [`PythonLogicalCodec`].
//!
//! [`PythonPhysicalCodec`] is the symmetric wrapper around
//! [`PhysicalExtensionCodec`]. Logical and physical layers each have
//! a `try_encode_udf` / `try_decode_udf` pair, so a `ScalarUDF`
//! referenced inside a `LogicalPlan`, an `ExecutionPlan`, or a
//! `PhysicalExpr` must encode identically through either layer for
//! plans to survive a serialization round-trip. Both codecs share
//! the same payload framing for that reason.
//!
//! Payloads emitted by these codecs are framed as
//! `<family_magic: 7 bytes> <version: u8> <py_major: u8> <py_minor: u8> <cloudpickle blob>`.
//! The family magic identifies the UDF flavor; the version byte lets
//! the decoder reject too-new or too-old payloads with a clean error
//! instead of falling into an opaque `cloudpickle` tuple-unpack
//! failure when the tuple shape changes; the Python `(major, minor)`
//! bytes catch the cloudpickle-cross-minor-version case and raise an
//! actionable error instead of an opaque `marshal` failure on load
//! (cloudpickle payloads are not portable across Python minor
//! versions). Dispatch precedence on decode: **family match +
//! supported version + matching Python version → codec chain →
//! caller's `FunctionRegistry` fallback.**
//!
//! ## Wire-format family registry
//!
//! | Layer + kind | Family prefix |
//! | ----------------------------- | ------------- |
//! | `PythonLogicalCodec` scalar | `DFPYUDF` |
//! | `PythonLogicalCodec` agg | `DFPYUDA` |
//! | `PythonLogicalCodec` window | `DFPYUDW` |
//! | `PythonPhysicalCodec` scalar | `DFPYUDF` |
//! | `PythonPhysicalCodec` agg | `DFPYUDA` |
//! | `PythonPhysicalCodec` window | `DFPYUDW` |
//! | User FFI extension codec | user-chosen |
//! | Default codec | (none) |
//!
//! Current wire-format version is [`WIRE_VERSION_CURRENT`]; supported
//! receive range is `WIRE_VERSION_MIN_SUPPORTED..=WIRE_VERSION_CURRENT`.
//! Bump [`WIRE_VERSION_CURRENT`] whenever the cloudpickle tuple shape
//! changes; raise [`WIRE_VERSION_MIN_SUPPORTED`] when dropping support
//! for an older shape.
//!
//! Downstream FFI codecs should pick non-colliding family prefixes
//! (use a `DF` namespace plus a crate-specific suffix) and return an
//! error for *objects* they do not own — on encode, that error is the
//! chain's "not mine" signal, letting the next codec take a turn. A
//! codec that answers `Ok` for objects outside its family claims them
//! ahead of every codec installed after it.
//!
//! Rejecting foreign *payloads* is not asked of a codec, because a
//! payload is only ever handed to the codec whose id it carries. A
//! codec is free to check for a marker of its own anyway — that costs
//! nothing here and is worth keeping for hosts that dispatch by
//! position or by trial — but dispatch never depends on it. See
//! [`PythonLogicalCodec`].
use std::sync::Arc;
use arrow::datatypes::{DataType, Field, Schema, SchemaRef};
use arrow::ipc::reader::StreamReader;
use arrow::ipc::writer::StreamWriter;
use datafusion::common::{Result, TableReference};
use datafusion::datasource::TableProvider;
use datafusion::datasource::file_format::FileFormatFactory;
use datafusion::execution::TaskContext;
use datafusion::logical_expr::{
AggregateUDF, AggregateUDFImpl, Extension, LogicalPlan, ScalarUDF, ScalarUDFImpl, Signature,
TypeSignature, Volatility, WindowUDF, WindowUDFImpl,
};
use datafusion::physical_expr::PhysicalExpr;
use datafusion::physical_expr_common::physical_expr::proto_decode::PhysicalExprDecodeCtx;
use datafusion::physical_expr_common::physical_expr::proto_encode::PhysicalExprEncodeCtx;
use datafusion::physical_plan::ExecutionPlan;
use datafusion_proto::logical_plan::{DefaultLogicalExtensionCodec, LogicalExtensionCodec};
use datafusion_proto::physical_plan::{
DefaultPhysicalExtensionCodec, PhysicalExtensionCodec, PhysicalProtoConverterExtension,
};
use pyo3::prelude::*;
use pyo3::sync::PyOnceLock;
use pyo3::types::{PyBytes, PyTuple};
use crate::errors::to_datafusion_err;
use crate::udaf::PythonFunctionAggregateUDF;
use crate::udf::PythonFunctionScalarUDF;
use crate::udwf::PythonFunctionWindowUDF;
// Wire-format framing for inlined Python UDF payloads.
//
// Layout: `<family_magic: 7 bytes> <version: u8> <py_major: u8> <py_minor: u8> <cloudpickle blob>`.
// The family magic identifies the UDF flavor; the version byte lets
// the decoder reject too-new or too-old payloads with a clean error
// instead of falling into an opaque `cloudpickle` tuple-unpack failure
// when the tuple shape changes; the Python `(major, minor)` bytes
// catch the cloudpickle-cross-minor-version case (cloudpickle is not
// portable across Python minor versions) and raise an actionable
// error instead of an opaque `marshal` failure on load. Bump
// [`WIRE_VERSION_CURRENT`] whenever the tuple shape changes; raise
// [`WIRE_VERSION_MIN_SUPPORTED`] when dropping support for an older
// shape.
/// Family prefix for an inlined Python scalar UDF
/// (cloudpickled tuple of name, callable, input schema, return field,
/// volatility).
pub(crate) const PY_SCALAR_UDF_FAMILY: &[u8] = b"DFPYUDF";
/// Family prefix for an inlined Python aggregate UDF
/// (cloudpickled tuple of name, accumulator factory, input schema bytes,
/// return schema bytes (single-field IPC schema), state schema bytes,
/// volatility).
pub(crate) const PY_AGG_UDF_FAMILY: &[u8] = b"DFPYUDA";
/// Family prefix for an inlined Python window UDF
/// (cloudpickled tuple of name, evaluator factory, input schema bytes,
/// return schema bytes (single-field IPC schema), volatility).
pub(crate) const PY_WINDOW_UDF_FAMILY: &[u8] = b"DFPYUDW";
/// Wire-format version this build emits.
pub(crate) const WIRE_VERSION_CURRENT: u8 = 1;
/// Oldest wire-format version this build still decodes. Bump when
/// retiring support for an older payload shape.
pub(crate) const WIRE_VERSION_MIN_SUPPORTED: u8 = 1;
/// Tag `buf` with the framing header for `family` at the current
/// wire-format version, stamping `py_version` as `(major, minor)`
/// bytes. Append-only — the caller writes the cloudpickle payload
/// after.
fn write_wire_header(buf: &mut Vec<u8>, family: &[u8], py_version: (u8, u8)) {
buf.extend_from_slice(family);
buf.push(WIRE_VERSION_CURRENT);
buf.push(py_version.0);
buf.push(py_version.1);
}
/// Inspect the framing on `buf`.
///
/// * `Ok(None)` — `buf` does not carry `family`. The caller should
/// delegate to its codec chain.
/// * `Ok(Some(payload))` — `buf` carries `family` at a version this
/// build accepts and a Python `(major, minor)` matching
/// `expected_py`; `payload` is the cloudpickle blob.
/// * `Err(_)` — `buf` carries `family` but the wire-format version
/// is outside `WIRE_VERSION_MIN_SUPPORTED..=WIRE_VERSION_CURRENT`,
/// or the stamped Python `(major, minor)` does not match
/// `expected_py`. The error names the offending values so an
/// operator can diagnose sender/receiver drift instead of seeing
/// an opaque cloudpickle tuple-unpack or `marshal` failure.
fn strip_wire_header<'a>(
buf: &'a [u8],
family: &[u8],
kind: &str,
expected_py: (u8, u8),
) -> Result<Option<&'a [u8]>> {
if !buf.starts_with(family) {
return Ok(None);
}
let version_idx = family.len();
let Some(&version) = buf.get(version_idx) else {
return Err(datafusion::error::DataFusionError::Execution(format!(
"Truncated inline Python {kind} payload: missing wire-format version byte"
)));
};
if !(WIRE_VERSION_MIN_SUPPORTED..=WIRE_VERSION_CURRENT).contains(&version) {
return Err(datafusion::error::DataFusionError::Execution(format!(
"Inline Python {kind} payload wire-format version v{version}; \
this build supports v{WIRE_VERSION_MIN_SUPPORTED}..=v{WIRE_VERSION_CURRENT}. \
Align datafusion-python versions on sender and receiver."
)));
}
let py_major_idx = version_idx + 1;
let Some(&encoded_major) = buf.get(py_major_idx) else {
return Err(datafusion::error::DataFusionError::Execution(format!(
"Truncated inline Python {kind} payload: missing Python major version byte"
)));
};
let py_minor_idx = version_idx + 2;
let Some(&encoded_minor) = buf.get(py_minor_idx) else {
return Err(datafusion::error::DataFusionError::Execution(format!(
"Truncated inline Python {kind} payload: missing Python minor version byte"
)));
};
let (current_major, current_minor) = expected_py;
if encoded_major != current_major || encoded_minor != current_minor {
return Err(datafusion::error::DataFusionError::Execution(format!(
"Inline Python {kind} payload was serialized on Python \
{encoded_major}.{encoded_minor} but this process is running Python \
{current_major}.{current_minor}. cloudpickle payloads are not portable \
across Python minor versions. Align Python versions on sender and receiver."
)));
}
Ok(Some(&buf[py_minor_idx + 1..]))
}
/// Family prefix for the envelope wrapping a chained codec's payload.
///
/// A distinct magic is what makes "is this framed?" a definite test
/// rather than a speculative decode. Probing by attempting to parse the
/// envelope would reintroduce exactly the protobuf ambiguity this
/// framing exists to remove: prost skips unknown fields and defaults
/// missing ones, so a foreign payload can parse cleanly as an envelope.
pub(crate) const CHAINED_PAYLOAD_FAMILY: &[u8] = b"DFPYCHN";
/// Wire-format version for the chained-payload envelope. Independent of
/// [`WIRE_VERSION_CURRENT`], which versions the cloudpickle framing.
pub(crate) const CHAIN_WIRE_VERSION_CURRENT: u8 = 1;
/// Oldest chained-payload envelope version this build decodes.
pub(crate) const CHAIN_WIRE_VERSION_MIN_SUPPORTED: u8 = 1;
/// Prefix for the id given to a codec installed from a bare
/// `PyCapsule`.
///
/// Every capsule reports the same type, so there is nothing on it to
/// derive an id from. The rest of the id is a fresh UUID, minted when
/// the codec is installed. Plans it encodes decode on the session that
/// installed it, and on sessions cloned from that one, because cloning
/// copies the chain along with its ids. On any other session the id is
/// simply missing, and decoding says so.
///
/// Numbering the capsules instead — `anon:0`, `anon:1` — would be
/// worse. Every session starts counting at zero, so one session's
/// `anon:0` would be accepted by another session and decoded with
/// whatever codec happened to be its own first capsule.
pub(crate) const ANONYMOUS_CODEC_ID_PREFIX: &str = "anon:";
/// Prefix for the id a `SessionContext` reports when its own codec stack
/// is installed as an extension codec on another session.
///
/// An ordinary codec object takes its id from its class, which is the
/// library's import path. That does not work for a session: every
/// session is an instance of the same class, so they would all report
/// the same id. Installing two sessions as codecs on one target would
/// then collide, and a plan encoded by one would be decoded by the
/// other. A session id is unique per session and stable, so the rest
/// of the id carries that.
pub(crate) const SESSION_CODEC_ID_PREFIX: &str = "session:";
/// One installed codec plus the identity its payloads are tagged with.
///
/// The id is what makes dispatch order-independent. Keying on position
/// in the chain — as `ComposedPhysicalExtensionCodec` does upstream —
/// is sound only when both ends assemble the same list in the same
/// order. That holds for the consumers upstream was written for, whose
/// lists structurally cannot disagree: Ballista's is a compile-time
/// constant, and `datafusion-distributed` pins its own codec at index 0
/// and appends user codecs rebuilt from the same startup code on every
/// node. It does not hold here. A chain is assembled by user Python,
/// and `Expr.to_bytes(ctx1)` / `Expr.from_bytes(ctx2)` puts two
/// independently configured sessions on either end of one payload, so
/// an index would name a different codec in the decoder as soon as
/// install order differed.
struct ChainEntry<C: ?Sized> {
id: Arc<str>,
codec: Arc<C>,
}
impl<C: ?Sized> Clone for ChainEntry<C> {
fn clone(&self) -> Self {
Self {
id: Arc::clone(&self.id),
codec: Arc::clone(&self.codec),
}
}
}
impl<C: ?Sized + std::fmt::Debug> std::fmt::Debug for ChainEntry<C> {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("ChainEntry")
.field("id", &self.id)
.field("codec", &self.codec)
.finish()
}
}
/// Wrap `blob` in the envelope identifying the codec that produced it.
///
/// Layout: `DFPYCHN | version: u8 | id_len: u32 (LE) | id | blob`.
fn write_chained_payload(buf: &mut Vec<u8>, codec_id: &str, blob: &[u8]) {
buf.extend_from_slice(CHAINED_PAYLOAD_FAMILY);
buf.push(CHAIN_WIRE_VERSION_CURRENT);
buf.extend_from_slice(&(codec_id.len() as u32).to_le_bytes());
buf.extend_from_slice(codec_id.as_bytes());
buf.extend_from_slice(blob);
}
/// Inspect the chained-payload envelope on `buf`.
///
/// * `Ok(None)` — no envelope. The payload came from the terminal
/// codec, which writes unframed so a session with no extension
/// codecs installed produces bytes identical to a build without
/// codec chaining.
/// * `Ok(Some((codec_id, blob)))` — the owning codec's id and its
/// original bytes, byte-for-byte as it wrote them.
fn read_chained_payload(buf: &[u8]) -> Result<Option<(&str, &[u8])>> {
if !buf.starts_with(CHAINED_PAYLOAD_FAMILY) {
return Ok(None);
}
let mut idx = CHAINED_PAYLOAD_FAMILY.len();
let Some(&version) = buf.get(idx) else {
return Err(datafusion::error::DataFusionError::Execution(
"Truncated extension codec payload: missing envelope version byte".to_string(),
));
};
if !(CHAIN_WIRE_VERSION_MIN_SUPPORTED..=CHAIN_WIRE_VERSION_CURRENT).contains(&version) {
return Err(datafusion::error::DataFusionError::Execution(format!(
"Extension codec payload envelope version v{version}; this build supports \
v{CHAIN_WIRE_VERSION_MIN_SUPPORTED}..=v{CHAIN_WIRE_VERSION_CURRENT}. \
Align datafusion-python versions on sender and receiver."
)));
}
idx += 1;
let Some(len_bytes) = buf.get(idx..idx + 4) else {
return Err(datafusion::error::DataFusionError::Execution(
"Truncated extension codec payload: missing codec id length".to_string(),
));
};
let id_len = u32::from_le_bytes(len_bytes.try_into().expect("4 bytes")) as usize;
idx += 4;
let Some(id_bytes) = buf.get(idx..idx + id_len) else {
return Err(datafusion::error::DataFusionError::Execution(
"Truncated extension codec payload: codec id shorter than its declared length"
.to_string(),
));
};
let codec_id = std::str::from_utf8(id_bytes).map_err(|err| {
datafusion::error::DataFusionError::Execution(format!(
"Extension codec payload carries a non-UTF-8 codec id: {err}"
))
})?;
Ok(Some((codec_id, &buf[idx + id_len..])))
}
/// Decode `buf` with the single codec that encoded it.
///
/// Three cases:
///
/// * **Empty `buf`** — nothing was encoded, so there is no tag to
/// dispatch on and every codec is offered the empty buffer in install
/// order. See [`chain_resolve_by_name`] for why that is sound here and
/// why the case exists at all.
/// * **Framed `buf`** — the envelope names its author, so exactly one
/// codec is consulted and its error surfaces verbatim.
/// * **Unframed non-empty `buf`** — the terminal codec wrote it.
///
/// Outside the empty case nothing is ever offered to a codec that did
/// not write it, which is what stops a structurally similar prost
/// message from decoding in the wrong library.
///
/// Do not replace this with a walk that hands `buf` to each codec until
/// one returns `Ok`. That looks simpler and removes the envelope, and it
/// is unsound: protobuf carries no type identity, so a `prost` message
/// decodes cleanly from an unrelated message's bytes whenever their
/// leading field numbers and wire types line up, and an all-defaults
/// message encodes to zero bytes that decode as anything. Requiring
/// each codec to check a marker of its own does not fix it either: the
/// codecs come from libraries this crate does not control, the natural
/// implementation is `MyMessage::decode(buf)`, which has no marker to
/// check and cannot decline, and one library skipping the convention is
/// enough to lose someone else's payload. Upstream shipped that design
/// and reverted it after a Parquet payload decoded as CSV —
/// apache/datafusion#16980, fixed in #16986.
fn chain_decode<C: ?Sized, R>(
chain: &[ChainEntry<C>],
terminal: &Arc<C>,
buf: &[u8],
what: &str,
f: impl Fn(&C, &[u8]) -> Result<R>,
) -> Result<R> {
if buf.is_empty() {
return chain_resolve_by_name(chain, terminal, what, |codec| f(codec, buf));
}
let Some((codec_id, blob)) = read_chained_payload(buf)? else {
return f(terminal.as_ref(), buf);
};
let Some(entry) = chain.iter().find(|entry| &*entry.id == codec_id) else {
let installed = if chain.is_empty() {
"no extension codecs are installed on this session".to_string()
} else {
format!(
"installed: {}",
chain
.iter()
.map(|entry| entry.id.as_ref())
.collect::<Vec<_>>()
.join(", ")
)
};
let hint = if codec_id.starts_with(ANONYMOUS_CODEC_ID_PREFIX) {
". This payload was written by a codec installed from a bare PyCapsule, which \
carries no portable identity. Pass `codec_id=` when installing it if plans must \
cross sessions."
} else {
""
};
return Err(datafusion::error::DataFusionError::Execution(format!(
"{what} was encoded by extension codec '{codec_id}', which is not installed on \
this session ({installed}){hint}"
)));
};
f(entry.codec.as_ref(), blob)
}
/// Resolve an object carrying no payload, by consulting each codec.
///
/// Used only where DataFusion encodes by name: `try_encode_udf` and its
/// aggregate/window siblings return `Ok` writing nothing, and the
/// decoder then tries the `FunctionRegistry` first and the codec second
/// (`from_proto.rs`, the `None => ctx.udf(..).or_else(..)` arm). A codec
/// whose functions are reconstructible from the name alone is reached
/// through that arm and must still be offered the empty buffer.
///
/// This is the one place dispatch cannot be tagged — there are no bytes
/// to tag. It is not the hazard that tagging exists to remove: the
/// question asked here is "do you own the function named `x`", which is
/// name-scoped and answerable, not "do these bytes happen to parse as
/// your message type". Two codecs disagreeing requires them to claim
/// the same function name, which already collides in the registry.
fn chain_resolve_by_name<C: ?Sized, R>(
chain: &[ChainEntry<C>],
terminal: &Arc<C>,
what: &str,
f: impl Fn(&C) -> Result<R>,
) -> Result<R> {
let mut errors: Vec<datafusion::error::DataFusionError> = Vec::new();
for entry in chain {
match f(entry.codec.as_ref()) {
Ok(value) => return Ok(value),
Err(err) => errors.push(err),
}
}
match f(terminal.as_ref()) {
Ok(value) => Ok(value),
Err(err) => {
errors.push(err);
Err(aggregate_chain_errors(what, errors))
}
}
}
/// Collapse per-codec failures into one error. A single failure is
/// returned as-is so a session with no extension codecs behaves exactly
/// like a build without codec chaining.
fn aggregate_chain_errors(
what: &str,
mut errors: Vec<datafusion::error::DataFusionError>,
) -> datafusion::error::DataFusionError {
match errors.len() {
0 => datafusion::error::DataFusionError::Internal(format!(
"Empty extension codec chain while handling {what}"
)),
1 => errors.swap_remove(0),
_ => {
let joined = errors
.iter()
.map(|err| err.to_string())
.collect::<Vec<_>>()
.join("; ");
datafusion::error::DataFusionError::Execution(format!(
"No installed extension codec handled {what}: {joined}"
))
}
}
}
/// Encode through the chain, tagging the payload with its author.
///
/// Entries are consulted in install order and the first one to write
/// bytes wins, so installing a codec can only claim objects no
/// earlier codec claimed. Adding a library therefore never changes how
/// an already-installed library's objects encode.
///
/// Each codec encodes into a scratch buffer so a failed attempt cannot
/// leave partial bytes behind. `Ok` with an empty buffer is "no
/// opinion" rather than a claim, so the walk continues; if nothing
/// writes bytes the result is `Ok` with nothing written, which is
/// DataFusion's encode-by-name signal. Framing that empty result would
/// set `fun_definition` and permanently skip the registry lookup the
/// decoder does first.
///
/// The terminal codec writes unframed, so a session with no extension
/// codecs is byte-compatible with a build predating the chain.
fn chain_encode<C: ?Sized>(
chain: &[ChainEntry<C>],
terminal: &Arc<C>,
buf: &mut Vec<u8>,
what: &str,
f: impl Fn(&C, &mut Vec<u8>) -> Result<()>,
) -> Result<()> {
let mut saw_empty_ok = false;
let mut errors: Vec<datafusion::error::DataFusionError> = Vec::new();
for entry in chain {
let mut scratch = Vec::new();
match f(entry.codec.as_ref(), &mut scratch) {
Ok(()) if !scratch.is_empty() => {
write_chained_payload(buf, &entry.id, &scratch);
return Ok(());
}
Ok(()) => saw_empty_ok = true,
Err(err) => errors.push(err),
}
}
let mut scratch = Vec::new();
match f(terminal.as_ref(), &mut scratch) {
Ok(()) if !scratch.is_empty() => {
buf.extend_from_slice(&scratch);
return Ok(());
}
Ok(()) => saw_empty_ok = true,
Err(err) => errors.push(err),
}
if saw_empty_ok {
return Ok(());
}
Err(aggregate_chain_errors(what, errors))
}
/// `LogicalExtensionCodec` parked on every `SessionContext`. Holds
/// the Python-aware encoding hooks for logical-layer types
/// (`LogicalPlan`, `Expr`) and delegates everything it does not
/// handle to a chain of composable codecs. Each downstream FFI codec
/// installed via `SessionContext.with_logical_extension_codec(...)` is
/// appended to the chain, and `terminal` — normally
/// `DefaultLogicalExtensionCodec` — handles whatever no installed codec
/// claims.
///
/// Every payload an installed codec writes is wrapped in an envelope
/// naming that codec (see [`write_chained_payload`]), so decoding
/// consults exactly the codec that encoded it. Dispatch does not depend
/// on a codec recognizing and rejecting foreign payloads, which is not
/// something a codec can reliably do: a prost message decodes cleanly
/// from another message's bytes whenever their leading field numbers and
/// wire types line up.
///
/// Sitting at the top of the session's logical codec stack means
/// every serializer that reads `session.logical_codec()` automatically
/// picks up Python-aware encoding for free.
///
/// A codec deliberately does **not** retain the session it was built from.
/// Codecs are routinely handed to a provider that is then registered back into
/// that same session, so retaining here would close a cycle:
/// `SessionContext -> catalog -> FFI provider -> FFI codec -> here`. Keeping
/// the weak `FFI_TaskContextProvider` valid is instead a matter of never
/// replacing the session's `Arc<SessionContext>`; see
/// `PySessionContext::set_session_query_planner`.
#[derive(Debug, Clone)]
pub struct PythonLogicalCodec {
chain: Vec<ChainEntry<dyn LogicalExtensionCodec>>,
terminal: Arc<dyn LogicalExtensionCodec>,
python_udf_inlining: bool,
}
impl PythonLogicalCodec {
/// Build a codec with no installed extension codecs and `inner` as
/// the terminal fallback. `inner` is not part of the keyed chain and
/// its payloads are written unframed, so a context built this way
/// serializes byte-identically to one with no chaining at all.
pub fn new(inner: Arc<dyn LogicalExtensionCodec>) -> Self {
Self {
chain: Vec::new(),
terminal: inner,
python_udf_inlining: true,
}
}
/// Return a copy of this codec with `codec` appended to the chain
/// under `id`, preserving the Python-UDF-inlining setting.
///
/// Appending rather than prepending keeps the operation additive:
/// the new codec is consulted for encoding only after every codec
/// already installed, so it can claim objects nothing else claimed
/// but cannot take over an existing library's objects.
pub fn with_additional_codec(
&self,
id: impl Into<Arc<str>>,
codec: Arc<dyn LogicalExtensionCodec>,
) -> Self {
let mut chain = self.chain.clone();
chain.push(ChainEntry {
id: id.into(),
codec,
});
Self {
chain,
terminal: Arc::clone(&self.terminal),
python_udf_inlining: self.python_udf_inlining,
}
}
/// Ids of the installed extension codecs, in install order.
///
/// The terminal codec is not listed: it is not addressable by id
/// because its payloads are written unframed.
pub fn codec_ids(&self) -> Vec<&str> {
self.chain.iter().map(|entry| entry.id.as_ref()).collect()
}
/// Installed extension codecs paired with their ids, in install
/// order. Restores the inspection that the removed `inner()`
/// accessor provided, and exposes the id dispatch keys along with it.
pub fn codecs(&self) -> Vec<(&str, &Arc<dyn LogicalExtensionCodec>)> {
self.chain
.iter()
.map(|entry| (entry.id.as_ref(), &entry.codec))
.collect()
}
/// Terminal codec consulted when no installed codec claims an object.
pub fn terminal(&self) -> &Arc<dyn LogicalExtensionCodec> {
&self.terminal
}
/// Toggle inline encoding of Python UDFs. See
/// `SessionContext.with_python_udf_inlining` (Python) for full
/// behavior and use cases.
///
/// Security scope: strict mode (`false`) narrows only the codec
/// layer — it stops `Expr::from_bytes` from invoking
/// `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. 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
}
pub fn python_udf_inlining(&self) -> bool {
self.python_udf_inlining
}
}
impl Default for PythonLogicalCodec {
fn default() -> Self {
Self::new(Arc::new(DefaultLogicalExtensionCodec {}))
}
}
impl LogicalExtensionCodec for PythonLogicalCodec {
fn try_decode(
&self,
buf: &[u8],
inputs: &[LogicalPlan],
ctx: &TaskContext,
) -> Result<Extension> {
chain_decode(
&self.chain,
&self.terminal,
buf,
"an extension logical plan node",
|codec, buf| codec.try_decode(buf, inputs, ctx),
)
}
fn try_encode(&self, node: &Extension, buf: &mut Vec<u8>) -> Result<()> {
chain_encode(
&self.chain,
&self.terminal,
buf,
"an extension logical plan node",
|codec, buf| codec.try_encode(node, buf),
)
}
fn try_decode_table_provider(
&self,
buf: &[u8],
table_ref: &TableReference,
schema: SchemaRef,
ctx: &TaskContext,
) -> Result<Arc<dyn TableProvider>> {
chain_decode(
&self.chain,
&self.terminal,
buf,
"a table provider",
|codec, buf| codec.try_decode_table_provider(buf, table_ref, Arc::clone(&schema), ctx),
)
}
fn try_encode_table_provider(
&self,
table_ref: &TableReference,
node: Arc<dyn TableProvider>,
buf: &mut Vec<u8>,
) -> Result<()> {
chain_encode(
&self.chain,
&self.terminal,
buf,
"a table provider",
|codec, buf| codec.try_encode_table_provider(table_ref, Arc::clone(&node), buf),
)
}
fn try_decode_file_format(
&self,
buf: &[u8],
ctx: &TaskContext,
) -> Result<Arc<dyn FileFormatFactory>> {
chain_decode(
&self.chain,
&self.terminal,
buf,
"a file format",
|codec, buf| codec.try_decode_file_format(buf, ctx),
)
}
fn try_encode_file_format(
&self,
buf: &mut Vec<u8>,
node: Arc<dyn FileFormatFactory>,
) -> Result<()> {
chain_encode(
&self.chain,
&self.terminal,
buf,
"a file format",
|codec, buf| codec.try_encode_file_format(buf, Arc::clone(&node)),
)
}
fn try_encode_udf(&self, node: &ScalarUDF, buf: &mut Vec<u8>) -> Result<()> {
if self.python_udf_inlining && try_encode_python_scalar_udf(node, buf)? {
return Ok(());
}
chain_encode(
&self.chain,
&self.terminal,
buf,
"a scalar UDF",
|codec, buf| codec.try_encode_udf(node, buf),
)
}
fn try_decode_udf(&self, name: &str, buf: &[u8]) -> Result<Arc<ScalarUDF>> {
if self.python_udf_inlining {
if let Some(udf) = try_decode_python_scalar_udf(buf)? {
return Ok(udf);
}
} else {
refuse_if_inline(buf, PY_SCALAR_UDF_FAMILY, "scalar UDF", name)?;
}
chain_decode(
&self.chain,
&self.terminal,
buf,
"a scalar UDF",
|codec, buf| codec.try_decode_udf(name, buf),
)
}
fn try_encode_udaf(&self, node: &AggregateUDF, buf: &mut Vec<u8>) -> Result<()> {
if self.python_udf_inlining && try_encode_python_udaf(node, buf)? {
return Ok(());
}
chain_encode(
&self.chain,
&self.terminal,
buf,
"an aggregate UDF",
|codec, buf| codec.try_encode_udaf(node, buf),
)
}
fn try_decode_udaf(&self, name: &str, buf: &[u8]) -> Result<Arc<AggregateUDF>> {
if self.python_udf_inlining {
if let Some(udaf) = try_decode_python_udaf(buf)? {
return Ok(udaf);
}
} else {
refuse_if_inline(buf, PY_AGG_UDF_FAMILY, "aggregate UDF", name)?;
}
chain_decode(
&self.chain,
&self.terminal,
buf,
"an aggregate UDF",
|codec, buf| codec.try_decode_udaf(name, buf),
)
}
fn try_encode_udwf(&self, node: &WindowUDF, buf: &mut Vec<u8>) -> Result<()> {
if self.python_udf_inlining && try_encode_python_udwf(node, buf)? {
return Ok(());
}
chain_encode(
&self.chain,
&self.terminal,
buf,
"a window UDF",
|codec, buf| codec.try_encode_udwf(node, buf),
)
}
fn try_decode_udwf(&self, name: &str, buf: &[u8]) -> Result<Arc<WindowUDF>> {
if self.python_udf_inlining {
if let Some(udwf) = try_decode_python_udwf(buf)? {
return Ok(udwf);
}
} else {
refuse_if_inline(buf, PY_WINDOW_UDF_FAMILY, "window UDF", name)?;
}
chain_decode(
&self.chain,
&self.terminal,
buf,
"a window UDF",
|codec, buf| codec.try_decode_udwf(name, buf),
)
}
}
/// Strict-mode gate: if `buf` is a well-framed inline payload for
/// `family`, return the strict-refusal error; otherwise return
/// `Ok(())` so the caller can delegate to its codec chain.
///
/// Routing through [`read_framed_payload`] (rather than a bare
/// `starts_with` probe) means malformed inline bytes — wrong
/// wire-format version, mismatched Python version, truncated header —
/// surface *their* diagnostic instead of the strict-mode message.
/// The strict message implies sender intent ("inlining is disabled"),
/// so it should fire only when the bytes really would have decoded.
///
/// Fast path: short-circuit on the family-magic prefix before
/// acquiring the GIL. Plans with many non-Python UDFs would otherwise
/// pay a GIL acquisition per decode call just to confirm "not a
/// Python UDF". `read_framed_payload` itself rejects buffers that
/// don't start with `family`, so this is purely an optimization.
fn refuse_if_inline(buf: &[u8], family: &[u8], kind: &str, name: &str) -> Result<()> {
if !buf.starts_with(family) {
return Ok(());
}
Python::attach(|py| match read_framed_payload(py, buf, family, kind)? {
Some(_) => Err(refuse_inline_payload(kind, name)),
None => Ok(()),
})
}
/// Build the error returned by a strict codec when it receives an
/// inline Python-UDF payload it has been told not to deserialize.
fn refuse_inline_payload(kind: &str, name: &str) -> datafusion::error::DataFusionError {
// `Execution`, not `Plan`: this is a wire-format decode refusal at
// codec time, not a planner-stage failure. Downstream error
// classification keys off the variant — surfacing this as a planner
// error would mis-route it into "fix your SQL" buckets.
datafusion::error::DataFusionError::Execution(format!(
"Refusing to deserialize inline Python {kind} '{name}': Python UDF \
inlining is disabled on this session. Two remediations: \
(1) ask the sender to re-encode with inlining disabled so '{name}' \
travels by name, and register '{name}' on this receiver; or \
(2) enable inlining on this receiver (accepts the cloudpickle \
execution risk on inbound payloads). Receivers cannot re-encode \
bytes they did not produce."
))
}
/// `PhysicalExtensionCodec` mirror of [`PythonLogicalCodec`] parked
/// on the same `SessionContext`. Carries the Python-aware encoding
/// hooks for physical-layer types (`ExecutionPlan`, `PhysicalExpr`)
/// and delegates the rest to the composable codec chain (see
/// [`PythonLogicalCodec`] for chain ordering and dispatch rules).
///
/// The `PhysicalExtensionCodec` trait has its own `try_encode_udf`
/// / `try_decode_udf` pair distinct from the logical one, so a
/// `ScalarUDF` referenced inside a physical plan needs Python-aware
/// 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`] et al.) so the wire format is identical.
///
/// Like [`PythonLogicalCodec`], this does not retain the session it was built
/// from; see that type for why.
#[derive(Debug, Clone)]
pub struct PythonPhysicalCodec {
chain: Vec<ChainEntry<dyn PhysicalExtensionCodec>>,
terminal: Arc<dyn PhysicalExtensionCodec>,
python_udf_inlining: bool,
}
impl PythonPhysicalCodec {
/// See [`PythonLogicalCodec::new`]; `inner` is the terminal codec
/// rather than a chain entry.
pub fn new(inner: Arc<dyn PhysicalExtensionCodec>) -> Self {
Self {
chain: Vec::new(),
terminal: inner,
python_udf_inlining: true,
}
}
/// Return a copy of this codec with `codec` appended to the chain
/// under `id`. See [`PythonLogicalCodec::with_additional_codec`].
pub fn with_additional_codec(
&self,
id: impl Into<Arc<str>>,
codec: Arc<dyn PhysicalExtensionCodec>,
) -> Self {
let mut chain = self.chain.clone();
chain.push(ChainEntry {
id: id.into(),
codec,
});
Self {
chain,
terminal: Arc::clone(&self.terminal),
python_udf_inlining: self.python_udf_inlining,
}
}
/// Ids of the installed extension codecs, in install order.
pub fn codec_ids(&self) -> Vec<&str> {
self.chain.iter().map(|entry| entry.id.as_ref()).collect()
}
/// Installed extension codecs paired with their ids, in install order.
pub fn codecs(&self) -> Vec<(&str, &Arc<dyn PhysicalExtensionCodec>)> {
self.chain
.iter()
.map(|entry| (entry.id.as_ref(), &entry.codec))
.collect()
}
/// Terminal codec consulted when no installed codec claims an object.
pub fn terminal(&self) -> &Arc<dyn PhysicalExtensionCodec> {
&self.terminal
}
/// Toggle inline encoding of Python UDFs on this physical codec.
///
/// Mirrors [`PythonLogicalCodec::with_python_udf_inlining`]; see
/// that method for the full security and portability discussion.
pub fn with_python_udf_inlining(mut self, enabled: bool) -> Self {
self.python_udf_inlining = enabled;
self
}
pub fn python_udf_inlining(&self) -> bool {
self.python_udf_inlining
}
}
impl Default for PythonPhysicalCodec {
fn default() -> Self {
Self::new(Arc::new(DefaultPhysicalExtensionCodec {}))
}
}
impl PhysicalExtensionCodec for PythonPhysicalCodec {
fn try_decode(
&self,
buf: &[u8],
inputs: &[Arc<dyn ExecutionPlan>],
ctx: &TaskContext,
proto_converter: &dyn PhysicalProtoConverterExtension,
) -> Result<Arc<dyn ExecutionPlan>> {
chain_decode(
&self.chain,
&self.terminal,
buf,
"an execution plan",
|codec, buf| codec.try_decode(buf, inputs, ctx, proto_converter),
)
}
fn try_encode(
&self,
node: Arc<dyn ExecutionPlan>,
buf: &mut Vec<u8>,
proto_converter: &dyn PhysicalProtoConverterExtension,
) -> Result<()> {
chain_encode(
&self.chain,
&self.terminal,
buf,
"an execution plan",
|codec, buf| codec.try_encode(Arc::clone(&node), buf, proto_converter),
)
}
fn try_encode_udf(&self, node: &ScalarUDF, buf: &mut Vec<u8>) -> Result<()> {
if self.python_udf_inlining && try_encode_python_scalar_udf(node, buf)? {
return Ok(());
}
chain_encode(
&self.chain,
&self.terminal,
buf,
"a scalar UDF",
|codec, buf| codec.try_encode_udf(node, buf),
)
}
fn try_decode_udf(&self, name: &str, buf: &[u8]) -> Result<Arc<ScalarUDF>> {
if self.python_udf_inlining {
if let Some(udf) = try_decode_python_scalar_udf(buf)? {
return Ok(udf);
}
} else {
refuse_if_inline(buf, PY_SCALAR_UDF_FAMILY, "scalar UDF", name)?;
}
chain_decode(
&self.chain,
&self.terminal,
buf,
"a scalar UDF",
|codec, buf| codec.try_decode_udf(name, buf),
)
}
fn try_encode_expr(
&self,
node: &Arc<dyn PhysicalExpr>,
buf: &mut Vec<u8>,
ctx: &PhysicalExprEncodeCtx<'_>,
) -> Result<()> {
chain_encode(
&self.chain,
&self.terminal,
buf,
"a physical expression",
|codec, buf| codec.try_encode_expr(node, buf, ctx),
)
}
fn try_decode_expr(
&self,
buf: &[u8],
inputs: &[Arc<dyn PhysicalExpr>],
ctx: &PhysicalExprDecodeCtx<'_>,
) -> Result<Arc<dyn PhysicalExpr>> {
chain_decode(
&self.chain,
&self.terminal,
buf,
"a physical expression",
|codec, buf| codec.try_decode_expr(buf, inputs, ctx),
)
}
fn try_encode_udaf(&self, node: &AggregateUDF, buf: &mut Vec<u8>) -> Result<()> {
if self.python_udf_inlining && try_encode_python_udaf(node, buf)? {
return Ok(());
}
chain_encode(
&self.chain,
&self.terminal,
buf,
"an aggregate UDF",
|codec, buf| codec.try_encode_udaf(node, buf),
)
}
fn try_decode_udaf(&self, name: &str, buf: &[u8]) -> Result<Arc<AggregateUDF>> {
if self.python_udf_inlining {
if let Some(udaf) = try_decode_python_udaf(buf)? {
return Ok(udaf);
}
} else {
refuse_if_inline(buf, PY_AGG_UDF_FAMILY, "aggregate UDF", name)?;
}
chain_decode(
&self.chain,
&self.terminal,
buf,
"an aggregate UDF",
|codec, buf| codec.try_decode_udaf(name, buf),
)
}
fn try_encode_udwf(&self, node: &WindowUDF, buf: &mut Vec<u8>) -> Result<()> {
if self.python_udf_inlining && try_encode_python_udwf(node, buf)? {
return Ok(());
}
chain_encode(
&self.chain,
&self.terminal,
buf,
"a window UDF",
|codec, buf| codec.try_encode_udwf(node, buf),
)
}
fn try_decode_udwf(&self, name: &str, buf: &[u8]) -> Result<Arc<WindowUDF>> {
if self.python_udf_inlining {
if let Some(udwf) = try_decode_python_udwf(buf)? {
return Ok(udwf);
}
} else {
refuse_if_inline(buf, PY_WINDOW_UDF_FAMILY, "window UDF", name)?;
}
chain_decode(
&self.chain,
&self.terminal,
buf,
"a window UDF",
|codec, buf| codec.try_decode_udwf(name, buf),
)
}
}
// =============================================================================
// Shared Python scalar UDF encode / decode helpers
//
// Both `PythonLogicalCodec` and `PythonPhysicalCodec` consult these on
// every `try_encode_udf` / `try_decode_udf` call. Same wire format on
// both layers — a Python `ScalarUDF` referenced inside a `LogicalPlan`
// or an `ExecutionPlan` round-trips identically.
// =============================================================================
/// Encode a Python scalar UDF inline if `node` is one. Returns
/// `Ok(true)` when the payload (`DFPYUDF` family prefix, version byte,
/// cloudpickled tuple) was written and the caller should skip its
/// inner codec. Returns `Ok(false)` for any non-Python UDF, signalling
/// the caller to delegate to its codec chain.
pub(crate) fn try_encode_python_scalar_udf(node: &ScalarUDF, buf: &mut Vec<u8>) -> Result<bool> {
let Some(py_udf) = node.inner().downcast_ref::<PythonFunctionScalarUDF>() else {
return Ok(false);
};
Python::attach(|py| -> Result<bool> {
let bytes = encode_python_scalar_udf(py, py_udf).map_err(to_datafusion_err)?;
append_framed_payload(py, buf, PY_SCALAR_UDF_FAMILY, &bytes)?;
Ok(true)
})
}
/// Decode an inline Python scalar UDF payload. Returns `Ok(None)`
/// when `buf` does not carry the `DFPYUDF` family prefix, signalling
/// the caller to delegate to its codec chain (and eventually the
/// `FunctionRegistry`).
pub(crate) fn try_decode_python_scalar_udf(buf: &[u8]) -> Result<Option<Arc<ScalarUDF>>> {
if !buf.starts_with(PY_SCALAR_UDF_FAMILY) {
return Ok(None);
}
Python::attach(|py| -> Result<Option<Arc<ScalarUDF>>> {
let Some(payload) = read_framed_payload(py, buf, PY_SCALAR_UDF_FAMILY, "scalar UDF")?
else {
return Ok(None);
};
let udf = decode_python_scalar_udf(py, payload).map_err(to_datafusion_err)?;
Ok(Some(Arc::new(ScalarUDF::new_from_impl(udf))))
})
}
/// Build the cloudpickle payload for a `PythonFunctionScalarUDF`.
///
/// Layout: `cloudpickle.dumps((name, func, input_schema_bytes,
/// return_schema_bytes, volatility_str))`. Schema blobs are produced
/// by arrow-rs's native IPC stream writer (no pyarrow round-trip) and
/// decoded with the matching stream reader on the receiver. See
/// [`build_input_schema_bytes`] for what the input blob carries.
fn encode_python_scalar_udf(py: Python<'_>, udf: &PythonFunctionScalarUDF) -> PyResult<Vec<u8>> {
let signature = udf.signature();
let input_dtypes = signature_input_dtypes(signature, "PythonFunctionScalarUDF")?;
let input_schema_bytes = build_input_schema_bytes(&input_dtypes)?;
let return_schema_bytes = build_single_field_schema_bytes(udf.return_field().as_ref())?;
let volatility = volatility_wire_str(signature.volatility);
let payload = PyTuple::new(
py,
[
udf.name().into_pyobject(py)?.into_any(),
udf.func().bind(py).clone().into_any(),
PyBytes::new(py, &input_schema_bytes).into_any(),
PyBytes::new(py, &return_schema_bytes).into_any(),
volatility.into_pyobject(py)?.into_any(),
],
)?;
cloudpickle(py)?
.call_method1("dumps", (payload,))?
.extract::<Vec<u8>>()
}
/// Inverse of [`encode_python_scalar_udf`].
fn decode_python_scalar_udf(py: Python<'_>, payload: &[u8]) -> PyResult<PythonFunctionScalarUDF> {
let tuple = cloudpickle(py)?
.call_method1("loads", (PyBytes::new(py, payload),))?
.cast_into::<PyTuple>()?;
let name: String = tuple.get_item(0)?.extract()?;
let func: Py<PyAny> = tuple.get_item(1)?.unbind();
let input_schema_bytes: Vec<u8> = tuple.get_item(2)?.extract()?;
let return_schema_bytes: Vec<u8> = tuple.get_item(3)?.extract()?;
let volatility_str: String = tuple.get_item(4)?.extract()?;
let input_types = read_input_dtypes(&input_schema_bytes)?;
let return_field = read_single_return_field(&return_schema_bytes, "PythonFunctionScalarUDF")?;
let volatility = parse_volatility_str(&volatility_str)?;
Ok(PythonFunctionScalarUDF::from_parts(
name,
func,
input_types,
return_field,
volatility,
))
}
/// Serialize a `Schema` to a self-contained IPC stream containing
/// only the schema message (no record batches). Inverse:
/// [`schema_from_ipc_bytes`].
fn schema_to_ipc_bytes(schema: &Schema) -> arrow::error::Result<Vec<u8>> {
let mut buf: Vec<u8> = Vec::new();
{
let mut writer = StreamWriter::try_new(&mut buf, schema)?;
writer.finish()?;
}
Ok(buf)
}
/// Decode an IPC stream containing only a schema message back into a
/// `Schema`. Inverse: [`schema_to_ipc_bytes`].
fn schema_from_ipc_bytes(bytes: &[u8]) -> arrow::error::Result<Schema> {
let reader = StreamReader::try_new(std::io::Cursor::new(bytes), None)?;
Ok(reader.schema().as_ref().clone())
}
/// Extract the per-arg `DataType`s from a `Signature` known to be
/// `TypeSignature::Exact` (all Python-defined UDFs are constructed
/// with `Signature::exact`). Any other variant indicates the impl was
/// not built by this crate's UDF/UDAF/UDWF constructors.
fn signature_input_dtypes(signature: &Signature, kind: &str) -> PyResult<Vec<DataType>> {
match &signature.type_signature {
TypeSignature::Exact(types) => Ok(types.clone()),
other => Err(pyo3::exceptions::PyValueError::new_err(format!(
"{kind} expected Signature::Exact, got {other:?}"
))),
}
}
/// Wrap per-arg `DataType`s in synthetic `arg_{i}` fields and emit
/// the IPC schema blob the encoder writes into the cloudpickle tuple.
///
/// The names and `nullable: true` are arbitrary: the underlying
/// `TypeSignature::Exact` carries no per-input nullability or
/// metadata, and the receiver collapses these fields back to
/// `Vec<DataType>` via [`read_input_dtypes`], so anything set here
/// beyond the data type is discarded on decode.
fn build_input_schema_bytes(dtypes: &[DataType]) -> PyResult<Vec<u8>> {
let fields: Vec<Field> = dtypes
.iter()
.enumerate()
.map(|(i, dt)| Field::new(format!("arg_{i}"), dt.clone(), true))
.collect();
schema_to_ipc_bytes(&Schema::new(fields)).map_err(arrow_to_py_err)
}
/// Emit a single-field IPC schema blob. Used for return-type and
/// state-field payloads where the receiver needs to recover field
/// metadata (names, nullability, key/value attributes) verbatim.
fn build_single_field_schema_bytes(field: &Field) -> PyResult<Vec<u8>> {
schema_to_ipc_bytes(&Schema::new(vec![field.clone()])).map_err(arrow_to_py_err)
}
/// Emit a multi-field IPC schema blob.
fn build_schema_bytes(fields: Vec<Field>) -> PyResult<Vec<u8>> {
schema_to_ipc_bytes(&Schema::new(fields)).map_err(arrow_to_py_err)
}
/// Decode the per-arg `DataType`s the encoder wrote via
/// [`build_input_schema_bytes`].
fn read_input_dtypes(bytes: &[u8]) -> PyResult<Vec<DataType>> {
let schema = schema_from_ipc_bytes(bytes).map_err(arrow_to_py_err)?;
Ok(schema
.fields()
.iter()
.map(|f| f.data_type().clone())
.collect())
}
/// Decode a single-field IPC schema blob and return that field by
/// value. `kind` names the UDF flavor in the error message produced
/// when the blob is empty (should be unreachable for sender-side
/// payloads built via [`build_single_field_schema_bytes`]).
fn read_single_return_field(bytes: &[u8], kind: &str) -> PyResult<Field> {
let schema = schema_from_ipc_bytes(bytes).map_err(arrow_to_py_err)?;
let field = schema.fields().first().ok_or_else(|| {
pyo3::exceptions::PyValueError::new_err(format!(
"{kind} return schema must contain exactly one field"
))
})?;
Ok(field.as_ref().clone())
}
fn arrow_to_py_err(e: arrow::error::ArrowError) -> PyErr {
pyo3::exceptions::PyValueError::new_err(format!("{e}"))
}
fn parse_volatility_str(s: &str) -> PyResult<Volatility> {
datafusion_python_util::parse_volatility(s)
.map_err(|e| pyo3::exceptions::PyValueError::new_err(format!("{e}")))
}
/// Stable wire-format string for a `Volatility`. Pinned to the three
/// tokens [`datafusion_python_util::parse_volatility`] accepts, so an
/// upstream change to `Volatility`'s `Debug` repr cannot silently
/// produce bytes the decoder rejects.
fn volatility_wire_str(v: Volatility) -> &'static str {
match v {
Volatility::Immutable => "immutable",
Volatility::Stable => "stable",
Volatility::Volatile => "volatile",
}
}
/// Read the interpreter's `sys.version_info` as `(major, minor)`.
///
/// Used by encoder/decoder to stamp and verify the Python version a
/// cloudpickle payload was produced on. cloudpickle is not portable
/// across Python minor versions; the wire header carries these bytes
/// so a mismatch surfaces an actionable error instead of an opaque
/// `marshal` failure at `cloudpickle.loads` time.
fn current_python_version(py: Python<'_>) -> PyResult<(u8, u8)> {
let version_info = py.import("sys")?.getattr("version_info")?;
let major: u8 = version_info.getattr("major")?.extract()?;
let minor: u8 = version_info.getattr("minor")?.extract()?;
Ok((major, minor))
}
/// Stamp `buf` with the framing header for `family` plus the current
/// Python `(major, minor)`, then append `payload`. Bundles the
/// `current_python_version` lookup with the header write so each
/// encoder call site stays one line.
fn append_framed_payload(
py: Python<'_>,
buf: &mut Vec<u8>,
family: &[u8],
payload: &[u8],
) -> Result<()> {
let py_version = current_python_version(py).map_err(to_datafusion_err)?;
write_wire_header(buf, family, py_version);
buf.extend_from_slice(payload);
Ok(())
}
/// Inspect `buf`'s framing against `family` + the current Python
/// `(major, minor)`. Returns `Ok(None)` when `buf` does not carry
/// `family` (caller should delegate); `Ok(Some(payload))` when the
/// framing matches; `Err(_)` for a recognised family at the wrong
/// wire-format or Python version (see [`strip_wire_header`]).
fn read_framed_payload<'a>(
py: Python<'_>,
buf: &'a [u8],
family: &[u8],
kind: &str,
) -> Result<Option<&'a [u8]>> {
let py_version = current_python_version(py).map_err(to_datafusion_err)?;
strip_wire_header(buf, family, kind, py_version)
}
/// Cached handle to the `cloudpickle` module.
///
/// The encode/decode helpers above would otherwise re-resolve the
/// module on every call. `py.import` is backed by `sys.modules` and
/// therefore cheap, but each call still walks a dict and re-binds the
/// result; a plan with many Python UDFs pays that cost per UDF.
///
/// `PyOnceLock` scopes the cached `Py<PyAny>` to the current
/// interpreter, so the slot drops cleanly on interpreter teardown
/// (relevant under CPython subinterpreters, PEP 684) instead of
/// resurrecting a `Py` rooted in a dead interpreter on the next call.
fn cloudpickle<'py>(py: Python<'py>) -> PyResult<Bound<'py, PyAny>> {
static CLOUDPICKLE: PyOnceLock<Py<PyAny>> = PyOnceLock::new();
CLOUDPICKLE
.get_or_try_init(py, || Ok(py.import("cloudpickle")?.unbind().into_any()))
.map(|cached| cached.bind(py).clone())
}
// =============================================================================
// Shared Python window UDF encode / decode helpers
//
// Cloudpickle tuple shape: `(name, evaluator_factory, input_schema_bytes,
// return_schema_bytes, volatility_str)`. The evaluator factory is the
// Python callable that produces a new evaluator instance per partition.
// =============================================================================
pub(crate) fn try_encode_python_udwf(node: &WindowUDF, buf: &mut Vec<u8>) -> Result<bool> {
let Some(py_udf) = node.inner().downcast_ref::<PythonFunctionWindowUDF>() else {
return Ok(false);
};
Python::attach(|py| -> Result<bool> {
let bytes = encode_python_udwf(py, py_udf).map_err(to_datafusion_err)?;
append_framed_payload(py, buf, PY_WINDOW_UDF_FAMILY, &bytes)?;
Ok(true)
})
}
pub(crate) fn try_decode_python_udwf(buf: &[u8]) -> Result<Option<Arc<WindowUDF>>> {
if !buf.starts_with(PY_WINDOW_UDF_FAMILY) {
return Ok(None);
}
Python::attach(|py| -> Result<Option<Arc<WindowUDF>>> {
let Some(payload) = read_framed_payload(py, buf, PY_WINDOW_UDF_FAMILY, "window UDF")?
else {
return Ok(None);
};
let udf = decode_python_udwf(py, payload).map_err(to_datafusion_err)?;
Ok(Some(Arc::new(WindowUDF::new_from_impl(udf))))
})
}
fn encode_python_udwf(py: Python<'_>, udf: &PythonFunctionWindowUDF) -> PyResult<Vec<u8>> {
let signature = WindowUDFImpl::signature(udf);
let input_dtypes = signature_input_dtypes(signature, "PythonFunctionWindowUDF")?;
let input_schema_bytes = build_input_schema_bytes(&input_dtypes)?;
let return_field = Field::new("result", udf.return_type().clone(), true);
let return_schema_bytes = build_single_field_schema_bytes(&return_field)?;
let volatility = volatility_wire_str(signature.volatility);
let payload = PyTuple::new(
py,
[
WindowUDFImpl::name(udf).into_pyobject(py)?.into_any(),
udf.evaluator().bind(py).clone().into_any(),
PyBytes::new(py, &input_schema_bytes).into_any(),
PyBytes::new(py, &return_schema_bytes).into_any(),
volatility.into_pyobject(py)?.into_any(),
],
)?;
cloudpickle(py)?
.call_method1("dumps", (payload,))?
.extract::<Vec<u8>>()
}
fn decode_python_udwf(py: Python<'_>, payload: &[u8]) -> PyResult<PythonFunctionWindowUDF> {
let tuple = cloudpickle(py)?
.call_method1("loads", (PyBytes::new(py, payload),))?
.cast_into::<PyTuple>()?;
let name: String = tuple.get_item(0)?.extract()?;
let evaluator: Py<PyAny> = tuple.get_item(1)?.unbind();
let input_schema_bytes: Vec<u8> = tuple.get_item(2)?.extract()?;
let return_schema_bytes: Vec<u8> = tuple.get_item(3)?.extract()?;
let volatility_str: String = tuple.get_item(4)?.extract()?;
let input_types = read_input_dtypes(&input_schema_bytes)?;
let return_type = read_single_return_field(&return_schema_bytes, "PythonFunctionWindowUDF")?
.data_type()
.clone();
let volatility = parse_volatility_str(&volatility_str)?;
Ok(PythonFunctionWindowUDF::new(
name,
evaluator,
input_types,
return_type,
volatility,
))
}
// =============================================================================
// Shared Python aggregate UDF encode / decode helpers
//
// Cloudpickle tuple shape: `(name, accumulator_factory, input_schema_bytes,
// return_schema_bytes, state_schema_bytes, volatility_str)`. The accumulator
// factory is the Python callable that produces a new accumulator instance
// per partition.
// =============================================================================
pub(crate) fn try_encode_python_udaf(node: &AggregateUDF, buf: &mut Vec<u8>) -> Result<bool> {
let Some(py_udf) = node.inner().downcast_ref::<PythonFunctionAggregateUDF>() else {
return Ok(false);
};
Python::attach(|py| -> Result<bool> {
let bytes = encode_python_udaf(py, py_udf).map_err(to_datafusion_err)?;
append_framed_payload(py, buf, PY_AGG_UDF_FAMILY, &bytes)?;
Ok(true)
})
}
pub(crate) fn try_decode_python_udaf(buf: &[u8]) -> Result<Option<Arc<AggregateUDF>>> {
if !buf.starts_with(PY_AGG_UDF_FAMILY) {
return Ok(None);
}
Python::attach(|py| -> Result<Option<Arc<AggregateUDF>>> {
let Some(payload) = read_framed_payload(py, buf, PY_AGG_UDF_FAMILY, "aggregate UDF")?
else {
return Ok(None);
};
let udf = decode_python_udaf(py, payload).map_err(to_datafusion_err)?;
Ok(Some(Arc::new(AggregateUDF::new_from_impl(udf))))
})
}
fn encode_python_udaf(py: Python<'_>, udf: &PythonFunctionAggregateUDF) -> PyResult<Vec<u8>> {
let signature = AggregateUDFImpl::signature(udf);
let input_dtypes = signature_input_dtypes(signature, "PythonFunctionAggregateUDF")?;
let input_schema_bytes = build_input_schema_bytes(&input_dtypes)?;
let return_field = Field::new("result", udf.return_type().clone(), true);
let return_schema_bytes = build_single_field_schema_bytes(&return_field)?;
let state_fields: Vec<Field> = udf
.state_fields_ref()
.iter()
.map(|f| f.as_ref().clone())
.collect();
let state_schema_bytes = build_schema_bytes(state_fields)?;
let volatility = volatility_wire_str(signature.volatility);
let payload = PyTuple::new(
py,
[
AggregateUDFImpl::name(udf).into_pyobject(py)?.into_any(),
udf.accumulator().bind(py).clone().into_any(),
PyBytes::new(py, &input_schema_bytes).into_any(),
PyBytes::new(py, &return_schema_bytes).into_any(),
PyBytes::new(py, &state_schema_bytes).into_any(),
volatility.into_pyobject(py)?.into_any(),
],
)?;
cloudpickle(py)?
.call_method1("dumps", (payload,))?
.extract::<Vec<u8>>()
}
fn decode_python_udaf(py: Python<'_>, payload: &[u8]) -> PyResult<PythonFunctionAggregateUDF> {
let tuple = cloudpickle(py)?
.call_method1("loads", (PyBytes::new(py, payload),))?
.cast_into::<PyTuple>()?;
let name: String = tuple.get_item(0)?.extract()?;
let accumulator: Py<PyAny> = tuple.get_item(1)?.unbind();
let input_schema_bytes: Vec<u8> = tuple.get_item(2)?.extract()?;
let return_schema_bytes: Vec<u8> = tuple.get_item(3)?.extract()?;
let state_schema_bytes: Vec<u8> = tuple.get_item(4)?.extract()?;
let volatility_str: String = tuple.get_item(5)?.extract()?;
let input_types = read_input_dtypes(&input_schema_bytes)?;
let return_type = read_single_return_field(&return_schema_bytes, "PythonFunctionAggregateUDF")?
.data_type()
.clone();
// Preserve the encoded state field metadata (names, nullability,
// arbitrary key/value attributes) so the post-decode UDF reports
// the same state schema as the sender's instance — important for
// accumulators whose `StateFieldsArgs` consumers key off names or
// nullability rather than positional `DataType`.
let state_schema = schema_from_ipc_bytes(&state_schema_bytes).map_err(arrow_to_py_err)?;
let state_fields: Vec<arrow::datatypes::FieldRef> =
state_schema.fields().iter().cloned().collect();
let volatility = parse_volatility_str(&volatility_str)?;
Ok(PythonFunctionAggregateUDF::from_parts(
name,
accumulator,
input_types,
return_type,
state_fields,
volatility,
))
}