blob: 78b79e82f649536e4645483d1526ff679211e8c8 [file] [view]
<!---
Licensed to the Apache Software Foundation (ASF) under one
or more contributor license agreements. See the NOTICE file
distributed with this work for additional information
regarding copyright ownership. The ASF licenses this file
to you under the Apache License, Version 2.0 (the
"License"); you may not use this file except in compliance
with the License. You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing,
software distributed under the License is distributed on an
"AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
KIND, either express or implied. See the License for the
specific language governing permissions and limitations
under the License.
-->
# Three libraries, one distributed query
A worked example of what `datafusion-python`'s extension protocol is *for*:
several independently compiled libraries, none of which knows about the
others, cooperating on a single query whose work runs in separate operating
system processes.
Everything here is real. The workers are separate interpreters. The plan they
run was serialized by the driver and decoded by them. If you break the
serialization, the tests fail.
## The three libraries
| Crate | Owns | Installed with |
| --- | --- | --- |
| `udf-library` (`dfx_udfs`) | a scalar function, an aggregate, a window function | **by hand** — `register_udf` plus two `with_*_extension_codec` calls |
| `storage-library` (`dfx_storage`) | a Parquet table provider and its own scan node | `with_extensions` |
| `engine-library` (`dfx_engine`) | a query planner, a stage node, and the driver/worker machinery | `with_extensions` |
One of them is deliberately old-fashioned. `dfx_udfs` exposes no
`__datafusion_session_components__`, so it cannot be installed as a bundle and
its caller has to do five things in the right order instead of one. That is
not a strawman: `SessionExtensionComponents` carries codec fields only, so a
library that contributes *functions* has nowhere to put them today. Mixed
setups are the normal case, and this example shows what one costs.
## Running it
```console
$ cd examples/distributed/engine-library
$ uv venv
$ uv pip install pytest maturin ../../.. ../storage-library ../udf-library
$ uv run maturin develop
$ uv run pytest
```
`../../..` is the repository root. The example needs the `datafusion` built
from this checkout rather than a released wheel, because an extension library
and its host have to agree on the FFI ABI.
The other two libraries are installed for a reason beyond running their own
tests: `dfx_engine.session` imports both, so `import dfx_engine` fails without
them. That first `uv pip install` builds three Rust libraries from source and
is not quick; `maturin develop` then builds the fourth, this one, in place.
Each library's tests can be run from its own directory the same way, without
the sibling installs. `storage-library` and `udf-library` need only
`pytest maturin ../../..`.
Against real TPC-H data, which the script generates for itself:
```console
$ uv pip install tpchgen-cli
$ uv run python ../run_tpch.py --partitions 4
```
`tpchgen-cli` shards natively, so `--partitions 4` asks it for four Parquet
files and tells the engine to use four partitions — the two are the same
number because a distributed engine can only spread work as widely as the data
is split. `--scale` sets how much data; it defaults to a tenth of TPC-H scale
factor 1, and `--scale 1` is the full ~6M row `lineitem`.
## What actually happens
The engine's planner splits the plan at the partial aggregate, which is where
DataFusion has already split it for its own reasons: a `GROUP BY` becomes a
partial pass per input partition and a final pass that merges them, and the
partial passes are independent by construction.
```
SortPreservingMergeExec
ProjectionExec
AggregateExec: mode=FinalPartitioned <- driver merges
RepartitionExec: Hash([l_returnflag], 2)
FFI_ExecutionPlan: ShuffleStageExec <- shipped to workers
AggregateExec: mode=Partial <- one worker per partition
FFI_ExecutionPlan: PartitionedParquetExec
```
The driver serializes the `ShuffleStageExec` subtree, starts one worker per
partition, and waits. Each worker rebuilds an equivalent session, decodes the
plan, runs *its* partition, and writes the result to an Arrow IPC file. The
driver then runs the whole query itself — and the stage node, finding the
files already there, streams them instead of recomputing.
One node does both halves of that exchange, which is why nothing has to
rewrite the plan in between. It also means a query run with no workers at all
still gets the right answer; it just does the work itself.
The cost of that trick is that the filesystem is the state, and stage ids
restart at 1 for every plan — so a second query pointed at the same shuffle
directory would find the first one's files and read them. The driver refuses
a directory that already holds stage output rather than letting that through:
one shuffle directory per query.
## The four things worth reading
**`engine-library/python/dfx_engine/session.py`** is the point of the whole
example. There is no way to snapshot a `SessionContext` and restore it
elsewhere, so worker parity cannot be automated — it has to be *built the same
way twice*, from data small enough to put in a message. Both the driver and
every worker call one `build_session`. Anything a query depends on that is not
in the `SessionSpec` is a bug waiting for a worker to find it.
**`storage-library/src/codec.rs`** is the repository's only codec that encodes
durable metadata. The others park the live object in a process-global map and
encode an integer token, which is fine for making Rust type identity
observable in a test and useless the moment the bytes leave the process. This
one writes the file paths, the projection, and the schema, so the same bytes
decode twice, decode on ten workers, and decode tomorrow.
**`udf-library/python/tests/_test_udfs.py`** shows that installing a
library's codec is an *alternative* to registering its functions, not an
addition. Three workers, three configurations:
| worker has | result |
| --- | --- |
| the codec, no registrations | works; the codec rebuilds each function from its name |
| the registrations, no codec | works; the registry answers first and the codec is never consulted |
| neither | fails, naming `dfx_net_revenue` |
The middle row is the trap. On the driver, where the functions are always
registered, a broken or missing codec looks completely fine.
**`engine-library/python/tests/_test_three_libraries.py`** runs the queries,
and pins the failure modes next to the successes — including a Python UDF that
works on the driver and fails on the worker.
## Things this example is not
It writes shuffle results to local files, so "distributed" means several
processes on one machine. Adding a network is a transport change and would not
alter anything above it.
It holds one partition of results in memory before writing, because an Arrow
IPC stream needs its schema up front. A production engine would stream to the
file and track completion separately.
It has one *level* of stages. A query with an aggregate in each branch of a
union gets one stage per branch, and they run side by side — but no stage ever
feeds another. A real engine chains them, and the interesting problems —
scheduling, retries, straggler handling, memory limits — all live in the part
this example replaces with `subprocess.Popen` and a `for` loop.
It is slower than running the query in one process. Four processes on one
laptop cannot beat one process that skips a round trip through Arrow IPC
files. The comparison the tests make is *agreement*, not speed.
## Further reading
- [Distributed query engines](https://datafusion.apache.org/python/user-guide/distributing-work/query-engines.html)
— using an engine, and the checklist for what a worker has to reproduce.
- [Extension Guide](https://datafusion.apache.org/python/extension-guide/index.html)
— writing a library like these.
- [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)
— what a codec should put on the wire, and why.