Skip to content

Commit 467fef1

Browse files
timsaucerclaude
andcommitted
Document what a worker has to reproduce, and three findings that cost time
The documentation half of #1719. Each section here exists because building the example ran into the thing it describes. **`distributing-work/query-engines.md` gains the worker-parity checklist.** This was the gap I most expected to find and did: there is no way to snapshot a SessionContext and restore it elsewhere — SessionConfig is write-only from Python, and `df_settings` is readable but lists `datafusion.runtime.*` keys with no namespace to set them back into — so parity has to be built the same way twice, and nothing said what "the same" covers. Nine items, each of which the example gets wrong somewhere on purpose to show the failure. **`extension-guide/query-planners.md` gains "plan against your own optimizer rules".** A planner returns protobuf rather than a plan handle, so every query serializes its output. Physical planning applies `session.physical_optimizers()`, which over FFI are the *host's* rules, so each one hands the library back a `ForeignExecutionPlan` — and a stock `CooperativeExec` wrapped that way has no reachable `try_to_proto`. A perfectly serializable node becomes unserializable by having crossed a boundary. It is also opaque to `downcast_ref`, so a planner that means to rewrite the plan cannot see what it was given. Wrapping the session with a locally-owned rule list fixes both, and the section says when to do that instead of delegating to a fallback: a planner does one or the other. **`extension-guide/codecs.md` gains "a table provider needs a logical codec".** A provider library reasonably concludes a physical codec is enough, since its scan is a physical node. It is enough until someone installs a query planner, which receives the logical plan — holding the provider as an `Arc<dyn TableProvider>` — and then the session fails while planning with "Error serializing custom table". Found by shipping the storage library without one. `extension_codec_durable_metadata` also now points at a codec that does encode durable metadata, which it previously could not: it described what to do, said the in-repo examples deliberately do not do it, and left the reader with no implementation to read. **`distributing-work/expressions.md` sharpens the UDF-portability rule.** The existing text said imports are captured by reference, which is true but not the useful distinction. What decides it is whether cloudpickle can resolve the name to an importable `module.qualname`: a module attribute like `pyarrow.compute` is stored as an import of that submodule and works, while a *function* in one of your modules becomes a pointer and requires your code installed on the worker. The same callable is around 1 kB from `__main__` and around 30 bytes from a package, so moving a helper into one silently changes what ships. Now a table, with the failure signature: a bare `ModuleNotFoundError` raised during plan decode, naming neither UDFs nor serialization. Also: a README for the example that says what it is not, and two checklist items — ship a logical codec with a provider, and decode in a different process in at least one test, since a token-registry codec passes every in-process round trip. Every `{ref}` added here resolves; checked by extracting defined labels and references across the docs tree. One dangling reference exists in `aggregations.md` (`spark-functions`) and predates this work. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 9339ac8 commit 467fef1

6 files changed

Lines changed: 326 additions & 21 deletions

File tree

docs/source/extension-guide/checklist.md

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,14 @@ publish. Each links to the page that explains it.
5656
- [ ] **You round-trip a plan in a test and assert *your* codec did the work.**
5757
Both being installed does not mean your node reached you.
5858
→ {ref}`extension_codec_order`
59+
- [ ] **You ship a logical codec too, if you contribute a table provider.** A
60+
physical codec is not enough: an installed query planner receives the
61+
logical plan, which holds your provider, and the session fails to plan
62+
without one. → {ref}`extension_codec_provider_logical`
63+
- [ ] **You decode in a *different process* in at least one test.** A codec
64+
that parks the object in a process-global map passes every in-process
65+
round trip and fails the first real one.
66+
→ {ref}`extension_codec_durable_metadata`
5967

6068
## Bundles and planners
6169

@@ -94,6 +102,8 @@ publish. Each links to the page that explains it.
94102
process-local token. The examples in this repository use tokens to make
95103
ownership observable; that is a demonstration, not a pattern.
96104
→ {ref}`extension_codec_durable_metadata`
97-
- [ ] **You have integration tests across a real FFI boundary.** The two
98-
example crates in this repository are the pattern: build the cdylib,
99-
install the wheel, then exercise it from Python.
105+
- [ ] **You have integration tests across a real FFI boundary.** The example
106+
trees in this repository are the pattern: build the cdylib, install the
107+
wheel, then exercise it from Python. `examples/distributed` additionally
108+
spawns worker processes, which is the only way to catch a codec that
109+
only works in the process that wrote it.

docs/source/extension-guide/codecs.md

Lines changed: 41 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -58,15 +58,47 @@ Your payload has to be enough to rebuild the object somewhere your process is
5858
not. Write the metadata a fresh instance can be constructed from — a path, a
5959
connection string, a schema, the options the object was created with.
6060

61-
The example codecs in this repository do not do this, and it is worth knowing
62-
before copying them. They keep a process-local `HashMap` of live providers and
63-
encode an integer token into it: encoding inserts, decoding removes. That makes
64-
Rust type identity observable across three separately loaded libraries in one
65-
test, which is what the examples exist to show. It also means a decode consumes
66-
its token, so the same bytes cannot be decoded twice, one encoded plan cannot
67-
fan out to several readers, and a plan that never reaches a decoder keeps its
68-
provider alive for the life of the process. A real codec has none of those
69-
properties because it does not park the object anywhere.
61+
Two of the example codecs in this repository do not do this, and it is worth
62+
knowing before copying them. `datafusion-ffi-example` and
63+
`datafusion-ffi-query-planner-example` keep a process-local `HashMap` of live
64+
providers and encode an integer token into it: encoding inserts, decoding
65+
removes. That makes Rust type identity observable across three separately
66+
loaded libraries in one test, which is what those examples exist to show. It
67+
also means a decode consumes its token, so the same bytes cannot be decoded
68+
twice, one encoded plan cannot fan out to several readers, and a plan that
69+
never reaches a decoder keeps its provider alive for the life of the process.
70+
A real codec has none of those properties because it does not park the object
71+
anywhere.
72+
73+
For one that does it properly, read
74+
[`examples/distributed/storage-library`](https://github.com/apache/datafusion-python/tree/main/examples/distributed/storage-library).
75+
Its payload is the file paths, the projection, the row limit, and the schema —
76+
enough to rebuild the scan from nothing — and its tests decode a plan in a
77+
separate interpreter that never registered the table.
78+
79+
(extension_codec_provider_logical)=
80+
81+
## A table provider needs a *logical* codec
82+
83+
A provider library can reasonably conclude it needs only a physical codec: its
84+
scan is a physical node, so that is where its own type appears. That holds
85+
right up until someone installs a query planner.
86+
87+
An FFI query planner is handed the **logical** plan, as protobuf. A logical
88+
plan holds its tables as `Arc<dyn TableProvider>`, and the default codec's
89+
`try_encode_table_provider` is unimplemented. So a session with your provider
90+
and any engine installed fails while planning, before anything is executed,
91+
with:
92+
93+
```text
94+
Error serializing custom table ... caused by
95+
Execution error: No installed extension codec handled a table provider
96+
```
97+
98+
Implement `try_encode_table_provider` and `try_decode_table_provider`, and
99+
contribute the logical codec alongside the physical one. The payload can be
100+
small — the storage library writes just the directory, because everything else
101+
it holds is read back from there — but it has to exist.
70102

71103
(extension_codec_ids)=
72104

docs/source/extension-guide/query-planners.md

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,53 @@ against the codecs of the session that will run the query.
3838
`MyQueryPlanner` in [`datafusion-ffi-query-planner-example`] is the worked
3939
implementation.
4040

41+
(planner_host_optimizer_rules)=
42+
43+
## Plan against your own optimizer rules
44+
45+
Your planner returns its plan as **protobuf**, not as a handle. Every query
46+
therefore serializes what you produce, and anything in it that cannot be
47+
encoded is your problem rather than a distant one.
48+
49+
That matters because of where physical optimization runs. Physical planning
50+
applies `session.physical_optimizers()`, and when the session arrived over FFI
51+
those rules are the *host's* — so each one runs back across the boundary and
52+
hands you a `ForeignExecutionPlan` wrapping the result. `EnsureCooperative` is
53+
on by default and will do exactly this. A stock `CooperativeExec` produced that
54+
way has no reachable `try_to_proto`, so a node that is perfectly serializable
55+
in the process that made it becomes unserializable in yours:
56+
57+
```text
58+
Internal error: Unsupported plan and extension codec failed with
59+
[This feature is not implemented: PhysicalExtensionCodec is not provided].
60+
Plan: ForeignExecutionPlan { name: "CooperativeExec", ... }
61+
```
62+
63+
A foreign node is also opaque to `downcast_ref`, so a planner that means to
64+
*rewrite* the plan — inserting stages, say — cannot inspect what it was given.
65+
66+
Both problems go away if the rules run on your side. Wrap the session you were
67+
handed in one that delegates everything except `physical_optimizers()`, and
68+
return the stock rule set from there:
69+
70+
```rust
71+
let local = LocalOptimizerSession::new(session); // owns PhysicalOptimizer::default().rules
72+
DefaultPhysicalPlanner::default()
73+
.create_physical_plan(logical_plan, &local)
74+
.await?
75+
```
76+
77+
`LocalOptimizerSession` in
78+
[`examples/distributed/engine-library`](https://github.com/apache/datafusion-python/tree/main/examples/distributed/engine-library)
79+
is about twenty delegating methods and one override.
80+
81+
Delegating to a `fallback` avoids the problem differently, by not planning at
82+
all: the plan comes back from whoever you delegated to, already concrete. That
83+
is the right choice for a planner that only layers behaviour on another, and
84+
the wrong one for a planner that needs to rewrite the result — you cannot
85+
rewrite a subtree you hold an opaque handle to. A planner does one or the
86+
other.
87+
4188
## One planner per session
4289

4390
A session holds exactly one query planner. Calling `set_query_planner` again

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

Lines changed: 24 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -119,14 +119,30 @@ requirements on the worker environment:
119119
stamps the sender's `(major, minor)`; mismatches raise a clear
120120
error naming both versions. Align the Python version on driver and
121121
workers.
122-
- **Imported modules must be importable on the worker.** cloudpickle
123-
captures the callable *by value* (bytecode and closure cells travel
124-
whole), but names resolved through `import` are captured *by
125-
reference* — module path only. A UDF doing
126-
`from mylib import transform` requires `mylib` installed on the
127-
worker. Same applies to bound methods of imported classes.
128-
Self-contained UDFs (no imports beyond what the worker already has,
129-
e.g. `pyarrow`) avoid this entirely.
122+
- **Anything the callable names must be reachable on the worker.**
123+
cloudpickle captures the function's own body *by value* — bytecode and
124+
closure cells travel whole — but every global it refers to is captured *by
125+
reference* if cloudpickle can resolve it to an importable
126+
`module.qualname`. The worker then imports it by that path.
127+
128+
So the rule is not "imports are bad", it is **whether the name has an
129+
importable home**:
130+
131+
| The callable refers to | Travels as | Worker needs |
132+
| --- | --- | --- |
133+
| a nested or `__main__`-level function | the function itself | nothing |
134+
| a module, including a submodule like `pyarrow.compute` | an import of that module | the module installed |
135+
| a function in an importable module of yours | a pointer to `yourmod.helper` | **your code installed** |
136+
137+
The third row is the one that surprises people, and the size difference
138+
makes it concrete: one small function is around 1 kB pickled from
139+
`__main__` and around 30 bytes from an importable module, because the
140+
second is only a pointer. Moving a helper out of a script and into a
141+
package silently changes what gets shipped.
142+
143+
It fails on the worker as a bare
144+
`ModuleNotFoundError: No module named 'yourmod'`, raised while the plan is
145+
being decoded, with nothing in the message about UDFs or serialization.
130146

131147
## Registering shared UDFs on workers
132148

docs/source/user-guide/distributing-work/query-engines.md

Lines changed: 54 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -71,11 +71,64 @@ If you install more than one library, pass them in one
7171
{ref}`user_guide_extensions` for the details of installing extension
7272
libraries, and {ref}`ffi` if you want to write an engine yourself.
7373

74+
(distributed_worker_parity)=
75+
76+
## What a worker has to reproduce
77+
78+
An engine ships your plan to a process that has never seen your session. That
79+
process has to be able to rebuild everything the plan refers to, and there is
80+
**no way to snapshot a {py:class}`~datafusion.SessionContext` and restore it
81+
somewhere else**: {py:class}`~datafusion.SessionConfig` is write-only from
82+
Python, and while `information_schema.df_settings` can be read back, it lists
83+
`datafusion.runtime.*` keys that have no configuration namespace to set them
84+
into again.
85+
86+
So parity is not automatic. It is something you build the same way twice, and
87+
these are the things that have to match. Most engines handle several of them
88+
for you — check which.
89+
90+
- **Codec ids.** A plan records which codec wrote each payload, and decoding
91+
routes on that id. Pin ids with `__datafusion_codec_id__` rather than
92+
letting them default to a class's import path, and compare
93+
{py:meth}`~datafusion.SessionContext.physical_extension_codec_ids` on both
94+
sides before shipping anything. See {ref}`extension_codec_ids`.
95+
- **Functions the plan names.** A function resolves either from the receiving
96+
session's registry or from a codec. Either is enough; neither is automatic.
97+
A Python UDF is the exception — it travels inside the plan.
98+
- **Object stores**, registered *before* the plan is decoded rather than
99+
before it is executed. Decoding a Parquet scan resolves its store.
100+
- **Config extensions**, installed before any namespaced key is set. Setting a
101+
key in a namespace that has not been declared is an error, not a no-op.
102+
- **The Python minor version**, if any inline Python UDF is involved.
103+
Cloudpickle payloads are stamped with the sender's version and refuse to
104+
load on another. Launching workers with `sys.executable` makes this true by
105+
construction; a hardcoded `python` does not.
106+
- **The `cloudpickle` version**, which is *not* stamped. Cross-version loading
107+
usually works and is not guaranteed. Pin it.
108+
- **`target_partitions`**, if a worker re-plans anything. Left to default it
109+
follows the core count, so two differently-sized machines disagree.
110+
111+
Two more that are about lifetime rather than configuration:
112+
113+
- **One session per worker, alive for the whole process.** An FFI codec
114+
resolves names against the session captured when its bundle was installed,
115+
and that reference is weak — see {ref}`extension_sessions`.
116+
- **Pass a context to `to_bytes`.** {py:meth}`~datafusion.ExecutionPlan.to_bytes`
117+
takes an optional context, and without one it uses an empty codec chain that
118+
cannot encode any library's nodes. The argument being optional makes this
119+
easy to miss, because the failure only appears once an extension node is in
120+
the plan.
121+
74122
## Available engines
75123

76124
Query-level distribution is being built upstream. Neither project
77125
below is usable from datafusion-python yet; both sections will fill
78-
in as the integrations land.
126+
in as the integrations land. In the meantime the repository contains a
127+
worked example you can read and run:
128+
[`examples/distributed`](https://github.com/apache/datafusion-python/tree/main/examples/distributed)
129+
splits a query across worker processes using three separate extension
130+
libraries, and is written to make each of the requirements above visible —
131+
including the ways they fail.
79132

80133
### datafusion-distributed
81134

examples/distributed/README.md

Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,147 @@
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+
# Three libraries, one distributed query
21+
22+
A worked example of what `datafusion-python`'s extension protocol is *for*:
23+
several independently compiled libraries, none of which knows about the
24+
others, cooperating on a single query whose work runs in separate operating
25+
system processes.
26+
27+
Everything here is real. The workers are separate interpreters. The plan they
28+
run was serialized by the driver and decoded by them. If you break the
29+
serialization, the tests fail.
30+
31+
## The three libraries
32+
33+
| Crate | Owns | Installed with |
34+
| --- | --- | --- |
35+
| `udf-library` (`dfx_udfs`) | a scalar function, an aggregate, a window function | **by hand**`register_udf` plus two `with_*_extension_codec` calls |
36+
| `storage-library` (`dfx_storage`) | a Parquet table provider and its own scan node | `with_extensions` |
37+
| `engine-library` (`dfx_engine`) | a query planner, a stage node, and the driver/worker machinery | `with_extensions` |
38+
39+
One of them is deliberately old-fashioned. `dfx_udfs` exposes no
40+
`__datafusion_session_components__`, so it cannot be installed as a bundle and
41+
its caller has to do five things in the right order instead of one. That is
42+
not a strawman: `SessionExtensionComponents` carries codec fields only, so a
43+
library that contributes *functions* has nowhere to put them today. Mixed
44+
setups are the normal case, and this example shows what one costs.
45+
46+
## Running it
47+
48+
```console
49+
$ cd examples/distributed/engine-library
50+
$ uv venv && uv pip install pytest pyarrow ../.. ../storage-library ../udf-library
51+
$ uv run maturin develop
52+
$ uv run pytest python/tests/_test*.py
53+
```
54+
55+
Against the real TPC-H data — generate it as
56+
[`examples/tpch`](../tpch/README.md) describes, then:
57+
58+
```console
59+
$ uv run python ../run_tpch.py --partitions 4
60+
```
61+
62+
## What actually happens
63+
64+
The engine's planner splits the plan at the partial aggregate, which is where
65+
DataFusion has already split it for its own reasons: a `GROUP BY` becomes a
66+
partial pass per input partition and a final pass that merges them, and the
67+
partial passes are independent by construction.
68+
69+
```
70+
SortPreservingMergeExec
71+
ProjectionExec
72+
AggregateExec: mode=FinalPartitioned <- driver merges
73+
RepartitionExec: Hash([l_returnflag], 2)
74+
FFI_ExecutionPlan: ShuffleStageExec <- shipped to workers
75+
AggregateExec: mode=Partial <- one worker per partition
76+
FFI_ExecutionPlan: PartitionedParquetExec
77+
```
78+
79+
The driver serializes the `ShuffleStageExec` subtree, starts one worker per
80+
partition, and waits. Each worker rebuilds an equivalent session, decodes the
81+
plan, runs *its* partition, and writes the result to an Arrow IPC file. The
82+
driver then runs the whole query itself — and the stage node, finding the
83+
files already there, streams them instead of recomputing.
84+
85+
One node does both halves of that exchange, which is why nothing has to
86+
rewrite the plan in between. It also means a query run with no workers at all
87+
still gets the right answer; it just does the work itself.
88+
89+
## The four things worth reading
90+
91+
**`engine-library/python/dfx_engine/session.py`** is the point of the whole
92+
example. There is no way to snapshot a `SessionContext` and restore it
93+
elsewhere, so worker parity cannot be automated — it has to be *built the same
94+
way twice*, from data small enough to put in a message. Both the driver and
95+
every worker call one `build_session`. Anything a query depends on that is not
96+
in the `SessionSpec` is a bug waiting for a worker to find it.
97+
98+
**`storage-library/src/codec.rs`** is the repository's only codec that encodes
99+
durable metadata. The others park the live object in a process-global map and
100+
encode an integer token, which is fine for making Rust type identity
101+
observable in a test and useless the moment the bytes leave the process. This
102+
one writes the file paths, the projection, and the schema, so the same bytes
103+
decode twice, decode on ten workers, and decode tomorrow.
104+
105+
**`udf-library/python/tests/_test_udfs.py`** shows that installing a
106+
library's codec is an *alternative* to registering its functions, not an
107+
addition. Three workers, three configurations:
108+
109+
| worker has | result |
110+
| --- | --- |
111+
| the codec, no registrations | works; the codec rebuilds each function from its name |
112+
| the registrations, no codec | works; the registry answers first and the codec is never consulted |
113+
| neither | fails, naming `dfx_net_revenue` |
114+
115+
The middle row is the trap. On the driver, where the functions are always
116+
registered, a broken or missing codec looks completely fine.
117+
118+
**`engine-library/python/tests/_test_three_libraries.py`** runs the queries,
119+
and pins the failure modes next to the successes — including a Python UDF that
120+
works on the driver and fails on the worker.
121+
122+
## Things this example is not
123+
124+
It writes shuffle results to local files, so "distributed" means several
125+
processes on one machine. Adding a network is a transport change and would not
126+
alter anything above it.
127+
128+
It holds one partition of results in memory before writing, because an Arrow
129+
IPC stream needs its schema up front. A production engine would stream to the
130+
file and track completion separately.
131+
132+
It has one stage. A real engine chains them, and the interesting problems —
133+
scheduling, retries, straggler handling, memory limits — all live in the part
134+
this example replaces with `subprocess.Popen` and a `for` loop.
135+
136+
It is slower than running the query in one process. Four processes on one
137+
laptop cannot beat one process that skips a round trip through Arrow IPC
138+
files. The comparison the tests make is *agreement*, not speed.
139+
140+
## Further reading
141+
142+
- [Distributed query engines](https://datafusion.apache.org/python/user-guide/distributing-work/query-engines.html)
143+
— using an engine, and the checklist for what a worker has to reproduce.
144+
- [Extension Guide](https://datafusion.apache.org/python/extension-guide/index.html)
145+
— writing a library like these.
146+
- [Encode metadata, not a handle to a live object](https://datafusion.apache.org/python/extension-guide/codecs.html#encode-metadata-not-a-handle-to-a-live-object)
147+
— what a codec should put on the wire, and why.

0 commit comments

Comments
 (0)