Skip to content

Commit 7c86fb0

Browse files
timsaucerclaude
andcommitted
docs: split the FFI guide into one section per audience
`contributor-guide/ffi.md` had grown to 795 lines serving three different readers at once, filed under a section whose index says it is for people contributing to this repository. An engineer at delta-rs or a distributed-engine vendor is neither a contributor nor an end user; they consume a published, versioned protocol, and the only description of it lived behind a heading telling them the page was not for them. Adds a third top-level section, `extension-guide/`, so the sidebar reads User Guide / Extension Guide / Contributor Guide — one per audience. It carries the `(ffi)=` label, so every existing reference keeps resolving, including the `:ref:`ffi`` inside `context.py`'s docstring that ships in the wheel. The maintainer-facing rationale moves to `contributor-guide/ffi-internals.md`: the weak-`Arc` scheme, why repairing an orphaned provider cannot work, why planner codec rebinding is one level deep, and the two upstream issues. An extension vendor should not be reading "that is a bug rather than a design, do not copy the pattern" halfway down their integration guide. The PyO3 `frozen` policy moves to `contributor-guide/pyo3-guidelines.md`. It is project review policy with nothing to do with FFI, and extracting it repairs a prose bug: it had been spliced into the middle of "Implementation Details", so the sentence "If you were interfacing with a library that provided the above `FFI_TableProvider`" resumed 46 lines after the snippet it referred to. Those two halves are rejoined on `capsule-protocol.md`. Fills the coverage gap the split exposed. The codebase exports 18 capsule getters and the old page documented 7; the remaining 11 appeared on no page that even listed them. The section index now carries a table of all 18, and `table-providers.md` and `functions.md` document the catalog family, table functions, physical optimizer rules, and extension options for the first time. Corrects the argument rule while moving it. The old section asserted that capsule getters "receive the SessionContext they are being installed on", which is true for the codec, planner, and table-function hooks but not for the catalog family: `CapsuleGetterArg::LogicalCodec` passes the host's logical codec as a bare capsule, and `__datafusion_table_provider__` gets a session from `SessionContext.register_table` but a codec capsule from `Schema.register_table`. Nothing breaks, because every implementation passes the argument to `ffi_logical_codec_from_pycapsule`, which handles both — so the rule is now stated by capability rather than by type, and the upgrade guide says which hooks changed. Also converts the guide's five in-page heading links to labelled `{ref}` targets, since heading anchors rot silently on rewording, and redirects the old `contributor-guide/ffi.html` URL. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 781e980 commit 7c86fb0

18 files changed

Lines changed: 1785 additions & 824 deletions

‎docs/source/conf.py‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,7 @@
6666
# gone, or Sphinx builds the real page and the stub is never written.
6767
redirects = {
6868
"user-guide/distributing-work": "distributing-work/index.html",
69+
"contributor-guide/ffi": "../extension-guide/index.html",
6970
}
7071

7172
# NOTE: .rst stays alongside .md because sphinx-autoapi generates RST
Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,137 @@
1+
<!---
2+
Licensed to the Apache Software Foundation (ASF) under one
3+
or more contributor license agreements. See the NOTICE file
4+
distributed with this work for additional information
5+
regarding copyright ownership. The ASF licenses this file
6+
to you under the Apache License, Version 2.0 (the
7+
"License"); you may not use this file except in compliance
8+
with the License. You may obtain a copy of the License at
9+
10+
http://www.apache.org/licenses/LICENSE-2.0
11+
12+
Unless required by applicable law or agreed to in writing,
13+
software distributed under the License is distributed on an
14+
"AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
15+
KIND, either express or implied. See the License for the
16+
specific language governing permissions and limitations
17+
under the License.
18+
-->
19+
20+
(ffi_internals)=
21+
22+
# FFI framing internals
23+
24+
Read this before changing how datafusion-python frames FFI components. You do
25+
**not** need it to write an extension library — that is the
26+
{ref}`Extension Guide <extension_guide>`.
27+
28+
The invariants here are the reasons the extension-facing rules are shaped the
29+
way they are. Each one has an "obvious" simplification that does not work, and
30+
the point of this page is to record why, so the next person does not spend a
31+
week rediscovering it.
32+
33+
For the wire format itself — how a codec id is stored alongside a payload,
34+
routed back on decode, and which two cases stay unframed — see the module
35+
documentation in `crates/core/src/codec.rs`. It is the authority; this page
36+
does not restate it.
37+
38+
(ffi_internals_one_arc)=
39+
40+
## One session, one `Arc<SessionContext>`
41+
42+
Every codec handed to a foreign object carries an `FFI_TaskContextProvider`,
43+
and that type holds its provider **weakly**. A registered catalog provider
44+
upgrades the handle on every `supports_filters_pushdown` and every `scan`.
45+
Those handles are bound to one particular `Arc<SessionContext>` allocation, not
46+
to the logical session, so anything that replaces the allocation orphans all of
47+
them and the next query fails with `TaskContextProvider went out of scope over
48+
FFI boundary`.
49+
50+
So a `PySessionContext` keeps the `Arc<SessionContext>` it was created with for
51+
its whole life. Installing a query planner writes the new `SessionState` back
52+
through `state_ref()`, exactly as `add_physical_optimizer_rule` does, rather
53+
than deriving a replacement context. The session id is carried across that
54+
rewrite — `SessionStateBuilder` mints a fresh one otherwise — so `session_id()`
55+
and every `TaskContext` the session hands out keep agreeing.
56+
57+
Repairing the damage instead of avoiding it does not work in general. A context
58+
can rebuild the codecs it holds in its own fields, but a codec already embedded
59+
in a registered `FFI_CatalogProvider` — and in every `FFI_SchemaProvider` and
60+
`FFI_TableProvider` minted from it — is not reachable from Python at all. Nor
61+
can the codec simply retain the session that built it: a codec handed to a
62+
provider is routinely registered straight back into that same session, which
63+
would close the cycle
64+
`SessionContext -> catalog -> FFI provider -> FFI codec -> SessionContext` and
65+
leak it.
66+
67+
`SessionContext.enable_url_table` is the one exception. It clones the
68+
underlying `SessionContext`, so the returned context has an allocation of its
69+
own and must not outlive the receiver. It also forks the session's state while
70+
keeping its id, so two handles report one `session_id()` with divergent
71+
configuration. That is a bug rather than a design, tracked in
72+
[apache/datafusion-python#1708](https://github.com/apache/datafusion-python/issues/1708);
73+
do not copy the pattern.
74+
75+
(ffi_internals_rebinding)=
76+
77+
## Why planner codec rebinding is one level deep
78+
79+
Installing a codec on a session that already has a foreign planner rebuilds
80+
that planner against the new chain. The rebuild swaps the codecs on the
81+
installed `ForeignQueryPlanner` handle, and only that handle. A planner that
82+
wraps a fallback resolved that fallback when *it* was installed, and holds the
83+
result inside its own library's private data — behind a `create_physical_plan`
84+
function pointer, with no Python-side handle. A codec installed afterwards
85+
therefore reaches the outer planner and not the fallback, which keeps whichever
86+
codecs were in force when it was imported.
87+
88+
Neither side can repair that:
89+
90+
- **The host cannot reach it.** `FFI_QueryPlanner::new_with_ffi_codecs` unwraps
91+
exactly one `ForeignQueryPlanner` layer. There is no deeper handle to unwrap
92+
— the same situation as a codec embedded in a registered
93+
`FFI_CatalogProvider`.
94+
- **The planner library cannot re-derive it.** `FFI_QueryPlanner` holds its
95+
codecs by value, and `Session` exposes no accessor for the ones the host
96+
currently has, so `create_physical_plan` cannot pick them up from the session
97+
it is handed. The rebuild has to be eager, and an eager rebuild only sees the
98+
top layer.
99+
100+
A fix has to come from upstream, and is tracked in
101+
[apache/datafusion#24762](https://github.com/apache/datafusion/issues/24762).
102+
103+
The stale codecs stay usable rather than dangling — they hold weak handles to
104+
the one `Arc<SessionContext>` that the previous section keeps alive — so the
105+
effect is a fallback hop serializing with an older codec, not a failure. It is
106+
also invisible to the examples in this repository, which use one fallback in
107+
the same cdylib as its wrapper; `datafusion-ffi` short-circuits a same-library
108+
hop rather than serializing, so no codec runs. A fallback in a *different*
109+
library would serialize, and would do it with the codecs it was imported with.
110+
111+
The extension-facing consequence — install codecs before a layered planner, and
112+
prefer `with_extensions` — is documented at {ref}`planner_codec_rebinding`.
113+
114+
## Two argument kinds for one convention
115+
116+
`CapsuleGetterArg` in `crates/util/src/lib.rs` distinguishes three cases: no
117+
argument, the session, and the host's logical extension codec as a bare
118+
capsule. Provider and catalog registration passes the codec; the extension
119+
codecs, the query planner, and table functions get the session.
120+
121+
The distinction exists so that a `TypeError` from a getter that refused its
122+
argument can be rewritten into an `ImportError` naming what the getter *should*
123+
accept. Getting that message wrong sends an extension author to fix the wrong
124+
signature, which is why every capsule getter routes through
125+
`call_capsule_getter` rather than calling `getattr` directly. Three importers
126+
previously each had their own copy of that logic and each missed later
127+
corrections to it.
128+
129+
When adding a hook, decide which arm it needs by what its FFI constructor
130+
requires: a task-context provider means it must have the session, since you
131+
cannot get a provider off a capsule. A codec alone means either works, and
132+
passing the codec keeps the host from handing out session handles it does not
133+
need to.
134+
135+
The extension-facing statement of this is {ref}`extension_getter_argument`,
136+
which deliberately describes the argument by capability — "something you can
137+
read the host's logical codec off" — rather than by type.

0 commit comments

Comments
 (0)