blob: e5a1093f03ee92bbf6347c7f2c82351b7f9e9e86 [file]
# 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.
"""The driver: split a query into tasks, fan them out, collect the answer.
The shape is deliberately boring, because the interesting part is not the
scheduling. What matters is the six things the driver has to get right, each
of which is a way a real deployment goes wrong:
1. It serializes each stage **with** its session: ``stage.to_bytes(ctx)``. The
codecs that can write a library's nodes live on the session; encode without
one and only the nodes DataFusion itself defines survive.
2. It ships the codec ids it used, so a worker can refuse a plan it would
misread rather than decode it with the wrong codec.
3. It puts the shuffle directory in the session config, not in the message,
so the directory travels *inside* the encoded plan and the two sides
cannot disagree.
4. It waits for every worker before reading, because the stage node decides
whether to read or recompute by looking at the filesystem.
5. It ships *every* stage. A plan can hold more than one -- an aggregate in
each branch of a union, say -- and they are independent subtrees rather
than a chain.
6. It refuses a shuffle directory that already holds stage output -- see
:func:`require_empty_shuffle`.
"""
from __future__ import annotations
import json
import pathlib
import subprocess
import sys
from typing import TYPE_CHECKING
from dfx_engine import _internal
from dfx_engine.session import SessionSpec, build_session
if TYPE_CHECKING:
import pyarrow as pa
from datafusion import DataFrame, SessionContext
from datafusion.plan import ExecutionPlan
from datafusion.user_defined import ScalarUDF
__all__ = [
"DistributedResult",
"dataframe_for",
"find_stage",
"find_stages",
"require_empty_shuffle",
"run_distributed",
"run_local",
]
class DistributedResult:
"""What a distributed run produced, and how."""
def __init__(
self,
batches: list[pa.RecordBatch],
tasks: list[tuple[int, int]],
task_rows: dict[tuple[int, int], int],
) -> None:
self.batches = batches
self.tasks = tasks
"""``(stage_id, partition)`` pairs that were dispatched, one per worker.
Keyed by both, not by partition alone: a query with an aggregate in
more than one branch has more than one stage, and partition 0 of each
is a different piece of work.
"""
self.task_rows = task_rows
"""Rows each worker produced, keyed as :attr:`tasks` is."""
STAGE_NODE_NAME = "ShuffleStageExec"
def find_stages(plan: ExecutionPlan) -> list[ExecutionPlan]:
"""Locate every stage node the planner inserted, in pre-order.
Matched on the display string because a Python caller has no way to
downcast a Rust plan node -- there is no ``isinstance`` across an FFI
boundary. The test is *containment*, and the stage id is recomputed rather
than read out of the text, because a foreign node prints as its FFI wrapper
-- see :ref:`extension_foreign_node_display`.
Pre-order is the contract with the planner, which numbers stages in the
same walk, so the nth node returned here has the id
``_internal.stage_id(n)``. The recursion stops at a stage rather than
descending into it, because the planner never nests one inside another.
"""
if STAGE_NODE_NAME in plan.display():
return [plan]
return [stage for child in plan.children() for stage in find_stages(child)]
def find_stage(plan: ExecutionPlan) -> ExecutionPlan | None:
"""The first stage in `plan`, or ``None``.
For tests and callers that only care whether the planner split at all.
See :func:`find_stages`.
"""
stages = find_stages(plan)
return stages[0] if stages else None
def _dispatch(
envelope: dict, envelope_dir: pathlib.Path, stage_id: int, partition: int
) -> subprocess.Popen[str]:
"""Start one worker for one ``(stage, partition)``.
``sys.executable``, not ``python``: a worker on a different Python minor
version cannot load a cloudpickled inline UDF, and that failure is far
from its cause.
"""
path = envelope_dir / f"task-{stage_id}-{partition}.json"
path.write_text(json.dumps(envelope))
return subprocess.Popen( # noqa: S603
[sys.executable, "-m", "dfx_engine.worker", str(path)],
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
)
def _report(task: tuple[int, int], stdout: str) -> int:
"""Read a worker's row count off its last line of output.
The last line, not the whole stream: a worker's stdout is shared with
everything loaded into it, and one stray `print` from a library -- or a
warning some future dependency decides to write there -- would turn
`json.loads` on the whole buffer into a confusing failure a long way from
its cause.
"""
stage_id, partition = task
lines = [line for line in stdout.splitlines() if line.strip()]
if not lines:
message = f"stage {stage_id} partition {partition} printed no report"
raise RuntimeError(message)
try:
return json.loads(lines[-1])["rows"]
except (ValueError, KeyError) as err:
message = (
f"stage {stage_id} partition {partition} printed an unreadable "
f"report {lines[-1]!r}"
)
raise RuntimeError(message) from err
def _fan_out(
envelopes: list[dict], tasks: list[tuple[int, int]], task_dir: pathlib.Path
) -> dict[tuple[int, int], int]:
"""One process per task, all in flight together; rows produced per task.
This is the claim the example is making: each worker reads a different
file and writes a different result, so they need no coordination beyond
the directory.
Every failure -- a nonzero exit or an unreadable report -- is accumulated
rather than raised mid-loop, so every worker gets its `communicate` before
anything is reported. The `finally` covers the paths that never reach the
loop's end at all: a spawn that fails partway through the fan-out, or an
interrupt while collecting. A worker abandoned running would keep writing
into a shuffle directory its caller believes is settled -- or, in
`run_tpch.py`, one that is about to be deleted.
"""
workers: list[subprocess.Popen[str]] = []
task_rows: dict[tuple[int, int], int] = {}
failures = []
try:
for envelope, (stage_id, partition) in zip(envelopes, tasks, strict=True):
workers.append(_dispatch(envelope, task_dir, stage_id, partition))
for task, worker in zip(tasks, workers, strict=True):
stdout, stderr = worker.communicate()
if worker.returncode != 0:
stage_id, partition = task
failures.append(
f"stage {stage_id} partition {partition} failed:\n{stderr}"
)
continue
try:
task_rows[task] = _report(task, stdout)
except RuntimeError as err:
failures.append(str(err))
finally:
for worker in workers:
if worker.poll() is None:
worker.kill()
worker.wait()
if failures:
raise RuntimeError("\n".join(failures))
return task_rows
def require_empty_shuffle(shuffle_dir: pathlib.Path) -> None:
"""Refuse a directory that already holds stage output.
A stage node reads partition `i` if the file for it exists and computes it
otherwise, which is what lets one node be both halves of the exchange. The
cost is that the filesystem *is* the state, and stage ids restart at
``stage_id(0)`` for every plan -- so a second query in the same directory
finds files left by the first and reads them, having computed nothing.
Nothing downstream can catch that. If the two plans' stage schemas differ
the failure is an unrelated-looking schema error a long way from here, and
if they agree -- the same query over data that has since changed, say --
the answer is simply the old one, silently.
So the rule this enforces is **one shuffle directory per query**, and the
check belongs here rather than in the node: the node cannot tell a file
this run's worker wrote from one last run's worker wrote, but the driver
knows it has not dispatched anything yet.
Scoped to stage output, not to "is the directory empty". The two are the
same thing only because :func:`run_distributed` keeps its own scratch in
a ``tasks/`` subdirectory; a caller who points this at a directory of
their own should get an answer about the files that would actually be
read, not about the ones they put there.
"""
stale = sorted(path.name for path in shuffle_dir.glob(_internal.partition_glob()))
if stale:
message = (
f"{shuffle_dir} already holds stage output {stale}; a stage reads "
f"a partition file if it finds one, so this query would return the "
f"previous query's results. Use one shuffle directory per query."
)
raise RuntimeError(message)
def run_distributed(
sql: str, spec: SessionSpec, extra_udfs: list[ScalarUDF] | None = None
) -> DistributedResult:
"""Run `sql`, executing each stage partition in its own worker process.
Requires ``spec.shuffle_dir``: without it the planner inserts no stage and
there is nothing to distribute. The directory must not already hold stage
output -- see :func:`require_empty_shuffle`.
``extra_udfs`` are registered on the driver only. They have to be here for
the query to *plan*, but not on the worker: a Python UDF is cloudpickled
into the plan and travels by value, unlike the Rust functions in
:func:`~dfx_engine.session.build_session`, which travel by name and so
have to exist on both sides.
"""
if not spec.shuffle_dir:
message = "run_distributed needs a shuffle_dir; build_session got none"
raise ValueError(message)
# Before building a session, so a reused directory costs nothing to
# diagnose. `glob` on a directory that does not exist yet yields nothing.
require_empty_shuffle(pathlib.Path(spec.shuffle_dir))
ctx, _engine, _storage = build_session(spec)
for function in extra_udfs or []:
ctx.register_udf(function)
plan = ctx.sql(sql).execution_plan()
stages = find_stages(plan)
if not stages:
message = (
"no ShuffleStageExec in the plan; the engine's planner did not run, "
"or its config extension was not registered"
)
raise RuntimeError(message)
shuffle_dir = pathlib.Path(spec.shuffle_dir)
# The driver's scratch lives in a subdirectory rather than beside the
# results. The stage node owns the `stage-*-part-*.arrow` namespace in
# `shuffle_dir` and nothing else should write there, so that
# :func:`require_empty_shuffle` is asking about stage output rather than
# about whatever else the driver happens to have left lying around.
task_dir = shuffle_dir / "tasks"
task_dir.mkdir(parents=True, exist_ok=True)
# One task per (stage, partition). Every stage is shipped, not just the
# first: a query with an aggregate in two branches has two independent
# subtrees, and leaving one behind would have the driver compute it
# locally while the plan it shipped claimed otherwise.
tasks: list[tuple[int, int]] = []
envelopes = []
for index, stage in enumerate(stages):
# The id the planner gave this stage, recomputed from its position
# because the FFI wrapper's display does not carry it.
stage_id = _internal.stage_id(index)
# Encode the stage subtree, through the session that owns the codecs.
plan_path = task_dir / f"stage-{stage_id}.plan"
plan_path.write_bytes(stage.to_bytes(ctx))
for partition in range(stage.partition_count):
tasks.append((stage_id, partition))
envelopes.append(
{
"spec": spec.to_json(),
"plan": str(plan_path),
"stage_id": stage_id,
"partition": partition,
}
)
task_rows = _fan_out(envelopes, tasks, task_dir)
# Now run the whole query here. Every stage partition has a file, so the
# stage nodes stream them instead of recomputing -- the driver does only
# the final merge.
batches = ctx.sql(sql).collect()
return DistributedResult(batches, tasks, task_rows)
def run_local(
sql: str, spec: SessionSpec, extra_udfs: list[ScalarUDF] | None = None
) -> list[pa.RecordBatch]:
"""Run `sql` in this process, for comparison.
Uses the same session factory with no shuffle directory, so the only
difference from :func:`run_distributed` is where the work happened. Any
disagreement between the two is a bug in the split.
"""
ctx, _engine, _storage = build_session(
SessionSpec(
tables=spec.tables,
shuffle_dir="",
target_partitions=spec.target_partitions,
)
)
for function in extra_udfs or []:
ctx.register_udf(function)
return ctx.sql(sql).collect()
def dataframe_for(sql: str, spec: SessionSpec) -> tuple[SessionContext, DataFrame]:
"""Session and DataFrame for `sql`, for tests that want to inspect a plan."""
ctx, _engine, _storage = build_session(spec)
return ctx, ctx.sql(sql)