Skip to content

Commit 781e980

Browse files
timsaucerclaude
andcommitted
docs: add a user-facing extensions page and split distributing-work
The user guide had no page for someone who installs an extension library and wants to run queries with it. `with_extensions` was described only in a 123-line docstring and in the contributor-guide FFI page, which opens by explaining that Rust has no stable ABI — the wrong altitude for a reader who just wants their queries to run somewhere else. Adds `user-guide/extensions.md`: what an extension library is, which kinds register directly versus needing `with_extensions`, the two failure modes that actually bite (a collected context, a version mismatch), and how to check what a session was taught. It names no capsule, no ABI, and no codec id; the only occurrences of `FFI` and `TaskContextProvider` are inside the error string a reader would be searching for. Splits `distributing-work.md` into a directory. The page was entirely about pickling expressions to worker pools and treated query-level distribution as two stubs at the end, so nothing on the site connected `with_extensions` to distribution at all — which is the road a data scientist is actually looking for. The index now asks who owns the partitioning decision and routes accordingly; `query-engines.md` carries the missing bridge and absorbs the two upstream work-in-progress sections. Wires `sphinx-reredirects`, which has been a declared dependency since #1578 without ever being enabled, so the old `distributing-work.html` URL keeps working. Also fixes three pointers that went stale in the MyST migration and still named `.rst` files, a malformed `ref:` role in udf-and-udfa.md that rendered as literal text, and a doubled "the" in data-sources.md. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 4296c5e commit 781e980

12 files changed

Lines changed: 370 additions & 52 deletions

File tree

‎crates/core/src/codec.rs‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -660,7 +660,7 @@ impl PythonLogicalCodec {
660660
/// `cloudpickle.loads` on the inline `DFPY*` payload. It does
661661
/// **not** make `pickle.loads(untrusted_bytes)` safe; treat every
662662
/// `pickle.loads` on untrusted input as unsafe regardless of this
663-
/// setting. See `docs/source/user-guide/io/distributing_work.rst`
663+
/// setting. See `docs/source/user-guide/distributing-work/expressions.md`
664664
/// (Security section) for the full threat model, and Python's
665665
/// [pickle module security warning][1] for why `pickle.loads` is
666666
/// unsafe in general.

‎docs/source/conf.py‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,8 +54,20 @@
5454
# raises an extension conflict.
5555
"myst_nb",
5656
"autoapi.extension",
57+
# Emits a meta-refresh stub at each old docname listed in `redirects`
58+
# below, so inbound links to pages that have moved keep working.
59+
"sphinx_reredirects",
5760
]
5861

62+
# Old page URLs that have moved. The site is single-version (each release
63+
# overwrites asf-site wholesale), so these exist purely for inbound external
64+
# links — issue comments, release blog posts, and README links that pinned a
65+
# heading anchor. Keys are docnames without a suffix; the source file must be
66+
# gone, or Sphinx builds the real page and the stub is never written.
67+
redirects = {
68+
"user-guide/distributing-work": "distributing-work/index.html",
69+
}
70+
5971
# NOTE: .rst stays alongside .md because sphinx-autoapi generates RST
6072
# under autoapi/ and Sphinx needs the suffix to parse it. The human-
6173
# authored docs are all MyST .md now. ".md" is routed through myst-nb so

‎docs/source/user-guide/common-operations/udf-and-udfa.md‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -391,7 +391,8 @@ df.select("a", exp_smooth(col("a")).alias("smooth_a")).show()
391391
User Defined Table Functions are slightly different than the other functions
392392
described here. These functions take any number of `Expr` arguments, but only
393393
literal expressions are supported. Table functions must return a Table
394-
Provider as described in the ref:`_io_custom_table_provider` page.
394+
Provider as described in the {ref}`Custom Table Provider <io_custom_table_provider>`
395+
page.
395396

396397
Once you have a table function, you can register it with the session context
397398
by using {py:func}`datafusion.context.SessionContext.register_udtf`.

‎docs/source/user-guide/data-sources.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -214,7 +214,7 @@ Features that are available in PyIceberg but not yet in Iceberg Rust will not be
214214
## Custom Table Provider
215215

216216
You can implement a custom Data Provider in Rust and expose it to DataFusion through the
217-
the interface as describe in the {ref}`Custom Table Provider <io_custom_table_provider>`
217+
interface described in the {ref}`Custom Table Provider <io_custom_table_provider>`
218218
section. This is an advanced topic, but a
219219
[user example](https://github.com/apache/datafusion-python/tree/main/examples/datafusion-ffi-example)
220220
is provided in the DataFusion repository.

docs/source/user-guide/distributing-work.md renamed to docs/source/user-guide/distributing-work/expressions.md

Lines changed: 20 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -17,24 +17,19 @@
1717
under the License.
1818
-->
1919

20-
# Distributing work
20+
(distributed_expressions)=
21+
22+
# Shipping expressions to workers
2123

2224
DataFusion supports splitting work across processes by shipping
2325
serialized expressions to workers: the driver builds an
2426
{py:class}`~datafusion.Expr`, each worker evaluates it against its
2527
own slice of data. This pattern suits embarrassingly-parallel
26-
workloads where the driver decides partitioning up front.
27-
28-
Query-level distribution — where the runtime partitions a single
29-
logical or physical plan across worker nodes — is in progress
30-
upstream via [datafusion-distributed](https://github.com/apache/datafusion-distributed) and [Apache
31-
Ballista](https://github.com/apache/datafusion-ballista). Both
32-
have short sections at the end of this page; integration details
33-
will land as those projects become usable from datafusion-python.
34-
35-
## Expression-level distribution
28+
workloads where the driver decides partitioning up front. If you
29+
would rather have a library partition your query plan for you, see
30+
{ref}`distributed_query_engines` instead.
3631

37-
DataFusion expressions support distribution directly: pass one to a
32+
Expressions support this directly: pass one to a
3833
worker process and Python's standard
3934
[pickle](https://docs.python.org/3/library/pickle.html) machinery
4035
serializes it transparently — the same machinery
@@ -43,7 +38,7 @@ similar libraries already use to ship function arguments. Python UDFs
4338
— scalar, aggregate, and window — travel inside the serialized
4439
expression; the receiver does not need to pre-register them.
4540

46-
### Basic worker-pool example
41+
## Basic worker-pool example
4742

4843
Define a worker function that takes the expression plus a batch and
4944
returns the evaluated result:
@@ -92,7 +87,7 @@ see [Safe importing of main module](https://docs.python.org/3/library/multiproce
9287
in the Python docs.
9388
:::
9489

95-
### What travels with the expression
90+
## What travels with the expression
9691

9792
- **Built-in functions** (`abs`, `length`, arithmetic, comparisons,
9893
etc.) — fully portable. Worker needs nothing pre-registered.
@@ -113,7 +108,7 @@ in the Python docs.
113108

114109
(distributed_udf_portability)=
115110

116-
### Portability requirements for inline Python UDFs
111+
## Portability requirements for inline Python UDFs
117112

118113
Inline Python UDFs ride on [cloudpickle](https://github.com/cloudpipe/cloudpickle), which imposes two
119114
requirements on the worker environment:
@@ -133,7 +128,7 @@ requirements on the worker environment:
133128
Self-contained UDFs (no imports beyond what the worker already has,
134129
e.g. `pyarrow`) avoid this entirely.
135130

136-
### Registering shared UDFs on workers
131+
## Registering shared UDFs on workers
137132

138133
When an expression references an FFI capsule UDF (or any UDF the
139134
worker must resolve from its registered functions), set up the
@@ -164,7 +159,7 @@ fine for expressions that only reference built-ins and Python UDFs,
164159
but FFI-capsule-backed registrations must be installed on the global
165160
context to resolve.
166161

167-
### Python 3.14 default change
162+
## Python 3.14 default change
168163

169164
Python 3.14 changed the Linux default start method for
170165
{py:mod}`multiprocessing` from `fork` to `forkserver` (macOS has
@@ -174,7 +169,7 @@ workers via copy-on-write; with `forkserver` and `spawn` it is
174169
not. The {py:func}`~datafusion.ipc.set_worker_ctx` pattern works on
175170
every start method — prefer it over relying on inherited state.
176171

177-
### Practical considerations
172+
## Practical considerations
178173

179174
- **Serialized size scales with what travels inline.** A serialized
180175
expression of just built-ins is small (tens of bytes). An
@@ -189,7 +184,7 @@ every start method — prefer it over relying on inherited state.
189184
the captured state is large, mutable, or not portable to the
190185
worker's environment. See {ref}`Portability requirements for inline Python UDFs <distributed_udf_portability>` for the Python-version and imported-module rules.
191186

192-
### Disabling Python UDF inlining
187+
## Disabling Python UDF inlining
193188

194189
For a stricter wire format, call
195190
{py:meth}`SessionContext.with_python_udf_inlining(enabled=False)
@@ -241,7 +236,7 @@ threat model.
241236

242237
(distributed_expr_security)=
243238

244-
### Security
239+
## Security
245240

246241
:::{warning}
247242
Reconstructing an expression containing a Python UDF executes
@@ -255,7 +250,9 @@ functions and pre-registered Rust-side UDFs, and avoid
255250
{py:func}`pickle.loads` on externally supplied bytes entirely.
256251
:::
257252

258-
### Reference: session context slots
253+
(session_context_slots)=
254+
255+
## Reference: session context slots
259256

260257
There is only one type — {py:class}`SessionContext`. It can occupy
261258
up to four *slots* in a running program:
@@ -297,32 +294,10 @@ Sharp edges:
297294
- The inlining toggle is per-context state, not a global switch.
298295
Two contexts with different toggles can coexist in one process.
299296

300-
## Query-level distribution via datafusion-distributed
301-
302-
🚧 *Work in progress upstream — not yet usable from datafusion-python.*
303-
304-
[datafusion-distributed](https://github.com/apache/datafusion-distributed)
305-
splits a single physical plan into stages and runs each stage on a
306-
different worker node. The driver writes a SQL or DataFrame query
307-
once; the runtime handles partitioning, shuffles, and reassembly.
308-
309-
A datafusion-python integration is in development. This section will
310-
document the integration once it lands. In the meantime, the
311-
expression-level approach above covers most use cases that do not
312-
require automatic plan partitioning.
313-
314-
## Query-level distribution via Apache Ballista
315-
316-
🚧 *Work in progress upstream — not yet usable from datafusion-python.*
317-
318-
[Apache Ballista](https://github.com/apache/datafusion-ballista)
319-
provides distributed query execution on top of DataFusion with a
320-
scheduler / executor model better suited to long-lived cluster
321-
deployments. A datafusion-python integration is on the roadmap; this
322-
section will fill in once the integration is usable.
323-
324297
## See also
325298

299+
- {ref}`distributed_query_engines` — the other road: let a library
300+
partition the plan for you.
326301
- {py:mod}`datafusion.ipc` — worker context API.
327302
- `examples/multiprocessing_pickle_expr.py` — runnable
328303
`multiprocessing.Pool` example that ships a different parametric
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
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+
(distributing_work)=
21+
22+
# Distributing work
23+
24+
A single {py:class}`~datafusion.SessionContext` already uses every
25+
core on the machine — DataFusion partitions and parallelizes within
26+
a process without being asked. See
27+
{doc}`../configuration` for tuning that.
28+
29+
This section is about the step after that: getting work onto more
30+
than one process or more than one machine. There are two roads, and
31+
they suit different problems.
32+
33+
## Pick your road
34+
35+
**You decide the partitioning → ship expressions.**
36+
37+
You already know how the data divides — one file per worker, one
38+
customer per worker, one parameter setting per worker. You build an
39+
{py:class}`~datafusion.Expr` in the driver and hand a copy to each
40+
worker along with its slice. Standard Python `pickle` moves it, so
41+
{py:mod}`multiprocessing`, Ray, and anything else that ships function
42+
arguments works with no extra machinery.
43+
44+
Best for embarrassingly-parallel work: parameter sweeps, per-file
45+
transforms, scoring batches. Available today.
46+
47+
→ {ref}`distributed_expressions`
48+
49+
**A library decides the partitioning → install a query engine.**
50+
51+
You write one ordinary SQL or DataFrame query against a table too
52+
large for one machine, and an engine library splits the plan into
53+
stages, runs them on its workers, and reassembles the result. You do
54+
not partition anything and your queries do not change.
55+
56+
Best for large-scale analytical queries — joins and aggregations over
57+
data that does not fit on one node. Being built upstream; not yet
58+
usable from datafusion-python.
59+
60+
→ {ref}`distributed_query_engines`
61+
62+
## Choosing between them
63+
64+
The two are not competing implementations of one feature. The
65+
question is who owns the partitioning decision.
66+
67+
If you can state the partitioning in one line — "one worker per input
68+
file" — the expression road is simpler, has no cluster to operate,
69+
and works now. If stating it requires knowing how a join will
70+
shuffle, that decision belongs to a query planner, which is what an
71+
engine library provides.
72+
73+
They also compose. An engine handles the query; expressions handle
74+
whatever you want to fan out around it.
75+
76+
```{toctree}
77+
:maxdepth: 2
78+
79+
expressions
80+
query-engines
81+
```
Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
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+
(distributed_query_engines)=
21+
22+
# Distributed query engines
23+
24+
A distributed query engine takes the query you already wrote and runs
25+
it across several machines for you. You do not partition anything by
26+
hand and you do not change your queries — the engine installs itself
27+
on your session, rewrites the plan into stages, and executes those
28+
stages on its own workers.
29+
30+
This is the counterpart to {ref}`distributed_expressions`, where
31+
*you* decide the partitioning and ship one expression per slice of
32+
data. Choose an engine when you want a single query spread across a
33+
cluster; choose expressions when you already know how to split the
34+
work and only need parallelism.
35+
36+
## How an engine attaches to your session
37+
38+
An engine library ships an object you hand to
39+
{py:meth}`~datafusion.SessionContext.with_extensions`:
40+
41+
```python
42+
from datafusion import SessionContext
43+
import my_engine
44+
45+
ctx = SessionContext().with_extensions(my_engine.Extension("scheduler:50050"))
46+
47+
ctx.register_table("events", my_engine.TableProvider("s3://bucket/events"))
48+
ctx.sql("SELECT country, count(*) FROM events GROUP BY country").show()
49+
```
50+
51+
The `sql` call is unchanged from a single-process program. What
52+
changed is underneath it: installing the engine gave the session a
53+
query planner of the engine's own, and that planner is what turns
54+
your plan into distributed stages.
55+
56+
Two consequences worth knowing:
57+
58+
- **The engine must be installed before you run the query**, not
59+
before you register tables. Registration order does not matter;
60+
`with_extensions` is a session-level setup step.
61+
- **The engine has to be able to reach your tables and functions.**
62+
It ships the plan to its workers, so anything the plan references
63+
has to be reconstructible there. Table providers from the engine's
64+
own library always are. A Python UDF may or may not be — the engine
65+
library documents what it supports, and
66+
{ref}`distributed_udf_portability` describes the constraints that
67+
apply to Python callables crossing a process boundary in general.
68+
69+
If you install more than one library, pass them in one
70+
`with_extensions` call so they can see each other. See
71+
{ref}`user_guide_extensions` for the details of installing extension
72+
libraries, and {ref}`ffi` if you want to write an engine yourself.
73+
74+
## Available engines
75+
76+
Query-level distribution is being built upstream. Neither project
77+
below is usable from datafusion-python yet; both sections will fill
78+
in as the integrations land.
79+
80+
### datafusion-distributed
81+
82+
🚧 *Work in progress upstream — not yet usable from datafusion-python.*
83+
84+
[datafusion-distributed](https://github.com/apache/datafusion-distributed)
85+
splits a single physical plan into stages and runs each stage on a
86+
different worker node. The driver writes a SQL or DataFrame query
87+
once; the runtime handles partitioning, shuffles, and reassembly.
88+
89+
A datafusion-python integration is in development. In the meantime,
90+
{ref}`distributed_expressions` covers most use cases that do not
91+
require automatic plan partitioning.
92+
93+
### Apache Ballista
94+
95+
🚧 *Work in progress upstream — not yet usable from datafusion-python.*
96+
97+
[Apache Ballista](https://github.com/apache/datafusion-ballista)
98+
provides distributed query execution on top of DataFusion with a
99+
scheduler / executor model better suited to long-lived cluster
100+
deployments. A datafusion-python integration is on the roadmap.

0 commit comments

Comments
 (0)