|
| 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