timsaucer opened a new pull request, #1721:
URL: https://github.com/apache/datafusion-python/pull/1721

   # Which issue does this PR close?
   
   Closes #1719.
   
   **Stacked on #1720**, which is stacked on #1679. This PR's base is 
`feat/plan-partitioning-and-errors`, so the diff shown here is only the example.
   
   # Rationale for this change
   
   #1678 made extension codecs composable and #1679 added `with_extensions`, so 
a library can ship codecs and a planner as one atomic bundle. Both are well 
covered by unit tests. What the repository has never had is an example of the 
thing that machinery is *for*: several independently compiled libraries 
cooperating on one query whose plan actually leaves the process.
   
   The second goal was to find out what breaks. That turned out to be the more 
valuable half — see the findings below, most of which were found by building 
something the wrong way rather than by reading code.
   
   # What changes are included in this PR?
   
   Three new crates under `examples/distributed/`, and the documentation the 
exercise showed was missing.
   
   **`dfx_udfs`** — a scalar function, an aggregate, a window function, and the 
two name-only codecs that make them portable. It deliberately exposes **no** 
`__datafusion_session_components__`, so it is installed by hand: two codec 
installs and three registrations in place of one call. That is not a strawman. 
`SessionExtensionComponents` carries codec fields only, so a library 
contributing *functions* has nowhere to put them today, and mixed setups are 
the normal case. A test asserts the shape rather than describing it — 
`with_extensions` rejects the object, naming the hook it lacks.
   
   Three worker tests pin what a codec buys, and they disagree usefully: codec 
installed and nothing registered works (`decode_calls == 1`); functions 
registered and no codec works (`decode_calls == 0`); neither fails naming 
`dfx_net_revenue`. So installing the codec is an *alternative* to registering 
the functions. The middle case is the trap — on the driver, where functions are 
always registered, the registry answers first and a broken codec looks fine.
   
   **`dfx_storage`** — a Parquet-directory table provider reporting one output 
partition per file, its own leaf scan node, and the repository's first codec 
that encodes durable metadata. Wire format is `DFXSTOR1 | json_len:u32 | json | 
arrow ipc schema`: JSON for the scalar fields because someone debugging a 
worker can read it, Arrow IPC for the schema because it is the only encoding 
that round-trips every Arrow type. Ten tests, the load-bearing one being a 
separate interpreter that builds its own session, checks the codec id it 
expects is installed, decodes a plan written elsewhere, and executes all three 
partitions. A token registry cannot pass that test, which is why it was written 
first.
   
   **`dfx_engine`** — a toy distributed engine in the two halves a real one 
has. Rust owns the query planner, the stage node, its codec, and a config 
extension; Python owns the session factory, the driver, and the worker entry 
point. It splits at the partial aggregate, because DataFusion has already split 
there for its own reasons and the partial passes are independent by 
construction.
   
   One node does both halves of the shuffle: `execute(i)` reads the file for 
partition `i` if it exists and otherwise computes its child and writes it on 
the way past. So the same node is the thing a worker runs and the thing the 
driver reads, nothing has to rewrite the plan in between, and a query run with 
no workers still gets the right answer.
   
   `session.py` is the piece the whole example exists to motivate, and is worth 
reading first.
   
   **Seventeen integration tests** plus `run_tpch.py`. Verified end to end: 
400k rows of real TPC-H `lineitem` across four worker processes — using the 
custom provider, its custom scan node, the engine's stage node, and both Rust 
functions — agreeing with the single-process result to 1e-6 relative.
   
   ## What the exercise found
   
   Recorded in #1719 as G1–G13. The ones that changed the design:
   
   **A stock node that crosses FFI cannot be serialized on the planner return 
path.** `FFI_QueryPlanner` returns protobuf rather than a plan handle, so every 
query serializes the plan. Physical planning applies 
`session.physical_optimizers()`, which over FFI are the *host's* rules, so 
`EnsureCooperative` hands the library back a `ForeignExecutionPlan` wrapping 
the host's `CooperativeExec` — which has no reachable `try_to_proto`. A 
perfectly serializable node becomes unserializable by having crossed a 
boundary, and is opaque to `downcast_ref` besides, so a planner that means to 
rewrite the plan cannot see what it was given. The engine plans against a 
session that owns the stock rule list locally, which fixes both; this was 
spiked before anything else was built.
   
   **The greedy codec claim in `datafusion-ffi-example` is a symptom of that, 
not sloppiness.** Its physical codec claims 
`node.is::<ForeignExecutionPlan>()`, which the extension guide tells authors 
never to do. I tried narrowing it and reverted: **31 of the 51 tests** in the 
query-planner example fail, every one on the `CooperativeExec` node above. 
#1720 documents why the arm exists rather than removing it.
   
   **A table provider needs a *logical* codec, not just a physical one.** A 
provider library reasonably concludes otherwise, since its scan is a physical 
node. But an installed query planner receives the *logical* plan, which holds 
tables as `Arc<dyn TableProvider>`, so the session fails while planning with 
"Error serializing custom table" — before anything is distributed. Found by 
shipping `dfx_storage` without one.
   
   **cloudpickle captures a module attribute as the module.** I expected 
`pa.compute` in a UDF to fail on a worker; it does not, because cloudpickle 
resolves the attribute and stores an import of `pyarrow.compute`. The real trap 
is a *function* with a resolvable `module.qualname`: the same callable is ~1 kB 
pickled from `__main__` and ~30 bytes from a package, because the second is a 
pointer. Both halves are pinned as tests.
   
   **An empty config value is not an absent one.** A registered config 
extension always has an entry, so an unset `shuffle_dir` arrives as `Some("")`. 
The planner treated that as configured and wrote shuffle files relative to the 
process's working directory, where later queries read another query's leftovers 
back out of them.
   
   ## The plan changed twice, on evidence
   
   I had planned to **retire** `datafusion-ffi-query-planner-example`. That is 
off: ~15 of its tests cover planner *layering*, and a stage-splitting planner 
structurally cannot delegate to a `fallback` — delegating hands planning back 
to the host and returns opaque nodes it can neither serialize nor split. 
Deleting the crate would delete real coverage of the most subtle part of 
#1679's contract. There are three example trees now, and the guide says which 
is which: `examples/distributed` is the worked example, 
`datafusion-ffi-example` is the capsule-protocol test bed, 
`datafusion-ffi-query-planner-example` is the planner-composition test bed.
   
   I had also planned to narrow the greedy codec claim, covered above.
   
   # Are there any user-facing changes?
   
   No API changes. New files ship in the repository but not in the `datafusion` 
wheel.
   
   Documentation:
   
   - `user-guide/distributing-work/query-engines.md` — replaces a 🚧 placeholder 
with the **worker-parity checklist**: nine things that have to match between a 
driver and a worker, and why none of it can be automated (there is no way to 
snapshot a `SessionContext`, and `df_settings` lists keys that cannot be set 
back).
   - `extension-guide/query-planners.md` — a new section on planning against 
your own optimizer rules, with the error you get if you do not.
   - `extension-guide/codecs.md` — a new section on why a provider needs a 
logical codec, and `extension_codec_durable_metadata` now points at a codec 
that actually does it, which it previously could not.
   - `user-guide/distributing-work/expressions.md` — sharpens the 
UDF-portability rule from "imports are by reference" to what actually decides 
it.
   - `extension-guide/checklist.md` — two items: ship a logical codec with a 
provider; decode in a different process in at least one test.
   - Fixes `examples/README.md`, which linked three files that do not exist, 
and the planner example's README, which claimed its planner owns no 
serializable types — untrue since `DistributedExec` was added, and the claim 
mattered.
   
   CI builds three more wheels and runs three more test suites, gated to `abi3` 
like the existing ones.
   
   Verified: `pytest python/` (1443 passed, 12 skipped) and all five example 
suites (55, 51, 10, 11, 17). `pre-commit run --all-files` clean except 
`actionlint`, which needs Docker and could not run locally; the workflow files 
parse as YAML.
   
   🤖 Generated with [Claude Code](https://claude.com/claude-code)
   


-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: [email protected]

For queries about this service, please contact Infrastructure at:
[email protected]


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to