Skip to content

Add method on SessionContext to add all extensions from one library - #1679

Draft
timsaucer wants to merge 27 commits into
mainfrom
feat/ffi-with-extensions
Draft

Add method on SessionContext to add all extensions from one library#1679
timsaucer wants to merge 27 commits into
mainfrom
feat/ffi-with-extensions

Conversation

@timsaucer

@timsaucer timsaucer commented Aug 7, 2026

Copy link
Copy Markdown
Member

Which issue does this PR close?

Part 3 of 3 in the split of #1672. These are enabled as a github stack so you should be able to swap between the 3 PRs in the github interface (above, next to the "Open" oval).

Rationale for this change

Working on the Ballista integration showed that chaining the low-level with_* methods is easy to get wrong. FFI codecs and planners carry a weak task-context provider, and a query planner is built against whatever codec chains exist at the moment it is installed — so installing a codec afterwards leaves the planner encoding through a chain that is missing a library, and the caller is responsible for an ordering rule nothing enforces. SessionContext.with_extensions makes the whole installation one step so there is no "afterwards".

What changes are included in this PR?

SessionContext.with_extensions(*extensions) installs one or more extension bundles in a single transaction. A bundle is a reusable configuration object — typically shipped by a compiled extension library — that implements one or both of two new protocol hooks. Nothing is written to the session until every hook has returned and every capsule has been validated, so a failing bundle leaves the session as it was.

Codecs and planners install in two phases, because they compose differently. A session chains many codecs and dispatches between them by id, so codecs accumulate and their order does not affect decoding. A session holds exactly one query planner, so planners cannot accumulate — they compose by nesting, each wrapping the one before it. Phase one calls every bundle's __datafusion_session_extension__(ctx), which returns its codecs as a SessionExtensionComponents, and installs all of them. Phase two then calls every bundle's __datafusion_session_planner__(ctx, fallback), in argument order, handing each the planner built so far; wrapping fallback nests that bundle outside the previous one, so the last bundle listed ends up outermost. A bundle implements whichever hooks apply, so a library shipping only an optimizing planner does not have to return empty components.

Running the planner hooks after every codec is installed is the point of the split. The rebuild that follows a later codec install reaches only the outermost planner layer (upstream apache/datafusion#24762), so a fallback captured against a partial chain would stay stale forever. Within a with_extensions call there is no "afterwards" for any layer. This is also what lets several planner-shipping libraries be installed together at all: collecting planners from one hook meant every factory ran before anything was installed, so no bundle could see another's planner to wrap it.

Codecs must be handed over as objects exposing the capsule getter, never as bare PyCapsule objects. A codec's wire id is read off the object it arrives as, and a capsule has no type to read one from; with_extensions takes no codec_id=, so the capsule is refused with a message naming the getter to implement. Deriving the id from the contributing bundle instead is not a fix — the bundle is whatever object the caller passed, so an application that packages a library inside a bundle of its own would silently re-tag that library's payloads and they would stop decoding in the process that reads them. The inner library cannot defend against that no matter what it declares, and the mismatch does not surface until a decode fails elsewhere. Wrapping keeps the identity with the codec, which is what __datafusion_codec_id__ is for.

Ownership and lifetime. Like the individual with_* methods, the returned context is a handle on the same session as the receiver: catalogs, tables, registered functions, and configuration are the one session, and the planner is installed on that shared session even if the returned handle is discarded. Only the Python-side codec chains belong to the returned handle. There is one Arc<SessionContext> per session, so every weak provider a bundle creates stays valid for as long as any handle on that session is alive. The context-outlives-DataFrame contract is documented and tested: a DataFrame outliving every handle on its session fails with a clean out-of-scope error rather than crashing.

Example library. MyPlannerExtension in datafusion-ffi-query-planner-example is a complete Rust implementation of both hooks, including taking the host's task-context provider off the supplied context and wrapping its codecs in BundledLogicalCodec / BundledPhysicalCodec so they carry declared ids. Its planner emits a DistributedExec — an execution plan node private to that library — and its physical codec claims that node by downcast and rebuilds it on decode. That pairing is the normal shape for a bundle: a planner that emits its own executor is only useful alongside the codec that can serialize it, which is why the two ship together and why every codec is installed before any planner is bound.

Documentation. docs/source/contributor-guide/ffi.md documents with_extensions as the preferred API for extension bundles, keeps low-level chaining as advanced usage, and includes a full multi-library registration recipe. It also now covers what codec order does and does not control: decoding routes by id and is never order-dependent, while encoding stops at the first codec that claims a node, so a codec claiming a broad category can take nodes from a library installed after it. The query still succeeds — only the library that wrote the bytes changes, which breaks a plan that has to decode elsewhere. Where a bundle needs one position for its codec and another for its planner, the guide shows contributing each half separately with a small adapter rather than reordering, and treats the low-level sequence as the last resort it is. docs/source/user-guide/upgrade-guides.md points at with_extensions from the planner-install section. Rule 2 of .ai/skills/ffi-capsule-protocol/SKILL.md documents the planner hook's extra argument and the codecs-are-objects rule; Rule 6 calls out with_extensions as where "a session keeps one Arc<SessionContext> for life" is easiest to get wrong, since "bind the components to the context you are about to return" reads like an instruction to derive one first.

Are there any user-facing changes?

New public APIs: SessionContext.with_extensions, SessionExtensionComponents, and the SessionExtensionExportable / SessionPlannerExportable protocols with their __datafusion_session_extension__ and __datafusion_session_planner__ hooks. QueryPlannerExportable moved from datafusion.context to datafusion.extensions and is now exported from the package root alongside the rest of the family. The context-outlives-DataFrame ownership contract is now documented.

No breaking changes to existing APIs. Both new hooks are new in this PR, so no shipped extension library implements them yet and no upgrade-guide migration entry is needed for them.

Review notes

Several things changed during review and the earlier revisions read differently. Recording them here so the discussion above stays legible.

The installation no longer derives a separate context. It used to — _derive_for_extensions, using SessionContext::new_with_state(self.ctx.state()) — and bound the factories against that. Because new_with_state carries the session id over while minting a fresh Arc<RwLock<SessionState>>, that produced two live sessions reporting one session_id() with independent state: configuration and the function registry diverged, catalogs stayed shared, and both handles reported the same __datafusion_codec_id__, which is session:<session_id> and exists precisely to tell codec chains apart. The fork also bought nothing. It was meant to stop components binding to an intermediate context that could later be collected, but with one Arc<SessionContext> per session there is no such intermediate — deriving one is what creates the hazard, which is why Rule 6 of the capsule-protocol skill already said to mutate SessionState in place rather than derive a replacement. test_with_extensions_shares_the_session_with_the_source asserts matching session ids and that a SET issued through the source after installation is visible to the provider the bundle bound; reintroducing the fork fails it.

Bare capsules were briefly named after the contributing bundle, and are now refused instead. Naming them looked like it closed the gap that made the natural shape for a distributed engine — a Rust bundle handing over capsules — the one shape that could not produce portable plans. It did not: because the id came from whichever object the caller passed to with_extensions, wrapping one bundle inside another silently re-tagged the inner library's codecs, which is the ordinary way an application presents several libraries as one. Requiring an object instead puts the identity on the codec, where composition cannot move it, and deletes the resolution arm rather than documenting a footgun. test_with_extensions_codec_ids_survive_composition pins it.

with_extensions accepted at most one planner per call before the two-phase split. That was the right refusal for a single-hook protocol — every factory ran before anything was installed, so two bundles that both captured the session's planner would both capture the default and one would silently win — but it meant two libraries that each ship a planner could not be installed together, and splitting them across two calls discarded the first with no diagnostic. The planner hook removes the refusal by having the host thread each planner into the next rather than letting bundles capture one.

with_extensions() with no arguments no longer raises. Every sibling varargs method — DataFrame.select, filter, sort, drop, window — accepts zero arguments and returns a no-op result, and the two existing "at least one" guards in the codebase both cover cases with no meaningful identity element. Installing no extensions has an obvious answer, and a caller assembling the list from a plugin registry should not have to special-case it being empty.

Related follow-ups, neither of which this PR needs. enable_url_table is now once again the only method that mints a second Arc<SessionContext> for a session, and it forks state while keeping the session id (#1708). An agent skill for extension authors, as suggested in review, is #1707.

@ntjohnson1 ntjohnson1 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't have a fully coherent thought here. IIUC this is mostly to manage life times across the FFI boundary. I do wonder if there is a slightly cleaner way to mange this but this seems fine for now to provide a safer avenue.

I wonder if it makes sense to have a todo for some datafusion python extension skill/s. Being able to generate the 3 library example from the skill might be a nice smoke test to verify. I suspect after getting things setup for ballista/datafusion-distributed keeping it up to date shouldn't be too bad but I do suspect it will require some guidance to make sure they are doing it safely.

Comment thread python/datafusion/context.py Outdated
Comment thread python/datafusion/context.py

@milenkovicm milenkovicm left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM, thanks @timsaucer
i guess the only open point is chaining of codecs raised in previous pr

@milenkovicm

Copy link
Copy Markdown
Contributor

Following up on comment apache/datafusion-ballista#2252 (review) and follow up on this PR. FFILogicalCodec::encode|decode_file_format break our intent to have fully working distributed execution.

I might be wrong but encode|decode_file_format might not be an easy fix, at least not in short term and not in datafusion 55 timeframe, hence i have a proposal to make.

DistributedExec in ballista is nothing but a GRPC wrapper, it takes a logical plan, calls grpc endpoint and returns a stream of record batches. Would it make sense to create a CallbackPlanner (we might need a CallbackExec) in datafusion-py which would take a python closure LogicalPlan -> Stream<RecordBatches> (or LogicalPlanBlob -> Stream<RecordBatches>). Ballista would provide a closure which implements grcp logic in python code.

As CallbackPlanner have same library marker as df python FFILogicalCodec will not be triggered and we should have possibility to fully integrate distributed execution.

Basically we could implement query planner in python (limited but working)

wdyt @timsaucer and @ntjohnson1 ?

@timsaucer
timsaucer force-pushed the feat/ffi-with-extensions branch 2 times, most recently from e95bc43 to 4f16fa6 Compare September 4, 2026 17:11
@timsaucer

Copy link
Copy Markdown
Member Author

Thanks for chasing this down — the write_* failures are real, but I think the fix is much smaller than a new planner API, and it is not in this PR.

Where it actually breaks. FFI_LogicalExtensionCodec has no vtable entries for file formats at all. Both hooks are hardcoded stubs in datafusion-ffi (src/proto/logical_extension_codec.rs, try_decode_file_format / try_encode_file_formatnot_impl_err!("FFI does not support ...")). So any codec that crosses the FFI boundary loses file-format support, and df.write_csv/write_parquet/write_json build a LogicalPlan::Copy that carries a FileFormatFactory. That is the whole failure.

This PR already survives it on the datafusion-python side. PythonLogicalCodec::try_{encode,decode}_file_format go through chain_encode/chain_decode, which collect a chained codec's error and fall through to the terminal DefaultLogicalExtensionCodec. Built-in formats encode and decode fine there. The direction that fails is the mirror image: Ballista holds an FFI_LogicalExtensionCodec wrapping the Python session's codec and calls it directly, with no Default fallback behind it.

Two fixes, both much cheaper than a new API:

  1. Ballista side, today, no upstream change. Compose the FFI codec you get from Python with a DefaultLogicalExtensionCodec fallback on the two file-format hooks — the same shape PythonLogicalCodec uses here. One file, unblocks DF 55.

  2. Upstream datafusion-ffi, ~8 lines. Make those two hooks delegate to a local DefaultLogicalExtensionCodec instead of returning not_impl_err!. No new vtable entries, so no ABI break, so it can ship in a 55.x patch. It works because built-in file formats are fully self-describing protobuf and nothing has to cross the boundary: DefaultLogicalExtensionCodec::try_encode_file_format in datafusion-proto covers csv/json/arrow/avro/parquet by downcasting the factory locally. What is left unsupported is a custom FileFormatFactory owned by the foreign library, and I agree that one needs a real FFI_FileFormatFactory and will not make the 55 window — but it is also not what is breaking write_*.

On CallbackPlanner. I do not think the concept holds up, and I want to be concrete about why rather than just deferring it.

A QueryPlanner returning a CallbackExec leaf replaces the entire plan with one opaque node: no children, no partitioning DataFusion can see, no pushdown, no repartition, no limit, no statistics. That is not planning, it is interception at the root. It is coherent for exactly one architecture — thin client, full delegation — which is what Ballista's DistributedExec already is; the local plan is degenerate because none of the work is local. But it has no answer for a hybrid plan, a local table joined against a remote one, because there is nothing left to split on. All or nothing.

It also buys very little over what already ships:

plan_bytes = df.logical_plan().to_bytes(ctx)
batches = my_grpc_client.execute(plan_bytes)   # your gRPC logic, in Python
result = ctx.from_arrow(batches)               # any __arrow_c_stream__ object

All three exist today, no new API. The only thing CallbackPlanner adds on top is transparency — the user keeps writing ctx.sql(...).collect() and never sees the interception. That is real, but it is a thin return for a permanent public API, and a second planner mechanism sitting next to the FFI query planner means two ways to do one thing.

If the goal is partial delegation rather than whole-plan delegation, the design that answers it already exists: a table provider exported from Python (__datafusion_table_provider__, see docs/source/user-guide/io/table_provider.md). Remote data appears as a table, DataFusion plans around it, filter and projection pushdown work, and local and remote sources mix in one query. That composes; a root-level callback does not.

The part that makes me most hesitant is the stated rationale: "As CallbackPlanner have same library marker as df python, FFILogicalCodec will not be triggered." That shapes a public API around which cdylib the code is compiled into, in order to route around an eight-line gap in datafusion-ffi. Fixing the gap is the smaller and more durable change.

One genuinely open question, independent of all of the above — and I think it is the same "chaining of codecs" point you flagged. Once Ballista's codec is installed on the Python session it becomes a chain entry, and chain entries write a framed payload (DFPYCHN + codec id) rather than bare bytes. A plain Rust BallistaCodec in the scheduler will not strip that envelope. So how does the scheduler decode — does it link PythonLogicalCodec, or does Ballista's codec stay off the chain? I am happy to add whatever hook makes the first option workable; I would rather solve that than work around it.

Proposal: keep #1679 as is, since the file-format gap predates it and is orthogonal, and I will open the upstream issue for (2). If you still want the callback route after the above, let us give it its own issue so the design can be argued on its own terms.

Base automatically changed from feat/ffi-composable-codecs to main September 4, 2026 18:07
timsaucer and others added 8 commits September 4, 2026 14:07
Installing FFI extension codecs and query planners by chaining the
existing with_* methods can bind task-context providers to intermediate
contexts that are later collected, breaking the weak provider reference
over the FFI boundary. with_extensions creates one destination context,
passes it to each extension factory so components bind to that exact
context, and installs everything in a single state write.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
MyPlannerExtension in the query-planner example crate implements the
__datafusion_session_extension__ protocol from Rust: it extracts the
destination context's task-context provider, binds fresh observing
codecs and a planner to it, and returns SessionExtensionComponents. Its
codecs record the max_rows config value resolved through the weak
provider, letting tests prove the provider targets the returned context
rather than the source. Documents with_extensions as the preferred API
in the FFI guide.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A DataFrame does not keep its SessionContext alive. FFI components hold
a weak task-context provider, so operations that reach an FFI codec
after the context is collected fail with a clean out-of-scope error
rather than crashing. Lock that behavior in with a test and document
the ownership contract in the FFI guide and with_extensions docstring.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Single-underscore methods on internal pyo3 classes (such as
SessionContext._install_extensions) are private support methods for the
Python wrappers and do not require a public wrapper.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A codec-only bundle installed on a context that already holds an FFI
planner must rebind that planner to the new chains, so the planner
decodes through the bundle's codecs.

Codec ids are derived from the exporting class, so two bundles shipping
the same codec class collide and the install is refused. Declaring
__datafusion_codec_id__ on the object a bundle hands over resolves it,
and both chains then install.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The docs build runs Sphinx with --fail-on-warning. SessionExtensionComponents
documented its fields in both a napoleon `Attributes:` section and the dataclass
class-body annotations, so autoapi emitted each field twice and the build failed
with six "duplicate object description" warnings.

Move each field's description to a per-field docstring under its annotation so
autoapi renders exactly one entry per field.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
QueryPlannerExportable, SessionExtensionComponents, and
SessionExtensionExportable describe how an extension library plugs into a
session, not how a SessionContext behaves. Give them their own module so
context.py does not keep absorbing the extension surface as it grows.

extensions.py imports SessionContext, the codec protocols, and CapsuleType
under TYPE_CHECKING only, so context.py can import from it at runtime
without a cycle. All three names remain importable from datafusion and
datafusion.context; QueryPlannerExportable stays out of the top-level
__all__ as before.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The example was marked `+SKIP` because the main suite has no built FFI
extension to import, which is exactly how such an example rots. Parse the
statements out of the live docstring in the query-planner example suite,
drop the skip, and execute each one against a real extension bundle. Only
names are redirected: `my_extension` resolves to a stand-in combining this
repository's provider codecs and planner, and `SessionContext` supplies the
config that planner reads. A renamed method, a changed signature, or a wrong
expected output now fails CI, which already runs this suite.

Also drop the `extensions` Args entry's restatement of the type hint and
say instead what the hint does not: install order is chain order.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
timsaucer and others added 4 commits September 4, 2026 15:43
`_derive_for_extensions` minted a new `Arc<SessionContext>` via
`new_with_state(self.ctx.state())`. Every other `with_*` method shares
`Arc::clone(&self.ctx)`, and `new_with_state` carries the session id over, so
`with_extensions` returned a second live session claiming the same
`session_id()` as the source while holding independent `SessionState`.
Configuration and the function registry diverged, catalogs stayed shared, and
both handles reported the same `__datafusion_codec_id__` — which is
`session:<session_id>` and exists precisely to distinguish codec chains, so
installing both on a third session was refused as a duplicate id.

The fork also bought nothing. It was introduced to keep components from binding
to an intermediate context that could be collected, but there is one
`Arc<SessionContext>` per session, so no such intermediate exists; deriving one
is what creates the hazard. Rule 6 of the ffi-capsule-protocol skill already
said to mutate `SessionState` in place rather than derive a replacement.

Delete `_derive_for_extensions` and hand the receiver to the extension
factories. `_install_extensions` already returned a handle sharing
`Arc::clone(&slf.borrow().ctx)`, so removing the fork upstream of it is the
whole change. Atomicity is unaffected: both codec chains are built as locals and
state is written exactly once, at the end, in `set_session_query_planner`.

Replace `test_with_extensions_provider_targets_returned_context`, which is
vacuous once the session is shared, with
`test_with_extensions_shares_the_session_with_the_source`. It asserts matching
session ids and that a `SET` issued through the source after installation is
visible to the provider the bundle bound. Reintroducing the fork fails it.

Update the prose that described the fork-era design: the `with_extensions`
docstring and `SessionExtensionComponents` / `SessionExtensionExportable` in
`datafusion.extensions`, the `with_extensions` and "What a derived context
shares" sections of the FFI guide, the query planner example's README and
`extension.rs` comments, and two test docstrings. Note the shared-session
mechanism in Rule 6 of the skill, since `with_extensions` is where it is
easiest to get wrong.

`enable_url_table` is once again the only method that mints a second
`Arc<SessionContext>` for a session; its comment, the FFI guide, and the skill
now also record that it forks state while keeping the session id, tracked as a
bug in #1708.

Also add the missing doctest to `SessionExtensionComponents` and a pointer to
`with_extensions` from the upgrade guide, which described only the low-level
install path.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A codec handed to `with_extensions` as a bare `PyCapsule` fell through to
`anon:<uuid4>`, an id private to the session that installed it. Plans written
through it are undecodable anywhere else, and `with_extensions` accepts no
`codec_id=` to override that — so the workaround was to wrap the capsule in an
object declaring `__datafusion_codec_id__`, which nothing documented. A
distributed engine has to decode its plans in another process, so the shape it
would naturally ship — a Rust bundle handing over capsules, as
`MyPlannerExtension` does — was the one shape that could not work.

The bundle is the stable name that was missing. It is a plain Python object, so
its `module.QualName` is library-owned and exactly as stable across processes as
an exporting codec class's, which arm 3 of `derive_codec_id` already trusts. The
capsule was unnameable only because a capsule carries no type of its own, not
because nothing stable was in reach.

Resolve a capsule's id through the contributing bundle, using `derive_codec_id`
itself so the bundle inherits the same `__datafusion_codec_id__` escape hatch
against a class rename. The fallback applies only where randomness would have:
an id declared on the handed-over object, or that object's own class, still
wins, so an extension can name a codec directly.

Two bare capsules of one kind from one bundle collide and are refused. Numbering
them by position would be exactly the id `codec.rs` rejects for `anon:` — one
another library can mint the same value from — and would break stored plans the
first time the bundle reordered what it returns.

`resolve_codec_id` gains the bundle argument, `_install_extensions` takes
(codec, bundle) pairs, and the collision message now names both routes to a
distinct identity; it previously offered only `codec_id=`, which is unreachable
from `with_extensions`.

Covered in `python/tests/test_context.py`, which reaches every arm without a
built extension library: the bundle-derived name, an extension pinning its own
id, an id on the handed-over object winning, an exporting object keeping its
own, and the two-capsule collision. The cross-FFI case is pinned in the query
planner example, where a Rust bundle's capsules must report
`datafusion_ffi_query_planner_example.MyPlannerExtension` and no id may be
`anon:`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The doc comment said "the final state is written through this context's own
`state_ref()`", which overstates it. `set_session_query_planner` returns early
when there is no planner to bind, and the codec chains live on the returned
`PySessionContext` fields rather than in `SessionState` — so a codec-only
install onto a session with no FFI planner writes nothing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Mark `SessionExtensionExportable` `@runtime_checkable` and have
`with_extensions` check it with `isinstance` rather than `hasattr`, so the
annotation and the runtime check are the same statement, and callers can ask
the question too. Covered by a doctest on the protocol.

Replace the leading-underscore skip in `test_wrapper_coverage` with a named
allowlist. The pattern also excused `DataFrame._repr_html_`, which a wrapper
does have to provide, so a two-method need was weakening coverage for every
private name. Removing `_install_extensions` from the allowlist fails the test,
so the entry is load-bearing rather than decorative.

Say in `_CodecOnlyExtension` that retaining the context is what the protocol
tells real extensions not to do, and that it is kept only so a test can assert
which context the factory was handed.

Let the docstring-example shim in the query planner example accept a config
positionally, the way the real constructor does. Editing the docstring to
`SessionContext(config)` now fails as a doctest diff rather than as a
`TypeError` inside the harness.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@timsaucer
timsaucer marked this pull request as draft September 4, 2026 20:31
@timsaucer timsaucer changed the title Add atomic SessionContext.with_extensions API Add method on SessionContext to add all extensions from one library Sep 4, 2026
Reverses "Name a bundle's bare capsules after the bundle". Deriving a
capsule's id from the bundle that contributed it reads the identity off the
wrong object: the bundle is whatever the caller passed to with_extensions, so
an application that packages several libraries as one bundle of its own
stamps its identity onto the inner libraries' codecs. Their pinned
__datafusion_codec_id__ is discarded and there is nothing the inner library
can do about it, since its object never reaches _install_extensions. Nothing
fails at install time; the mismatch surfaces as an undecodable plan in the
process that reads it, naming an id nobody wrote in source.

So with_extensions now refuses a bare capsule and names the getter to
implement. An id read off the handed-over object is composition-stable by
construction, which the new tests pin at both layers. This also decouples a
codec's wire identity from the bundle's Python class name, which is what
__datafusion_codec_id__ exists for, and closes the case where a bundle built
by a factory function contributed a wire id containing "<locals>".

The low-level methods keep accepting capsules: they take codec_id=, so the
random anon: arm still has an escape hatch. Query planners are unaffected,
carrying no wire id.

MyPlannerExtension gains BundledLogicalCodec and BundledPhysicalCodec, small
pyclasses holding the bound FFI codec and declaring pinned ids, as the
reference shape for a library whose plans leave the process.

Also, unrelated to the above but adjacent in the docs: with_extensions never
said that a bundle-supplied planner replaces an installed one rather than
layering, and the SessionExtensionComponents example that showed a codec was
fully skipped with undefined names. Both fixed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
timsaucer and others added 2 commits September 5, 2026 10:46
A session chains many codecs and dispatches between them by id, so codecs
accumulate and their order does not affect decoding. A session holds exactly
one query planner, so planners cannot accumulate — they compose by nesting,
each wrapping the one before it. Collecting both from a single hook forced
with_extensions to refuse more than one planner per call, because every factory
ran before anything was installed and so no bundle could see another bundle's
planner to wrap it. Two libraries that each ship a planner could not be
installed together at all, and splitting them across two calls silently
discarded the first.

Codecs now come from __datafusion_session_extension__ and planners from a new
__datafusion_session_planner__(ctx, fallback), which runs once per bundle in
argument order after every codec is installed. Each receives the planner built
so far; wrapping it nests this bundle outside the previous one, so the last
bundle listed ends up outermost. A bundle implements either hook or both, which
also lets a library that ships only an optimizing planner stop returning empty
components. SessionExtensionComponents loses its query_planner field.

Running the planner hooks after every codec is installed is what makes a nested
planner safe. The rebuild that follows a later codec install reaches only the
outermost layer, so a fallback captured against a partial chain would stay
stale; there is now no "afterwards" within a call.

Atomicity is unchanged. _install_extension_codecs writes nothing — the chains
belong to the returned handle — so phase one is transactional for free, and the
nest is built in memory with _install_extension_planner performing the single
session write after the last hook returns. A hook that raises in either phase
leaves the caller's context as it was.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The bundle shipped a planner and codecs, but the halves never met: the planner
emitted a stock GlobalLimitExec and the codecs delegated everything to the
default codec. So the example asserted by structure that a planner and its
codecs belong together without demonstrating why, and no test would have caught
a bundle whose planner emits a node its own codec cannot encode.

DistributedQueryPlanner now wraps its result in a DistributedExec, a type
private to this library, and ObservingPhysicalExtensionCodec claims it by
downcast and rebuilds it from its inputs. Nothing else in the session knows the
type, which is the reason the two ship as one bundle. The observing codecs stop
being dead weight in the process — they were previously never consulted, and
decode_max_rows_seen had no caller.

Also documents what codec order does and does not control, which building this
surfaced. Decoding routes by id and is never order-dependent. Encoding stops at
the first codec that claims the node, so a codec claiming a broad category —
MyPhysicalExtensionCodec claims any ForeignExecutionPlan — takes nodes from any
library installed after it. The query still succeeds; only the library that
wrote the bytes changes, which breaks a plan that has to decode elsewhere.

That gives a bundle two reasons to want different positions for its two halves.
The guide now says to contribute each half at its own position with a small
adapter rather than reordering, since the hooks are independent, and treats the
low-level sequence as the last resort it is: it works, but it hands back
responsibility for codec-before-planner ordering and leaves a hand-layered
fallback holding the codecs it captured. No attempt is made to express every
permutation from one call.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
timsaucer and others added 12 commits September 8, 2026 14:48
The physical observer earns its place now that it claims the bundle's own
DistributedExec, but the logical one never did and cannot: it declines all four
methods to the default codec, and this library defines no logical extension
node for it to claim. The FFI logical codec does not carry arbitrary
LogicalPlan::Extension nodes anyway, so there is no logical analogue to give
it. Measuring a query confirms it: every record_task_ctx firing comes from the
physical decode path and none from the logical one.

BundledLogicalCodec now wraps DefaultLogicalExtensionCodec directly, which
keeps what the logical half actually demonstrated -- a bundle contributing both
codec kinds under ids it declares -- and drops 54 lines of trait impl and
hand-written Debug that fed an accessor nothing could observe.

Also narrows PlannerObservations::used_fallback back to private. It is read
only through MyQueryPlanner::used_fallback in the same module; its neighbours
need pub(crate) because extension.rs reads them, and it does not.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Two loose ends from review.

QueryPlannerExportable was the only member of the extension protocol family
left in the submodule while SessionExtensionComponents,
SessionExtensionExportable and SessionPlannerExportable were exported from the
package root. It types the planner a __datafusion_session_planner__ hook
returns, so a bundle author needs it just as much, and one family member
importing differently from the rest is a papercut with no upside. Also fixes
two doc references that stopped resolving when these classes moved out of
context.py: a bare :class:`QueryPlannerExportable` and a bare
:py:class:`SessionExtensionComponents`, both now spelled with their module the
way the neighbouring datafusion.user_defined references are.

with_extensions() with no arguments raised instead of installing nothing. That
put it out of family: every sibling varargs method -- DataFrame.select, filter,
sort, drop, window -- accepts zero arguments and returns a no-op result, and
the two existing "at least one" guards in the codebase both cover cases with no
meaningful identity element, which this is not. Installing no extensions has an
obvious answer, and a caller assembling the list from a plugin registry should
not have to special-case it being empty. Phase two still runs, so the empty
case rebinds an existing FFI planner to unchanged chains; a test pins that the
planner survives it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The planner-install section still said a bundle exposes
__datafusion_session_extension__, which stopped being the whole protocol when
planners moved to a hook of their own.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`myst_heading_anchors` was 3, but the extension-bundles section added in this
branch cross-references its own `####` subsections. Sphinx warns
`'myst' cross-reference target not found: 'when-codec-order-does-matter'` and
renders that link as plain text; the docs build does not pass `-W`, so it went
unnoticed.

Bumping to 4 rather than promoting the heading keeps the four subsections
nested under `### Extension bundles: with_extensions`, where they belong.
Only one other `####` heading exists under `docs/source/`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`with_extensions` ended every call with `_install_extension_planner`, which
rebuilds `SessionState` to rebind an existing FFI planner to this handle's
codec chains. When the call installed no codec there is nothing to rebind
against, so the rebuild is at best churn — and at worst it drags a planner
that is sitting on another handle's codecs onto this one's, silently undoing
that install. `with_python_udf_inlining` already guards its no-op toggle for
exactly this reason; `with_extensions` now guards the same way.

`test_with_extensions_installing_nothing_leaves_the_planner_alone` covers both
shapes of "installed nothing": no arguments at all, and a bundle whose hooks
answer empty. Both fail without the guard, with the planner left on an empty
logical chain and the query erroring out.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`logical_extension_codecs=codec` instead of `(codec,)` is the easy mistake to
make, and it surfaced as `'MyCodec' object is not iterable` raised by an
`extend` call inside `with_extensions` — naming neither the field nor the hook
that built the value. `__post_init__` now checks it, so the error lands in the
extension library's own frame and says which field is wrong and how to spell
one codec.

It also normalizes each field to a tuple. The declared type is a tuple and the
class is frozen, so a list left in place would be a mutable member of an
immutable value, and a generator would be exhausted by the first read. A str
is refused rather than normalized: it is iterable, so it would otherwise
become a tuple of characters and fail much later as that many bogus codecs.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`with_extensions` accepts a bundle implementing either hook — the runtime check
tests against both protocols, `test_with_extensions_accepts_a_planner_only_extension`
pins it, and the FFI guide's `PlannerOf` adapter recommends contributing only the
planner half. The annotation named `SessionExtensionExportable` alone, so a type
checker rejected the very shape the guide tells authors to write.

Two doc comments also went stale. `test_with_extensions_no_extensions_keeps_an_installed_planner`
still described phase two running and rebinding an existing planner, which the
no-op guard now skips outright, and `__datafusion_codec_id__` listed a
`_install_extensions` method that never existed under that name.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The `SessionExtensionComponents` doctest took its codec capsule off a
`SessionContext()` that was dropped on the same line. An FFI codec holds its
task-context provider weakly, so that capsule names a session that is already
gone — the doctest only reads an id back so it passes, but it is the exact
shape the FFI guide warns against. It now keeps the context in a name.

`SessionPlannerExportable` called returning `fallback` a wrap that "contributes
nothing". It is not: the capsule the first bundle receives wraps the session's
planner for export, so handing it back installs it as a foreign planner and
every later plan crosses an FFI boundary that was not there before. `None` is
the no-op. Corrected in the protocol docstring, the FFI guide's canonical
section, and the `_PlannerExtension` test helper that repeated the claim.

`__post_init__` walked a written-out list of field names. It now walks
`dataclasses.fields`, filtered on the `_codecs` suffix so a codec field added
later is normalized without anyone remembering to name it, and a future field
that is not a codec collection is left alone.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The method rebinds nothing. It imports whatever a `__datafusion_session_planner__`
hook returned — an object exposing the getter or a raw capsule — and hands back a
capsule, so the next hook receives one either way; its own doc comment already
said "re-export". Meanwhile "rebind" means something specific and different in
this file: rebuilding an installed planner against a handle's codec chains, which
is what `set_session_query_planner` does. Freeing the word keeps the two apart.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`__datafusion_session_planner__` was documented as receiving a context that
carries the final codec chains, and `MyPlannerExtension` relies on exactly that
when it takes the host's codecs off `ctx` instead of minting its own. The other
side was never stated: `__datafusion_session_extension__` runs before anything
is installed, so its `ctx` is the same session with the chains the receiver
already had — missing this call's codecs, including the bundle's own.

Both hooks hand back a valid task-context provider, which is what components
actually need, so the difference only bites an author who reads codec chains off
the context. Recorded on `SessionExtensionExportable`, in the FFI guide's
two-phase section, and in Rule 2 of the capsule-protocol skill.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`test_with_extensions_threads_the_planner_through_in_order` never checked
an order: it asserted each hook recorded one fallback and that the second
was not None, both of which hold for a host that ran the hooks backwards.
`test_with_extensions_skips_a_planner_hook_returning_none` asserted only
that downstream ran, while its comment claimed the skipped hook had not
become downstream's fallback.

`_PlannerExtension` now takes an optional shared list the hooks append
themselves to, so order is observable. The threading test asserts that
list, plus that the second hook's fallback is not the object the first was
handed -- the host re-exports every return value before passing it on. A
capsule is opaque from Python, so that cannot separate a re-export of the
first planner from a fresh read of the session's; the comment says so and
points at the FFI suite's `test_with_extensions_nests_planners_in_argument_order`,
which pins the nesting by asserting the outer planner delegated.

The skip test records the skipped hook's fallback too, and asserts both
hooks ran, that downstream was handed a different capsule, and that the
resulting context still queries.

Each new assertion was mutation-tested: reversing the planner loop, dropping
the `if supplied is None: continue`, and replacing the re-export with a
straight pass-through each fail exactly one of these tests.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`QueryPlannerExportable` said `session` is the
`datafusion.context.SessionContext` the planner is being installed on. It
is not. The capsule getters are called from Rust and receive the PyO3
context, so `isinstance(session, SessionContext)` is False -- while its
repr reads `datafusion.SessionContext`, because the pyclass declares
`module = "datafusion"`. It carries every capsule getter and
`__datafusion_codec_id__`, which is all the protocol needs, so the fix is
to say duck-type it rather than to change what is passed. The two bundle
hooks are the exception and do receive the wrapper, since `with_extensions`
dispatches them from Python; the ffi.md section on capsule getters now
draws the same distinction.

The `with_extensions` `Raises:` section listed ValueError for colliding
codec ids only. A getter returning a capsule of the wrong kind also raises
it -- `Expected name 'datafusion_query_planner' in PyCapsule, instead got
'datafusion_logical_extension_codec'` -- which
`test_with_extensions_rejects_bad_codec_capsule` already pins.

The `datafusion.extensions` module docstring said phase two runs the
planner hook "once per bundle". Once per bundle that implements it; a
bundle implements either hook or both.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants