Skip to content

Commit 6571d89

Browse files
timsaucerclaude
andcommitted
Add dfx_engine: a toy distributed engine in both halves
Third library for #1719, and the one that makes the other two do something. 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. A real engine needs both, so this crate is a mixed maturin package rather than a pure extension module. The split point is the partial aggregate. DataFusion already breaks a GROUP BY into a partial pass per input partition and a final pass that merges them, so the partial passes are independent by construction and only their output has to come back. Wrapping that subtree in a `ShuffleStageExec` is the whole rewrite. The planner plans against `LocalOptimizerSession`, which borrows the foreign session but owns the stock optimizer rule list. Without it the rules run back across FFI and hand the library `ForeignExecutionPlan` wrappers, which cannot be serialized (that is G1) and cannot be rewritten either — an engine cannot split a subtree it holds only an opaque handle to. This was validated as a spike before any of it was built. 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, and nothing has to rewrite the plan in between. A query with no workers still gets the right answer, having done the work itself. The shuffle directory travels inside the node and therefore inside its encoding, so a worker and a driver cannot disagree about where results go. `session.py` is the piece the whole example exists to motivate. 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 that have no namespace to set them back into — so worker parity cannot be automated. It has to be built the same way twice from data small enough to put in a message, which is what `SessionSpec` is. Both sides call `build_session`; anything a query depends on that is not in the spec is a bug waiting for a worker to find it. Two findings this turned up, both now documented in the code: `dfx_storage` needed a *logical* codec, not just a physical one. Its scan node is physical, so a physical codec looks sufficient — but installing any FFI query planner means the session hands that planner the logical plan as protobuf, and a logical plan holds its tables as `Arc<dyn TableProvider>`. With no `try_encode_table_provider` the session fails at `execution_plan()` with "Error serializing custom table", before anything is distributed. The payload is the directory, since everything else the provider holds is read back from it. A foreign node does not print its own name. The host shows `FFI_ExecutionPlan: ShuffleStageExec, number_of_children=1`, so the driver's tree walk has to match on containment; an anchored match works in a single-library test and fails the moment a real extension is involved. Verified end to end: three Parquet files, three worker processes, each producing one partition of partial aggregate, driver merging them to the same answer the single-process path gives. Corrupting one shuffle file breaks the driver's query, which is how we know the workers did the work rather than the driver quietly recomputing it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent a63583c commit 6571d89

22 files changed

Lines changed: 1897 additions & 6 deletions

Cargo.lock

Lines changed: 18 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ members = [
3434
"examples/datafusion-ffi-query-planner-example",
3535
"examples/distributed/storage-library",
3636
"examples/distributed/udf-library",
37+
"examples/distributed/engine-library",
3738
]
3839
resolver = "3"
3940

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
# Licensed to the Apache Software Foundation (ASF) under one
2+
# or more contributor license agreements. See the NOTICE file
3+
# distributed with this work for additional information
4+
# regarding copyright ownership. The ASF licenses this file
5+
# to you under the Apache License, Version 2.0 (the
6+
# "License"); you may not use this file except in compliance
7+
# with the License. You may obtain a copy of the License at
8+
#
9+
# http://www.apache.org/licenses/LICENSE-2.0
10+
#
11+
# Unless required by applicable law or agreed to in writing,
12+
# software distributed under the License is distributed on an
13+
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
14+
# KIND, either express or implied. See the License for the
15+
# specific language governing permissions and limitations
16+
# under the License.
17+
18+
[package]
19+
name = "dfx-engine"
20+
version.workspace = true
21+
edition.workspace = true
22+
rust-version.workspace = true
23+
license.workspace = true
24+
description = "Example extension library: a toy distributed engine that splits plans into stages"
25+
homepage.workspace = true
26+
repository.workspace = true
27+
publish = false
28+
29+
[dependencies]
30+
arrow = { workspace = true }
31+
async-trait = { workspace = true }
32+
datafusion = { workspace = true }
33+
datafusion-common = { workspace = true, default-features = false }
34+
datafusion-ffi = { workspace = true }
35+
datafusion-proto = { workspace = true }
36+
datafusion-python-util.workspace = true
37+
datafusion-session = { workspace = true }
38+
futures = { workspace = true }
39+
pyo3 = { workspace = true, features = [
40+
"extension-module",
41+
"abi3",
42+
"abi3-py310",
43+
] }
44+
pyo3-log = { workspace = true }
45+
46+
[build-dependencies]
47+
pyo3-build-config = { workspace = true }
48+
49+
[lib]
50+
name = "_internal"
51+
crate-type = ["cdylib", "rlib"]
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
// Licensed to the Apache Software Foundation (ASF) under one
2+
// or more contributor license agreements. See the NOTICE file
3+
// distributed with this work for additional information
4+
// regarding copyright ownership. The ASF licenses this file
5+
// to you under the Apache License, Version 2.0 (the
6+
// "License"); you may not use this file except in compliance
7+
// with the License. You may obtain a copy of the License at
8+
//
9+
// http://www.apache.org/licenses/LICENSE-2.0
10+
//
11+
// Unless required by applicable law or agreed to in writing,
12+
// software distributed under the License is distributed on an
13+
// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
14+
// KIND, either express or implied. See the License for the
15+
// specific language governing permissions and limitations
16+
// under the License.
17+
18+
fn main() {
19+
pyo3_build_config::add_extension_module_link_args();
20+
}
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
# Licensed to the Apache Software Foundation (ASF) under one
2+
# or more contributor license agreements. See the NOTICE file
3+
# distributed with this work for additional information
4+
# regarding copyright ownership. The ASF licenses this file
5+
# to you under the Apache License, Version 2.0 (the
6+
# "License"); you may not use this file except in compliance
7+
# with the License. You may obtain a copy of the License at
8+
#
9+
# http://www.apache.org/licenses/LICENSE-2.0
10+
#
11+
# Unless required by applicable law or agreed to in writing,
12+
# software distributed under the License is distributed on an
13+
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
14+
# KIND, either express or implied. See the License for the
15+
# specific language governing permissions and limitations
16+
# under the License.
17+
18+
[build-system]
19+
requires = ["maturin>=1.6,<2.0"]
20+
build-backend = "maturin"
21+
22+
[project]
23+
name = "dfx_engine"
24+
requires-python = ">=3.10"
25+
classifiers = [
26+
"Programming Language :: Rust",
27+
"Programming Language :: Python :: Implementation :: CPython",
28+
]
29+
dynamic = ["version"]
30+
31+
[tool.maturin]
32+
features = ["pyo3/extension-module"]
33+
python-source = "python"
34+
module-name = "dfx_engine._internal"
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
# Licensed to the Apache Software Foundation (ASF) under one
2+
# or more contributor license agreements. See the NOTICE file
3+
# distributed with this work for additional information
4+
# regarding copyright ownership. The ASF licenses this file
5+
# to you under the Apache License, Version 2.0 (the
6+
# "License"); you may not use this file except in compliance
7+
# with the License. You may obtain a copy of the License at
8+
#
9+
# http://www.apache.org/licenses/LICENSE-2.0
10+
#
11+
# Unless required by applicable law or agreed to in writing,
12+
# software distributed under the License is distributed on an
13+
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
14+
# KIND, either express or implied. See the License for the
15+
# specific language governing permissions and limitations
16+
# under the License.
17+
18+
"""A toy distributed engine, as an extension library.
19+
20+
Two halves, because a real engine has two: the Rust side owns the query
21+
planner, the stage node, the codec that carries it, and a config extension;
22+
the Python side owns the session factory, the driver, and the worker entry
23+
point.
24+
25+
Start with :mod:`dfx_engine.session` -- ``build_session`` is the piece the
26+
rest of the example exists to motivate.
27+
"""
28+
29+
from dfx_engine import _internal
30+
from dfx_engine._internal import DfxEngineConfig, DfxEngineExtension
31+
from dfx_engine.session import SessionSpec, build_session, expected_codec_ids
32+
33+
__all__ = [
34+
"DfxEngineConfig",
35+
"DfxEngineExtension",
36+
"SessionSpec",
37+
"_internal",
38+
"build_session",
39+
"expected_codec_ids",
40+
]
Lines changed: 206 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,206 @@
1+
# Licensed to the Apache Software Foundation (ASF) under one
2+
# or more contributor license agreements. See the NOTICE file
3+
# distributed with this work for additional information
4+
# regarding copyright ownership. The ASF licenses this file
5+
# to you under the Apache License, Version 2.0 (the
6+
# "License"); you may not use this file except in compliance
7+
# with the License. You may obtain a copy of the License at
8+
#
9+
# http://www.apache.org/licenses/LICENSE-2.0
10+
#
11+
# Unless required by applicable law or agreed to in writing,
12+
# software distributed under the License is distributed on an
13+
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
14+
# KIND, either express or implied. See the License for the
15+
# specific language governing permissions and limitations
16+
# under the License.
17+
18+
"""The driver: split a query into tasks, fan them out, collect the answer.
19+
20+
The shape is deliberately boring, because the interesting part is not the
21+
scheduling. What matters is the four things the driver has to get right, each
22+
of which is a way a real deployment goes wrong:
23+
24+
1. It serializes the stage **with** its session. ``to_bytes(None)`` uses an
25+
empty codec chain and cannot encode any library's node.
26+
2. It ships the codec ids it used, so a worker can refuse a plan it would
27+
misread rather than decode it with the wrong codec.
28+
3. It puts the shuffle directory in the session config, not in the message,
29+
so the directory travels *inside* the encoded plan and the two sides
30+
cannot disagree.
31+
4. It waits for every worker before reading, because the stage node decides
32+
whether to read or recompute by looking at the filesystem.
33+
"""
34+
35+
from __future__ import annotations
36+
37+
import json
38+
import pathlib
39+
import subprocess
40+
import sys
41+
from typing import TYPE_CHECKING
42+
43+
from dfx_engine import _internal
44+
from dfx_engine.session import SessionSpec, build_session
45+
46+
if TYPE_CHECKING:
47+
import pyarrow as pa
48+
from datafusion import DataFrame, SessionContext
49+
from datafusion.plan import ExecutionPlan
50+
51+
__all__ = ["DistributedResult", "find_stage", "run_distributed"]
52+
53+
54+
class DistributedResult:
55+
"""What a distributed run produced, and how."""
56+
57+
def __init__(
58+
self,
59+
batches: list[pa.RecordBatch],
60+
partitions: list[int],
61+
worker_rows: dict[int, int],
62+
) -> None:
63+
self.batches = batches
64+
self.partitions = partitions
65+
"""Partition indices that were dispatched, one per worker."""
66+
self.worker_rows = worker_rows
67+
"""Rows each worker produced, keyed by partition index."""
68+
69+
70+
STAGE_NODE_NAME = "ShuffleStageExec"
71+
72+
73+
def find_stage(plan: ExecutionPlan) -> ExecutionPlan | None:
74+
"""Locate the stage node the planner inserted.
75+
76+
Matched on the display string because a Python caller has no way to
77+
downcast a Rust plan node -- there is no ``isinstance`` across an FFI
78+
boundary.
79+
80+
Note the *containment* test. The node was built inside this library and
81+
handed back to the host, so what the host prints is not
82+
``ShuffleStageExec: stage=1`` but::
83+
84+
FFI_ExecutionPlan: ShuffleStageExec, number_of_children=1
85+
86+
A foreign node reports its own name nested inside the wrapper's, which
87+
makes anchored matches on plan text quietly wrong -- the kind of thing
88+
that works in a single-library test and fails the moment a real extension
89+
is involved.
90+
"""
91+
if STAGE_NODE_NAME in plan.display():
92+
return plan
93+
for child in plan.children():
94+
found = find_stage(child)
95+
if found is not None:
96+
return found
97+
return None
98+
99+
100+
def _dispatch(
101+
envelope: dict, envelope_dir: pathlib.Path, partition: int
102+
) -> subprocess.Popen[str]:
103+
"""Start one worker for one partition.
104+
105+
``sys.executable``, not ``python``: a worker on a different Python minor
106+
version cannot load a cloudpickled inline UDF, and that failure is far
107+
from its cause.
108+
"""
109+
path = envelope_dir / f"task-{partition}.json"
110+
path.write_text(json.dumps(envelope))
111+
return subprocess.Popen( # noqa: S603
112+
[sys.executable, "-m", "dfx_engine.worker", str(path)],
113+
stdout=subprocess.PIPE,
114+
stderr=subprocess.PIPE,
115+
text=True,
116+
)
117+
118+
119+
def run_distributed(sql: str, spec: SessionSpec) -> DistributedResult:
120+
"""Run `sql`, executing its leaf stage in one worker process per partition.
121+
122+
Requires ``spec.shuffle_dir``: without it the planner inserts no stage and
123+
there is nothing to distribute.
124+
"""
125+
if not spec.shuffle_dir:
126+
message = "run_distributed needs a shuffle_dir; build_session got none"
127+
raise ValueError(message)
128+
129+
ctx, engine, _storage = build_session(spec)
130+
plan = ctx.sql(sql).execution_plan()
131+
132+
stage = find_stage(plan)
133+
if stage is None:
134+
message = (
135+
"no ShuffleStageExec in the plan; the engine's planner did not run, "
136+
"or its config extension was not registered"
137+
)
138+
raise RuntimeError(message)
139+
140+
shuffle_dir = pathlib.Path(spec.shuffle_dir)
141+
shuffle_dir.mkdir(parents=True, exist_ok=True)
142+
143+
# Encode the stage subtree, through the session that owns the codecs.
144+
plan_path = shuffle_dir / "stage.plan"
145+
plan_path.write_bytes(stage.to_bytes(ctx))
146+
147+
stage_id = _internal.stage_id()
148+
partitions = list(range(stage.partition_count))
149+
envelopes = [
150+
{
151+
"spec": spec.to_json(),
152+
"plan": str(plan_path),
153+
"stage_id": stage_id,
154+
"partition": partition,
155+
}
156+
for partition in partitions
157+
]
158+
159+
# One process per partition, all in flight together. This is the claim the
160+
# example is making: each worker reads a different file and writes a
161+
# different result, so they need no coordination beyond the directory.
162+
workers = [
163+
_dispatch(envelope, shuffle_dir, partition)
164+
for envelope, partition in zip(envelopes, partitions, strict=True)
165+
]
166+
167+
worker_rows: dict[int, int] = {}
168+
failures = []
169+
for partition, worker in zip(partitions, workers, strict=True):
170+
stdout, stderr = worker.communicate()
171+
if worker.returncode != 0:
172+
failures.append(f"partition {partition} failed:\n{stderr}")
173+
continue
174+
worker_rows[partition] = json.loads(stdout)["rows"]
175+
176+
if failures:
177+
raise RuntimeError("\n".join(failures))
178+
179+
# Now run the whole query here. Every stage partition has a file, so the
180+
# stage node streams them instead of recomputing -- the driver does only
181+
# the final merge.
182+
batches = ctx.sql(sql).collect()
183+
_ = engine
184+
return DistributedResult(batches, partitions, worker_rows)
185+
186+
187+
def run_local(sql: str, spec: SessionSpec) -> list[pa.RecordBatch]:
188+
"""Run `sql` in this process, for comparison.
189+
190+
Uses the same session factory with no shuffle directory, so the only
191+
difference from :func:`run_distributed` is where the work happened.
192+
"""
193+
ctx, _engine, _storage = build_session(
194+
SessionSpec(
195+
tables=spec.tables,
196+
shuffle_dir="",
197+
target_partitions=spec.target_partitions,
198+
)
199+
)
200+
return ctx.sql(sql).collect()
201+
202+
203+
def dataframe_for(sql: str, spec: SessionSpec) -> tuple[SessionContext, DataFrame]:
204+
"""Session and DataFrame for `sql`, for tests that want to inspect a plan."""
205+
ctx, _engine, _storage = build_session(spec)
206+
return ctx, ctx.sql(sql)

0 commit comments

Comments
 (0)