Skip to content

Commit d28615d

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 e6c7587 commit d28615d

9 files changed

Lines changed: 545 additions & 90 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: 17 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1465,14 +1465,13 @@ impl PySessionContext {
14651465
let inner: Arc<dyn LogicalExtensionCodec> = (&inner_ffi).into();
14661466

14671467
let this = slf.borrow();
1468-
// Carry the receiver's inlining setting over. `PythonLogicalCodec::new`
1469-
// defaults it to on, so building the replacement without this would
1470-
// silently re-enable inline Python UDF encoding on a context that had
1471-
// opted out with `with_python_udf_inlining(enabled=False)`.
1472-
let logical_codec = Arc::new(
1473-
PythonLogicalCodec::new(inner)
1474-
.with_python_udf_inlining(this.logical_codec.python_udf_inlining()),
1475-
);
1468+
// Prepend rather than replace: previously installed codecs stay active,
1469+
// with the most recently installed one consulted first. Prepending also
1470+
// carries the receiver's inlining setting over, which a fresh
1471+
// `PythonLogicalCodec::new` would not — it defaults inlining to on, and
1472+
// would silently re-enable inline Python UDF encoding on a context that
1473+
// had opted out with `with_python_udf_inlining(enabled=False)`.
1474+
let logical_codec = Arc::new(this.logical_codec.with_additional_codec(inner));
14761475
let derived = Self {
14771476
ctx: Arc::clone(&this.ctx),
14781477
logical_codec,
@@ -1503,11 +1502,9 @@ impl PySessionContext {
15031502
let inner: Arc<dyn PhysicalExtensionCodec> = (&inner_ffi).into();
15041503

15051504
let this = slf.borrow();
1506-
// See `with_logical_extension_codec` for why the flag is carried over.
1507-
let physical_codec = Arc::new(
1508-
PythonPhysicalCodec::new(inner)
1509-
.with_python_udf_inlining(this.physical_codec.python_udf_inlining()),
1510-
);
1505+
// See `with_logical_extension_codec` for why this prepends rather than
1506+
// replaces, and why that is also what carries the inlining flag over.
1507+
let physical_codec = Arc::new(this.physical_codec.with_additional_codec(inner));
15111508
let derived = Self {
15121509
ctx: Arc::clone(&this.ctx),
15131510
logical_codec: Arc::clone(&this.logical_codec),
@@ -1525,7 +1522,7 @@ impl PySessionContext {
15251522
// already inlines would otherwise rebind the session's planner to this
15261523
// handle's codecs, and callers routinely discard the result. Returning
15271524
// the codecs as-is is observationally equivalent to the rebuild below,
1528-
// which wraps the same inner codec in a fresh `Python*Codec`.
1525+
// which clones the same codec chain and only flips the flag.
15291526
if self.logical_codec.python_udf_inlining() == enabled
15301527
&& self.physical_codec.python_udf_inlining() == enabled
15311528
{
@@ -1537,11 +1534,15 @@ impl PySessionContext {
15371534
}
15381535

15391536
let logical_codec = Arc::new(
1540-
PythonLogicalCodec::new(Arc::clone(self.logical_codec.inner()))
1537+
self.logical_codec
1538+
.as_ref()
1539+
.clone()
15411540
.with_python_udf_inlining(enabled),
15421541
);
15431542
let physical_codec = Arc::new(
1544-
PythonPhysicalCodec::new(Arc::clone(self.physical_codec.inner()))
1543+
self.physical_codec
1544+
.as_ref()
1545+
.clone()
15451546
.with_python_udf_inlining(enabled),
15461547
);
15471548
let derived = Self {

‎docs/source/contributor-guide/ffi.md‎

Lines changed: 43 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -248,10 +248,43 @@ 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. Two libraries that
267+
each own tables, functions, and a planner register like this:
268+
269+
```python
270+
ctx = SessionContext(config)
271+
272+
# Codecs from both libraries. Order between libraries does not matter.
273+
ctx = ctx.with_logical_extension_codec(lib_a.codec())
274+
ctx = ctx.with_logical_extension_codec(lib_b.codec())
275+
ctx = ctx.with_physical_extension_codec(lib_a.physical_codec())
276+
ctx = ctx.with_physical_extension_codec(lib_b.physical_codec())
277+
278+
# A session holds one planner, so layering is explicit delegation. Install the
279+
# codecs first: the fallback captured here keeps the codecs it was exported
280+
# with. See "Rebinding a planner's codecs is one level deep" below.
281+
ctx.set_query_planner(lib_a.Planner())
282+
ctx.set_query_planner(lib_b.Planner(fallback=ctx.__datafusion_query_planner__()))
283+
284+
# Tables and functions — any time before the first query.
285+
ctx.register_table("t", lib_a.TableProvider())
286+
ctx.register_udf(udf(lib_b.SomeUDF()))
287+
```
255288

256289
The current FFI logical codec supports providers and UDFs but not arbitrary custom
257290
`LogicalPlan::Extension` nodes. See both example READMEs for the supported flow and
@@ -347,7 +380,7 @@ side is visible to both.
347380
so it is a property of the session rather than of a handle on it, and installing one is
348381
visible to every context sharing that session — including ones a `with_*` call returned
349382
earlier. Installing a codec on a session that already has a foreign planner rebuilds
350-
that planner against the new codec for the same reason: there is one planner, and it has
383+
that planner against the new chain for the same reason: there is one planner, and it has
351384
to carry the codecs currently in force. This happens on the shared session, so it takes
352385
effect even if the returned context is discarded — `ctx.with_python_udf_inlining(...)`
353386
whose result is thrown away still leaves the session's planner carrying the codecs of
@@ -361,15 +394,17 @@ that surprises people:
361394
> installed one. Every other path — `Expr.to_bytes(ctx)`, `ExecutionPlan.to_bytes(ctx)`,
362395
> registering a provider — uses the codecs of the handle you call it on.
363396
364-
Those can be different handles, and then one session has two codecs in effect at once:
397+
Those can be different handles, and then one session has two codec chains in effect at
398+
once:
365399

366400
```python
367401
ctx = ctx.with_logical_extension_codec(codec_a)
368402
ctx.set_query_planner(planner)
369403
ctx.with_logical_extension_codec(codec_b) # discarded
370404

371-
Expr.to_bytes(expr, ctx) # encodes with codec_a -- ctx's own field
372-
ctx.sql(...).collect() # plans with codec_b -- installed via the discarded handle
405+
Expr.to_bytes(expr, ctx) # encodes with [codec_a, default] -- ctx's own field
406+
ctx.sql(...).collect() # plans with [codec_b, codec_a, default] -- the discarded
407+
# handle's chain, installed on the shared session
373408
```
374409

375410
Chaining `ctx = ctx.with_...(...)`, as the example below does, keeps the two in step.

‎examples/datafusion-ffi-example/README.md‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,9 @@ Separate shared libraries guarantee distinct DataFusion library markers. This ca
3535

3636
Both codec getters take the `SessionContext` they are being installed on and pull the `TaskContextProvider` off it, so decode callbacks resolve session configuration and registered functions against the session that is running the query. Passing `require_udf_on_decode` to either constructor makes every decode call resolve a named scalar function out of that context, which is how the tests check where the registry came from.
3737

38-
This example makes the provider library the sole external codec owner. Register both provider codecs before installing the planner:
38+
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). This example keeps the provider library as the sole external codec owner; the planner uses built-in physical nodes and receives the provider codecs from the host.
39+
40+
Register both provider codecs before installing the planner:
3941

4042
```python
4143
ctx = ctx.with_logical_extension_codec(provider_logical_codec)
@@ -45,4 +47,4 @@ ctx.set_query_planner(planner)
4547

4648
Installing a codec after the planner rebuilds the planner against it, so this order is a recommendation rather than a requirement. Planner-last states the ownership flow more clearly. The exception is a planner that wraps a fallback: the rebuild reaches the installed planner only, not the fallback inside it, so codecs-first is a requirement there. See [Rebinding a planner's codecs is one level deep](../../docs/source/contributor-guide/ffi.md#rebinding-a-planners-codecs-is-one-level-deep), which also covers why re-installing a planner rebinds the session to the codecs of whichever handle it was installed on.
4749

48-
For the limits behind that choice — why there is one external codec owner rather than a registry, which node kinds survive the boundary, and what a derived context shares with the context it came from — see [Query Planners Across Multiple Libraries](../../docs/source/contributor-guide/ffi.md#query-planners-across-multiple-libraries) in the contributor guide.
50+
For the limits behind that choice — how the codec chain dispatches, which node kinds survive the boundary, and what a derived context shares with the context it came from — see [Query Planners Across Multiple Libraries](../../docs/source/contributor-guide/ffi.md#query-planners-across-multiple-libraries) in the contributor guide.

‎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: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,6 @@ ctx.set_query_planner(MyQueryPlanner())
5555

5656
`MyPlannerConfig` 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.
5757

58-
The provider's codec pair is attached to the planner when it is installed and is also used to decode the returned physical plan in `datafusion-python`. This planner deliberately uses only built-in physical nodes. Install the codecs before the planner where possible; installing a codec afterwards rebuilds the planner against it, but planner-last order is easier to audit. That rebuild is one level deep — a planner constructed with `fallback=` keeps the codecs its fallback was imported with — so codecs-first is a requirement rather than a preference once planners are layered. See [Rebinding a planner's codecs is one level deep](../../docs/source/contributor-guide/ffi.md#rebinding-a-planners-codecs-is-one-level-deep).
58+
The provider's codec chain is attached to the planner when it is installed 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 and the order between them does not matter. This planner owns no serializable types of its own and deliberately uses only built-in physical nodes. Install the codecs before the planner where possible; installing a codec afterwards rebuilds the planner against the new chain, but planner-last order is easier to audit. That rebuild is one level deep — a planner constructed with `fallback=` keeps the codecs its fallback was imported with — so codecs-first is a requirement rather than a preference once planners are layered. See [Rebinding a planner's codecs is one level deep](../../docs/source/contributor-guide/ffi.md#rebinding-a-planners-codecs-is-one-level-deep).
5959

60-
For the limits behind that choice — why there is one external codec owner rather than a registry, which node kinds survive the boundary, and what a derived context shares with the context it came from — see [Query Planners Across Multiple Libraries](../../docs/source/contributor-guide/ffi.md#query-planners-across-multiple-libraries) in the contributor guide.
60+
For the limits behind that choice — how the codec chain dispatches, which node kinds survive the boundary, and what a derived context shares with the context it came from — see [Query Planners Across Multiple Libraries](../../docs/source/contributor-guide/ffi.md#query-planners-across-multiple-libraries) in the contributor guide.

‎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
@@ -667,3 +667,26 @@ def test_query_planner_rejects_invalid_config(max_rows: str):
667667

668668
with pytest.raises(Exception, match=r"max_rows|Invalid value"):
669669
ctx.sql(f"SET ffi_query_planner.max_rows = '{max_rows}'").collect()
670+
671+
672+
def test_composed_codecs_with_query_planner():
673+
"""A second pair of codecs installed on top of the provider codecs
674+
composes with them instead of replacing them. The extra codecs
675+
(default-backed exports from a fresh session) decline everything,
676+
so planner-driven encode/decode falls through to the provider
677+
codecs and the query still succeeds end to end."""
678+
ctx, logical_codec, physical_codec = configured_context(max_rows=2)
679+
other = SessionContext()
680+
ctx = ctx.with_logical_extension_codec(
681+
other.__datafusion_logical_extension_codec__()
682+
)
683+
ctx = ctx.with_physical_extension_codec(
684+
other.__datafusion_physical_extension_codec__()
685+
)
686+
ctx.set_query_planner(MyQueryPlanner())
687+
688+
batches = ctx.sql('SELECT "A" FROM numbers ORDER BY "A"').collect()
689+
assert batches[0].column(0).to_pylist() == [0, 1]
690+
assert logical_codec.table_provider_encode_calls() > 0
691+
assert logical_codec.table_provider_decode_calls() > 0
692+
assert physical_codec.execution_plan_decode_calls() > 0

0 commit comments

Comments
 (0)