blob: e1ecb506565e3128a9530c5dbe4eba571553e98c [file]
"""pytest plugin: run the official Spark Connect tests through our Rust transport.
The official test files import the *reference* pyspark (they live in its tree), which
builds the protobuf plans. This plugin monkeypatches ``SparkConnectClient`` so its gRPC
stub is our Rust-backed transport instead of grpcio: the reference builds byte-identical
plan protos, and our Rust ``spark-connect-core`` carries them over the wire and decodes
the Arrow results. That makes the official suite genuinely exercise our Rust client with
no vendoring.
Usage (see scripts/run_rust_official_tests.sh):
RUST_PYSPARK_SO=/path/to/python/pyspark/_pyspark.so \
SPARK_CONNECT_TESTING_REMOTE=sc://localhost:15002 SPARK_TESTING=1 \
SPARK_SKIP_CONNECT_COMPAT_TESTS=1 \
PYTHONPATH=/repo/scripts:/path/to/spark-v4.2.0/python \
python -m pytest -p rust_transport_plugin <official test file>
"""
import importlib.util
import os
# Load our compiled extension directly by file so it works even though the reference
# pyspark (which has no _pyspark) is the one on the path.
_SO = os.environ.get("RUST_PYSPARK_SO")
if not _SO or not os.path.exists(_SO):
raise RuntimeError("RUST_PYSPARK_SO must point to the built _pyspark extension (.so/.dylib)")
# The extension's init symbol is PyInit__pyspark, so it must be loaded as "_pyspark".
_spec = importlib.util.spec_from_file_location("_pyspark", _SO)
_rust = importlib.util.module_from_spec(_spec)
_spec.loader.exec_module(_rust)
_GRPC_DETAILS_KEY = "grpc-status-details-bin"
def _rust_rpc_to_exception(e):
"""Turn a Rust RustRpcError into a real grpc.RpcError.
The Rust side hands us (grpc_code:int, status_details:bytes, message:str) where
status_details is the serialized google.rpc.Status (the grpc-status-details-bin
trailer). We wrap it as a grpc.RpcError so the reference client's own
_handle_rpc_error runs natively: it reads rpc_status.from_call(error), parses the
ErrorInfo, enriches via FetchErrorDetails, and calls convert_exception - giving the
exact same typed exception AND full message the reference produces.
"""
import grpc
code, details, message = e.args
class _RustRpcError(grpc.RpcError):
def code(self):
for c in grpc.StatusCode:
if c.value[0] == code:
return c
return grpc.StatusCode.UNKNOWN
def details(self):
# grpc_status.rpc_status.from_call requires call.details() to match the
# message inside the status-details proto exactly, so read it from there.
if details:
try:
from google.rpc import status_pb2
return status_pb2.Status.FromString(bytes(details)).message
except Exception:
pass
return message
def trailing_metadata(self):
return ((_GRPC_DETAILS_KEY, bytes(details)),) if details else ()
def initial_metadata(self):
return ()
return _RustRpcError()
class _RustStub:
"""A grpcio-compatible SparkConnectServiceStub backed by our Rust transport."""
def __init__(self, url):
self._c = _rust.RustConnectStub(url)
def _pb(self):
# Imported lazily; the reference pyspark provides these pb2 types.
from pyspark.sql.connect.proto import base_pb2
return base_pb2
def _stream(self, rust_stream):
# Lazily yield pb2 ExecutePlanResponse from a Rust ResponseStream, converting a
# per-message gRPC error into a real grpc.RpcError so the reference client's
# reattachable iterator/error handling behaves exactly as upstream.
pb = self._pb()
it = iter(rust_stream)
while True:
try:
b = next(it)
except StopIteration:
return
except _rust.RustRpcError as e:
raise _rust_rpc_to_exception(e) from None
yield pb.ExecutePlanResponse.FromString(b)
def ExecutePlan(self, request, metadata=None, **kw):
try:
rust_stream = self._c.execute_plan(request.SerializeToString())
except _rust.RustRpcError as e:
raise _rust_rpc_to_exception(e) from None
return self._stream(rust_stream)
def AnalyzePlan(self, request, metadata=None, **kw):
pb = self._pb()
try:
raw = self._c.analyze_plan(request.SerializeToString())
except _rust.RustRpcError as e:
raise _rust_rpc_to_exception(e) from None
return pb.AnalyzePlanResponse.FromString(raw)
def Config(self, request, metadata=None, **kw):
pb = self._pb()
try:
raw = self._c.config(request.SerializeToString())
except _rust.RustRpcError as e:
raise _rust_rpc_to_exception(e) from None
return pb.ConfigResponse.FromString(raw)
def Interrupt(self, request, metadata=None, **kw):
pb = self._pb()
try:
raw = self._c.interrupt(request.SerializeToString())
except _rust.RustRpcError as e:
raise _rust_rpc_to_exception(e) from None
return pb.InterruptResponse.FromString(raw)
def FetchErrorDetails(self, request, metadata=None, **kw):
pb = self._pb()
# Must surface a real grpc.RpcError on failure, exactly like the grpcio stub:
# the reference client enriches errors via _fetch_enriched_error(), which wraps
# this call in `except grpc.RpcError: return None` and falls back to the original
# execute error. If we let a raw RustRpcError escape instead, that fallback never
# runs and the enrichment failure masks the real error (e.g. a worker "Segmentation
# fault" gets replaced by the server-side NPE that FetchErrorDetails itself raises).
try:
raw = self._c.fetch_error_details(request.SerializeToString())
except _rust.RustRpcError as e:
raise _rust_rpc_to_exception(e) from None
return pb.FetchErrorDetailsResponse.FromString(raw)
def ReattachExecute(self, request, metadata=None, **kw):
try:
rust_stream = self._c.reattach_execute(request.SerializeToString())
except _rust.RustRpcError as e:
raise _rust_rpc_to_exception(e) from None
return self._stream(rust_stream)
def ReleaseExecute(self, request, metadata=None, **kw):
pb = self._pb()
return pb.ReleaseExecuteResponse.FromString(
self._c.release_execute(request.SerializeToString())
)
def ReleaseSession(self, request, metadata=None, **kw):
# Best-effort: the server reclaims idle sessions; a no-op keeps close() working.
return self._pb().ReleaseSessionResponse()
def close(self):
# Cancel in-flight Rust streams (mirrors grpcio channel.close()) so a reader
# blocked on a long-lived stream (e.g. the streaming-query listener bus) unblocks.
self._c.close()
def pytest_collection_modifyitems(config, items):
"""Deselect the known-environmental tests named in ``RUST_PARITY_DESELECT``.
run_official_tests.py passes the manifest's ``Class::method`` suffixes for the
file being run (newline-separated). We match on the node-id *suffix* rather than
handing pytest a full ``--deselect <path>::...`` argument: pytest's --deselect
compares against a node id computed relative to its rootdir, which varies between
a Spark *dist* tree (rootdir = python/) and a *source clone* (rootdir may be the
repo root, prefixing ids with ``python/``). Suffix matching is immune to that, so
the manifest applies identically everywhere. Only one file runs per invocation,
so a ``Class::method`` suffix cannot collide across files.
"""
# Two modes, both by node-id suffix match:
# RUST_PARITY_DESELECT - drop the listed tests (normal skiplist application)
# RUST_PARITY_SELECT_ONLY - keep ONLY the listed tests (the drift check re-runs
# the skiplisted tests to see if any now pass)
select_only = os.environ.get("RUST_PARITY_SELECT_ONLY", "")
deselect = os.environ.get("RUST_PARITY_DESELECT", "")
raw = select_only or deselect
suffixes = [s.strip() for s in raw.splitlines() if s.strip()]
if not suffixes:
return
def matches(nid):
# Match "…::Class::method" (or a parametrized "…::Class::method[param]").
return any(nid.endswith(s) or f"::{s}[" in nid for s in suffixes)
keep, drop = [], []
for item in items:
hit = matches(item.nodeid)
# select-only keeps hits and drops the rest; deselect does the opposite.
if hit == bool(select_only):
keep.append(item)
else:
drop.append(item)
if drop:
config.hook.pytest_deselected(items=drop)
items[:] = keep
def pytest_configure(config):
import pyspark.sql.connect.client.core as core
url = os.environ.get("SPARK_CONNECT_TESTING_REMOTE", "sc://localhost:15002")
orig_init = core.SparkConnectClient.__init__
def patched_init(self, connection, *args, **kwargs):
orig_init(self, connection, *args, **kwargs)
try:
endpoint = self._builder.endpoint
client_url = f"sc://{endpoint}"
except Exception:
client_url = url
# Route transport through Rust. Reattachable execute stays enabled (upstream
# default) so operations are registered/interruptible and streaming works like
# upstream; ReattachExecute/ReleaseExecute are implemented on the Rust stub.
self._stub = _RustStub(client_url)
core.SparkConnectClient.__init__ = patched_init
# The reference client's close() calls self._channel.close(), which for grpcio cancels
# in-flight RPCs. Our transport bypasses that channel, so also cancel the Rust stub's
# streams here - otherwise a non-daemon streaming-query listener thread blocked reading
# a long-lived stream never terminates and hangs interpreter shutdown (threading._shutdown).
orig_close = core.SparkConnectClient.close
def patched_close(self):
try:
stub = getattr(self, "_internal_stub", None)
if stub is not None and hasattr(stub, "close"):
stub.close()
except Exception:
pass
return orig_close(self)
core.SparkConnectClient.close = patched_close