Skip to content

Commit d3ce2b2

Browse files
timsaucerclaude
andcommitted
fix: keep the UDF inlining setting when installing a codec
`with_logical_extension_codec` and `with_physical_extension_codec` built the replacement wrapper with `Python{Logical,Physical}Codec::new`, whose constructor defaults `python_udf_inlining` to true. Installing a codec on a context that had opted out therefore turned inlining back on without saying so: ctx = SessionContext().with_python_udf_inlining(enabled=False) ctx = ctx.with_logical_extension_codec(codec) # inlining silently back on That matters beyond a stale flag. Inlining is what embeds a cloudpickled callable in the wire format, and it is opt-out precisely because that is not portable across interpreters and not something every deployment wants to ship. A codec install is not a request to change it. Carry the receiver's setting across instead. Both new tests fail on the prior build with `DFPYUDF` reappearing in the blob, and the paired `..._preserves_inlining_when_enabled` case pins the default-on direction so the fix cannot degenerate into hard-coding it off. The physical case is covered in `test_plans.py` rather than alongside the logical one: `Expr.to_bytes` only routes through the logical codec, so an assertion there would pass with the physical bug still present. It takes an `ExecutionPlan` to observe. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 06c9b9b commit d3ce2b2

3 files changed

Lines changed: 95 additions & 2 deletions

File tree

crates/core/src/context.rs

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1445,9 +1445,16 @@ impl PySessionContext {
14451445
) -> PyDataFusionResult<Self> {
14461446
let inner_ffi = ffi_logical_codec_from_pycapsule(codec, Some(slf.as_any()))?;
14471447
let inner: Arc<dyn LogicalExtensionCodec> = (&inner_ffi).into();
1448-
let logical_codec = Arc::new(PythonLogicalCodec::new(inner));
14491448

14501449
let this = slf.borrow();
1450+
// Carry the receiver's inlining setting over. `PythonLogicalCodec::new`
1451+
// defaults it to on, so building the replacement without this would
1452+
// silently re-enable inline Python UDF encoding on a context that had
1453+
// opted out with `with_python_udf_inlining(enabled=False)`.
1454+
let logical_codec = Arc::new(
1455+
PythonLogicalCodec::new(inner)
1456+
.with_python_udf_inlining(this.logical_codec.python_udf_inlining()),
1457+
);
14511458
let derived = Self {
14521459
ctx: Arc::clone(&this.ctx),
14531460
logical_codec,
@@ -1476,9 +1483,13 @@ impl PySessionContext {
14761483
) -> PyDataFusionResult<Self> {
14771484
let inner_ffi = ffi_physical_codec_from_pycapsule(codec, Some(slf.as_any()))?;
14781485
let inner: Arc<dyn PhysicalExtensionCodec> = (&inner_ffi).into();
1479-
let physical_codec = Arc::new(PythonPhysicalCodec::new(inner));
14801486

14811487
let this = slf.borrow();
1488+
// See `with_logical_extension_codec` for why the flag is carried over.
1489+
let physical_codec = Arc::new(
1490+
PythonPhysicalCodec::new(inner)
1491+
.with_python_udf_inlining(this.physical_codec.python_udf_inlining()),
1492+
);
14821493
let derived = Self {
14831494
ctx: Arc::clone(&this.ctx),
14841495
logical_codec: Arc::clone(&this.logical_codec),

python/tests/test_pickle_expr.py

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -387,6 +387,45 @@ def test_toggle_off_then_on_restores_inline_encoding(self):
387387
decoded = Expr.from_bytes(blob_toggled, ctx=SessionContext())
388388
assert "double" in decoded.canonical_name()
389389

390+
def test_installing_a_codec_preserves_strict_mode(self):
391+
"""Installing an extension codec must not re-enable inlining.
392+
393+
`with_{logical,physical}_extension_codec` builds a replacement
394+
`Python{Logical,Physical}Codec` around the imported one, and the
395+
constructor defaults inlining to on. A context that opted out has to
396+
keep its setting, or a codec install silently starts shipping
397+
cloudpickled callables again.
398+
399+
The session's own capsule getters are a convenient stand-in for a real
400+
extension codec here: they return a genuine FFI codec, so the import
401+
path under test is the same one an extension library exercises.
402+
403+
This covers the logical codec. The physical one is checked in
404+
``test_plans.py``, since it takes an ``ExecutionPlan`` to observe.
405+
"""
406+
strict = SessionContext().with_python_udf_inlining(enabled=False)
407+
e = self._build_double_udf()(col("a"))
408+
assert b"DFPYUDF" not in e.to_bytes(strict)
409+
410+
with_logical = strict.with_logical_extension_codec(
411+
strict.__datafusion_logical_extension_codec__()
412+
)
413+
assert b"DFPYUDF" not in e.to_bytes(with_logical)
414+
415+
def test_installing_a_codec_preserves_inlining_when_enabled(self):
416+
"""The converse: the default stays on across a codec install.
417+
418+
Guards against 'fixing' the above by hard-coding inlining off.
419+
"""
420+
ctx = SessionContext()
421+
e = self._build_double_udf()(col("a"))
422+
assert b"DFPYUDF" in e.to_bytes(ctx)
423+
424+
installed = ctx.with_logical_extension_codec(
425+
ctx.__datafusion_logical_extension_codec__()
426+
)
427+
assert b"DFPYUDF" in e.to_bytes(installed)
428+
390429
def test_strict_roundtrip_via_registry(self):
391430
"""When both sender and receiver disable inlining, the UDF
392431
travels by name only and the receiver resolves it from its

python/tests/test_plans.py

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,13 +17,16 @@
1717

1818
import datetime
1919

20+
import pyarrow as pa
2021
import pytest
2122
from datafusion import (
2223
ExecutionPlan,
2324
LogicalPlan,
2425
Metric,
2526
MetricsSet,
2627
SessionContext,
28+
col,
29+
udf,
2730
)
2831

2932

@@ -92,6 +95,46 @@ def test_session_with_logical_extension_codec_roundtrip(ctx, df) -> None:
9295
assert df.collect() == df_round_trip.collect()
9396

9497

98+
def test_installing_a_physical_codec_preserves_strict_mode() -> None:
99+
"""Installing a physical extension codec must not re-enable inlining.
100+
101+
`with_physical_extension_codec` builds a replacement `PythonPhysicalCodec`
102+
around the imported one, and the constructor defaults inlining to on. A
103+
context that opted out via `with_python_udf_inlining(enabled=False)` has to
104+
keep its setting, or installing a codec silently starts embedding
105+
cloudpickled callables in serialized execution plans.
106+
107+
The logical counterpart lives in `test_pickle_expr.py`; this one needs an
108+
`ExecutionPlan` because only the physical codec encodes it. `DFPYUDF` is
109+
the scalar Python-UDF family prefix, shared by both layers; see
110+
`PY_SCALAR_UDF_FAMILY` in crates/core/src/codec.rs.
111+
"""
112+
identity = udf(
113+
lambda arr: arr,
114+
[pa.string()],
115+
pa.string(),
116+
volatility="immutable",
117+
name="identity_str",
118+
)
119+
120+
def plan_bytes(ctx: SessionContext) -> bytes:
121+
df = ctx.read_csv(path="testing/data/csv/aggregate_test_100.csv").select(
122+
identity(col("c1"))
123+
)
124+
return df.execution_plan().to_bytes(ctx)
125+
126+
# The inlining default is on, so the strict blob is what has to differ.
127+
assert b"DFPYUDF" in plan_bytes(SessionContext())
128+
129+
strict = SessionContext().with_python_udf_inlining(enabled=False)
130+
assert b"DFPYUDF" not in plan_bytes(strict)
131+
132+
installed = strict.with_physical_extension_codec(
133+
strict.__datafusion_physical_extension_codec__()
134+
)
135+
assert b"DFPYUDF" not in plan_bytes(installed)
136+
137+
95138
def test_session_codec_capsule_getters(ctx) -> None:
96139
"""SessionContext exposes both logical and physical codec capsules."""
97140
logical = ctx.ctx.__datafusion_logical_extension_codec__()

0 commit comments

Comments
 (0)