Skip to content

Commit 8e02881

Browse files
timsaucerclaude
andcommitted
Make extension codecs composable
Installing a logical or physical extension codec now prepends it to a codec chain instead of replacing the prior codec. The most recently installed codec is consulted first, falling through codec by codec to the default codec. This lets multiple independent extension libraries install codecs on the same session, and removes the codec registration ordering requirement between libraries. Chain dispatch treats a codec error as "not mine". Encoding runs each codec against a scratch buffer so failed attempts leave no partial bytes, and treats Ok-with-no-bytes (encode by name) as no opinion so later codecs still get a chance. When every codec fails, the errors are aggregated so the owning codec's diagnostic is not masked by the default codec's generic error. Also preserves the python_udf_inlining setting when installing a codec; previously it was silently reset to enabled. Documents the remaining planner constraint: a session holds one query planner, layering is explicit via fallback capsules, and codecs must be installed before exporting or chaining planners because a planner capsule captures the codecs at export time. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 05ab5da commit 8e02881

9 files changed

Lines changed: 552 additions & 69 deletions

File tree

crates/core/src/codec.rs

Lines changed: 388 additions & 56 deletions
Large diffs are not rendered by default.

crates/core/src/context.rs

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1472,7 +1472,9 @@ impl PySessionContext {
14721472
) -> PyDataFusionResult<Self> {
14731473
let inner_ffi = ffi_logical_codec_from_pycapsule(codec)?;
14741474
let inner: Arc<dyn LogicalExtensionCodec> = (&inner_ffi).into();
1475-
let logical_codec = Arc::new(PythonLogicalCodec::new(inner));
1475+
// Prepend rather than replace: previously installed codecs stay
1476+
// active, with the most recently installed one consulted first.
1477+
let logical_codec = Arc::new(self.logical_codec.with_additional_codec(inner));
14761478

14771479
let physical_codec = Arc::clone(&self.physical_codec);
14781480
let ctx = self
@@ -1497,7 +1499,9 @@ impl PySessionContext {
14971499
codec: Bound<'py, PyAny>,
14981500
) -> PyDataFusionResult<Self> {
14991501
let inner = physical_codec_from_pycapsule(&codec)?;
1500-
let physical_codec = Arc::new(PythonPhysicalCodec::new(inner));
1502+
// Prepend rather than replace: previously installed codecs stay
1503+
// active, with the most recently installed one consulted first.
1504+
let physical_codec = Arc::new(self.physical_codec.with_additional_codec(inner));
15011505

15021506
let logical_codec = Arc::clone(&self.logical_codec);
15031507
let ctx = self
@@ -1511,11 +1515,15 @@ impl PySessionContext {
15111515

15121516
pub fn with_python_udf_inlining(&self, enabled: bool) -> Self {
15131517
let logical_codec = Arc::new(
1514-
PythonLogicalCodec::new(Arc::clone(self.logical_codec.inner()))
1518+
self.logical_codec
1519+
.as_ref()
1520+
.clone()
15151521
.with_python_udf_inlining(enabled),
15161522
);
15171523
let physical_codec = Arc::new(
1518-
PythonPhysicalCodec::new(Arc::clone(self.physical_codec.inner()))
1524+
self.physical_codec
1525+
.as_ref()
1526+
.clone()
15191527
.with_python_udf_inlining(enabled),
15201528
);
15211529
let ctx = self

docs/source/contributor-guide/ffi.md

Lines changed: 54 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -248,15 +248,65 @@ foreign planner. This lets the planner decode provider-owned objects and lets
248248
process-local tokens to demonstrate ownership; production codecs should serialize
249249
durable metadata instead.
250250

251-
The current Python API has one external logical codec and one external physical codec.
252-
Installing another codec replaces the prior codec rather than composing a registry.
253-
The example therefore has one external codec owner, and the planner uses built-in
254-
physical nodes. Install the provider codecs before the planner where possible.
251+
### Composable codecs
252+
253+
Extension codecs compose. Each call to `with_logical_extension_codec` or
254+
`with_physical_extension_codec` adds the codec to the front of the session's codec
255+
chain rather than replacing prior codecs. During encoding and decoding, the most
256+
recently installed codec is consulted first, falling through codec by codec to
257+
DataFusion's default codec. A codec signals "not mine" by returning an error, which
258+
sends the chain on to the next codec. Two conventions keep this dispatch sound:
259+
260+
- Frame your payloads with a distinct byte prefix (pick a `DF` namespace plus a
261+
crate-specific suffix) and only decode payloads carrying your prefix.
262+
- Return an error for objects and payloads you do not own. A codec that answers
263+
success for objects outside its family shadows every codec installed before it.
264+
265+
Because dispatch keys off payload prefixes rather than install position, codec
266+
registration order between independent libraries does not matter.
255267

256268
The current FFI logical codec supports providers and UDFs but not arbitrary custom
257269
`LogicalPlan::Extension` nodes. See both example READMEs for the supported flow and
258270
local build commands.
259271

272+
### One planner per session, with explicit fallback
273+
274+
Unlike codecs, a `SessionState` holds exactly one query planner — installing another
275+
replaces it. Planner layering is therefore explicit: a planner that wants to handle
276+
only some queries should accept a fallback planner and delegate the rest to it. The
277+
current planner can be exported for that purpose with
278+
`ctx.__datafusion_query_planner__()`.
279+
280+
One ordering rule applies: a planner capsule captures the session's codecs at export
281+
time and cannot be rebound afterward. Codec changes made after installing a single
282+
planner are rebound automatically, but a planner wrapped inside another planner as a
283+
fallback is opaque and keeps the codecs it was exported with. **Install all extension
284+
codecs before exporting or chaining planners.**
285+
286+
Putting it together for a session using two extension libraries that each provide
287+
tables, functions, and a query planner:
288+
289+
```python
290+
ctx = SessionContext(config)
291+
292+
# 1. Codecs from both libraries. Order between libraries does not matter.
293+
ctx = ctx.with_logical_extension_codec(lib_a.codec())
294+
ctx = ctx.with_logical_extension_codec(lib_b.codec())
295+
ctx = ctx.with_physical_extension_codec(lib_a.physical_codec())
296+
ctx = ctx.with_physical_extension_codec(lib_b.physical_codec())
297+
298+
# 2. Planners, innermost fallback first. Library A's planner falls back to
299+
# DataFusion's default planner; library B's planner falls back to A's.
300+
ctx = ctx.with_query_planner(lib_a.Planner())
301+
ctx = ctx.with_query_planner(
302+
lib_b.Planner(fallback=ctx.__datafusion_query_planner__())
303+
)
304+
305+
# 3. Tables and functions — any time before the first query.
306+
ctx.register_table("t", lib_a.TableProvider())
307+
ctx.register_udf(udf(lib_b.SomeUDF()))
308+
```
309+
260310
## Alternative Approach
261311

262312
Suppose you needed to expose some other features of DataFusion and you could not wait

examples/datafusion-ffi-example/README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ Separate shared libraries guarantee distinct DataFusion library markers. This ca
1616

1717
The example codecs do not inspect the callback `TaskContext`. A production codec that depends on session configuration or registered functions must ensure its exported FFI codec is bound to, and retains, the appropriate host `TaskContextProvider`.
1818

19-
The current Python API installs one external logical codec and one external physical codec. It does not yet compose codecs from several independent plugin owners. This example therefore makes the provider library the sole external codec owner; the planner uses built-in physical nodes and receives the provider codecs from the host.
19+
Extension codecs compose: each `with_logical_extension_codec` / `with_physical_extension_codec` call prepends the codec to the session's codec chain, with the most recently installed codec consulted first and DataFusion's default codec as the terminal fallback. A codec signals "not mine" by returning an error, so several independent plugin libraries can install codecs on the same session as long as each only answers for payloads it owns (frame them with a distinct byte prefix). In this example the provider library is the only codec owner; the planner uses built-in physical nodes and receives the provider codecs from the host.
2020

2121
Register both provider codecs before installing the planner:
2222

examples/datafusion-ffi-example/python/tests/_test_logical_extension_codec.py

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@
1818
from __future__ import annotations
1919

2020
from datafusion import LogicalPlan, SessionContext
21-
from datafusion_ffi_example import MyLogicalExtensionCodec
21+
from datafusion_ffi_example import MyLogicalExtensionCodec, MyTableProvider
2222

2323

2424
def _setup_session_with_codec() -> tuple[SessionContext, MyLogicalExtensionCodec]:
@@ -80,3 +80,28 @@ def test_ffi_logical_codec_roundtrip():
8080
restored = LogicalPlan.from_bytes(ctx, blob)
8181
df_round_trip = ctx.create_dataframe_from_logical_plan(restored)
8282
assert df.collect() == df_round_trip.collect()
83+
84+
85+
def test_ffi_logical_codec_composes_with_later_install():
86+
"""Codecs compose: installing a second codec prepends it to the
87+
session's codec chain instead of replacing the first. The second
88+
codec here (a default-backed codec exported from a fresh session)
89+
cannot encode this library's table provider, so encoding falls
90+
through to the user codec installed first. Under replace semantics
91+
this test fails with `LogicalExtensionCodec is not provided`."""
92+
ctx, codec = _setup_session_with_codec()
93+
ctx = ctx.with_logical_extension_codec(
94+
SessionContext().__datafusion_logical_extension_codec__()
95+
)
96+
97+
ctx.register_table("numbers", MyTableProvider(1, 4, 1))
98+
df = ctx.sql('SELECT "A" FROM numbers')
99+
plan = df.logical_plan()
100+
101+
before = codec.table_provider_encode_calls()
102+
blob = plan.to_bytes(ctx)
103+
assert codec.table_provider_encode_calls() > before
104+
105+
restored = LogicalPlan.from_bytes(ctx, blob)
106+
df_round_trip = ctx.create_dataframe_from_logical_plan(restored)
107+
assert df.collect() == df_round_trip.collect()

examples/datafusion-ffi-example/python/tests/_test_physical_extension_codec.py

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,3 +76,26 @@ def test_ffi_physical_codec_roundtrip():
7676

7777
restored = ExecutionPlan.from_bytes(ctx, blob)
7878
assert str(original) == str(restored)
79+
80+
81+
def test_ffi_physical_codec_composes_with_later_install():
82+
"""Codecs compose: a second install prepends to the chain instead
83+
of replacing the first codec. The second codec here (default-backed
84+
export from a fresh session) encodes UDFs by name without writing
85+
bytes, which the chain treats as "no opinion" — so the user codec
86+
installed first is still consulted. Under replace semantics its
87+
counter stays at zero."""
88+
ctx, codec = _setup_session_with_codec()
89+
ctx = ctx.with_physical_extension_codec(
90+
SessionContext().__datafusion_physical_extension_codec__()
91+
)
92+
93+
df = ctx.sql("SELECT abs(a) AS x FROM t")
94+
original = df.execution_plan()
95+
96+
before = codec.encode_udf_calls()
97+
blob = original.to_bytes(ctx)
98+
assert codec.encode_udf_calls() > before
99+
100+
restored = ExecutionPlan.from_bytes(ctx, blob)
101+
assert str(original) == str(restored)

examples/datafusion-ffi-query-planner-example/README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,6 @@ ctx = ctx.with_query_planner(MyQueryPlanner())
3636

3737
`PlannerConfig` is transferred through the foreign session. `MyQueryPlanner` reads `ffi_query_planner.max_rows`, creates the plan with `DefaultPhysicalPlanner`, and adds a built-in `GlobalLimitExec`. The test changes the setting with `SET` and verifies the new row limit.
3838

39-
The provider's codec pair is attached to the planner when the derived context is created and is also used to decode the returned physical plan in `datafusion-python`. The API currently supports one external codec owner rather than a registry of independently composed codecs, so this planner deliberately uses only built-in physical nodes. Install the codecs before the planner where possible; derived contexts rebind codecs after planner installation, but planner-last order is easier to audit.
39+
The provider's codec pair is attached to the planner when the derived context is created and is also used to decode the returned physical plan in `datafusion-python`. Extension codecs compose: each `with_logical_extension_codec` / `with_physical_extension_codec` call prepends to the session's codec chain, so several libraries can install codecs on the same session. This planner owns no serializable types of its own and deliberately uses only built-in physical nodes. Install codecs before the planner; derived contexts rebind codecs after a planner is installed directly, but a planner exported as a fallback for another planner keeps the codecs captured at export time.
4040

4141
The pinned FFI logical codec cannot encode arbitrary custom `LogicalPlan::Extension` nodes. The example therefore demonstrates table-provider, UDF, and physical-plan interoperability without claiming custom logical extension support.

examples/datafusion-ffi-query-planner-example/python/tests/_test_three_library_query_planner.py

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -118,3 +118,26 @@ def test_query_planner_rejects_invalid_config(max_rows: str):
118118

119119
with pytest.raises(Exception, match=r"max_rows|Invalid value"):
120120
ctx.sql(f"SET ffi_query_planner.max_rows = '{max_rows}'").collect()
121+
122+
123+
def test_composed_codecs_with_query_planner():
124+
"""A second pair of codecs installed on top of the provider codecs
125+
composes with them instead of replacing them. The extra codecs
126+
(default-backed exports from a fresh session) decline everything,
127+
so planner-driven encode/decode falls through to the provider
128+
codecs and the query still succeeds end to end."""
129+
ctx, logical_codec, physical_codec = configured_context(max_rows=2)
130+
other = SessionContext()
131+
ctx = ctx.with_logical_extension_codec(
132+
other.__datafusion_logical_extension_codec__()
133+
)
134+
ctx = ctx.with_physical_extension_codec(
135+
other.__datafusion_physical_extension_codec__()
136+
)
137+
ctx = ctx.with_query_planner(MyQueryPlanner())
138+
139+
batches = ctx.sql('SELECT "A" FROM numbers ORDER BY "A"').collect()
140+
assert batches[0].column(0).to_pylist() == [0, 1]
141+
assert logical_codec.table_provider_encode_calls() > 0
142+
assert logical_codec.table_provider_decode_calls() > 0
143+
assert physical_codec.execution_plan_decode_calls() > 0

python/datafusion/context.py

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1779,6 +1779,15 @@ def with_query_planner(
17791779
its logical and physical extension codec settings. Codec changes made on
17801780
a derived context are rebound to the planner before planning.
17811781
1782+
A session holds exactly one query planner; installing another replaces
1783+
it. To layer planners, construct the new planner with the current
1784+
planner as its fallback (export it via
1785+
:py:meth:`__datafusion_query_planner__`) before installing. A planner
1786+
exported this way captures the codecs installed at export time and
1787+
cannot be rebound afterward, so install all extension codecs before
1788+
chaining planners. See the FFI extensions guide for the full
1789+
multi-library registration recipe.
1790+
17821791
Args:
17831792
planner: Object exposing ``__datafusion_query_planner__`` or a raw
17841793
``datafusion_query_planner`` PyCapsule.
@@ -2229,11 +2238,19 @@ def __datafusion_query_planner__(self) -> Any:
22292238
def with_logical_extension_codec(
22302239
self, codec: LogicalExtensionCodecExportable | _PyCapsule
22312240
) -> SessionContext:
2232-
"""Create a new session context with specified codec.
2241+
"""Create a new session context with an additional logical codec.
22332242
22342243
Only FFI codecs are supported. Pass any object implementing
22352244
``__datafusion_logical_extension_codec__`` (see
22362245
:py:class:`~datafusion.user_defined.LogicalExtensionCodecExportable`).
2246+
2247+
Codecs compose: each call adds the codec to the front of the
2248+
session's codec chain rather than replacing prior codecs. During
2249+
encoding and decoding, the most recently installed codec is
2250+
consulted first, falling through codec by codec to DataFusion's
2251+
default codec. Codecs signal "not mine" by returning an error, so
2252+
extension codecs should only answer for payloads they own —
2253+
typically identified by a distinct byte prefix.
22372254
"""
22382255
new_internal = self.ctx.with_logical_extension_codec(codec)
22392256
new = SessionContext.__new__(SessionContext)
@@ -2247,11 +2264,16 @@ def __datafusion_physical_extension_codec__(self) -> Any:
22472264
def with_physical_extension_codec(
22482265
self, codec: PhysicalExtensionCodecExportable | _PyCapsule
22492266
) -> SessionContext:
2250-
"""Create a new session context with the specified physical codec.
2267+
"""Create a new session context with an additional physical codec.
22512268
22522269
Only FFI codecs are supported. Pass any object implementing
22532270
``__datafusion_physical_extension_codec__`` (see
22542271
:py:class:`~datafusion.user_defined.PhysicalExtensionCodecExportable`).
2272+
2273+
Codecs compose the same way as in
2274+
:py:meth:`with_logical_extension_codec`: each call prepends to the
2275+
session's codec chain, and the most recently installed codec is
2276+
consulted first.
22552277
"""
22562278
new_internal = self.ctx.with_physical_extension_codec(codec)
22572279
new = SessionContext.__new__(SessionContext)

0 commit comments

Comments
 (0)