blob: 09ad8d4411229da6005f74e1880acda7f883998c [file]
#!/usr/bin/env python3
"""Parity gate: run the official Spark Connect test suite through OUR Rust client.
Now that the client is at full parity, we no longer run the reference client on every
file to compute a per-file baseline (that doubled CI time). Instead we run ONLY our
client (via the Rust transport-injection plugin, scripts/rust_transport_plugin.py) and
require every test to pass, except a checked-in manifest of *known environmental
failures* (scripts/parity_known_failures.txt) - tests the reference client also fails
in this single-node pure-Connect CI environment. Those are deselected for our run too.
Any test that fails and is NOT in the manifest is a genuine problem: either a client
regression, or a new environmental failure. Regenerate the manifest with
scripts/gen_parity_skiplist.py (which runs the reference client) to reclassify, and
commit the update if it is environmental.
Usage:
RUST_PYSPARK_SO=/repo/python/pyspark/_pyspark.so \
SPARK_CONNECT_TESTING_REMOTE=sc://localhost:15002 \
python3 scripts/run_official_tests.py --spark ~/spark-source [--jobs 1]
"""
from __future__ import annotations
import argparse
import concurrent.futures
import os
import re
import subprocess
import sys
from pathlib import Path
REPO = Path(__file__).resolve().parent.parent
DEFAULT_MANIFEST = REPO / "scripts" / "parity_known_failures.txt"
# The only files whose failures are genuinely event-timing-driven: they wait on
# real server-pushed events (streaming query progress/listener callbacks, observed
# metrics) and can transiently exceed the per-file cap late in a long serial run on
# a resource-starved single-node server, yet pass reliably in isolation. ONLY these
# get retried; every other file is expected to be deterministic, so a
# non-deterministic client bug there fails the gate on its first occurrence instead
# of having to reproduce on every one of N attempts (which would let a 50%-flaky bug
# through most runs). Basenames, matched exactly.
FLAKY_FILES = {
"test_parity_streaming.py",
"test_parity_foreach_batch.py",
"test_parity_foreach.py",
"test_parity_listener.py",
"test_parity_observation.py",
# Stateful structured-streaming (transformWithState) - also event-driven.
"test_parity_transform_with_state.py",
"test_parity_pandas_transform_with_state.py",
}
# Files that are deterministic and pass, but are legitimately SLOW: they run many
# tests that each spawn a Python/pyarrow worker on the server, so the whole file takes
# far longer than a normal one. They are not flaky (retrying at the same cap can't help
# a file that simply needs more wall-clock), so instead of retrying them we give them a
# larger per-file timeout. Reference point: the reference client runs the full
# arrow-python-udf file (320 passed, 64 skipped) in ~120s and our client matches that
# locally; a shared CI runner is several times slower, hence the generous cap. The
# earlier whole-file TIMEOUT (p=0) was this file exceeding the 360s default, not a hang.
SLOW_FILE_TIMEOUTS = {
"test_parity_arrow_python_udf.py": 1200,
}
_FAIL_RE = re.compile(r"^(?:FAILED|ERROR)\s+(\S+)")
# NB: pytest pluralizes "error" -> "errors" in the summary; `error\b` would miss it
# (no word boundary between "r" and "s"), so match an optional trailing "s".
_COUNT_RE = {
k: re.compile(rf"(\d+) {k}{'s?' if k == 'error' else ''}\b")
for k in ("passed", "failed", "error", "skipped")
}
def discover(spark_py: Path):
# Scoped to sql/tests/connect (mirrors gen_parity_skiplist and the old gate): the
# ml/pandas connect suites need extra deps (torch, sklearn, ...) CI does not install.
out = []
for p in sorted(spark_py.rglob("test_*.py")):
if "/sql/tests/connect/" in str(p).replace(os.sep, "/"):
out.append(p)
return out
def resolve_only(spec: str, spark_py: Path):
"""Resolve a comma-separated `--only` list to explicit test-file Paths under spark_py.
Accepts either a dotted module name the way Apache's `./python/run-tests --testnames`
does (`pyspark.resource.tests.test_connect_resources`) or a file path relative to
spark_py (`pyspark/sql/tests/connect/client/test_artifact.py`). This is how the
local-cluster phase runs a fixed set of resource/artifact tests, including
`pyspark.resource.tests.test_connect_resources`, which lives outside the
`sql/tests/connect/` scope `discover()` walks.
"""
out = []
for name in spec.split(","):
name = name.strip()
if not name:
continue
if name.endswith(".py"):
out.append(spark_py / name)
else:
out.append(spark_py.joinpath(*name.split(".")).with_suffix(".py"))
return out
def load_manifest(path: Path):
"""Return (per_file_deselect, whole_file_skip).
per_file_deselect maps a file-relative path -> list of "Class::test" suffixes to
deselect; whole_file_skip is the set of file-relative paths to skip entirely
(manifest entries with no "::", i.e. the reference could not even run the file).
"""
per_file, whole_file = {}, set()
if not path.exists():
return per_file, whole_file
for raw in path.read_text().splitlines():
line = raw.split("#", 1)[0].strip()
if not line:
continue
if "::" in line:
relpath, suffix = line.split("::", 1)
per_file.setdefault(relpath, []).append(suffix)
else:
whole_file.add(line)
return per_file, whole_file
def parse_counts(text: str):
c = {"passed": 0, "failed": 0, "error": 0, "skipped": 0}
for line in reversed(text.strip().splitlines()):
if any(w in line for w in (" passed", " failed", " error", " skipped")):
for k, rx in _COUNT_RE.items():
m = rx.search(line)
if m:
c[k] = int(m.group(1))
if any(w in line for w in ("passed", "failed", "error")):
break
return c
def run_ours(
test_file: Path, spark_py: Path, remote: str, deselect, timeout: int, select_only=False
):
env = dict(os.environ)
env["SPARK_CONNECT_TESTING_REMOTE"] = remote
env["SPARK_TESTING"] = "1"
env["SPARK_SKIP_CONNECT_COMPAT_TESTS"] = "1"
env["PYTHONPATH"] = os.pathsep.join([str(REPO / "scripts"), str(spark_py)])
env["RUST_PYSPARK_SO"] = os.environ["RUST_PYSPARK_SO"]
# Deselection is done by the transport plugin (pytest_collection_modifyitems) via
# this env var - matching node-id *suffixes* - rather than pytest's own --deselect.
# --deselect compares against a node id relative to pytest's rootdir, which differs
# between a Spark *dist* (rootdir = python/, id = `pyspark/...`) and a *source clone*
# (rootdir may be the repo root, id = `python/pyspark/...`); an absolute or wrong-
# prefix path silently matches nothing and voids the whole manifest. Suffix matching
# in the plugin is rootdir-independent. Run from spark_py (as Apache's ./python/
# run-tests does) with the file passed relative to it.
rel_file = test_file.relative_to(spark_py).as_posix()
# select_only: run ONLY the listed tests (the drift check); otherwise deselect them.
env["RUST_PARITY_SELECT_ONLY" if select_only else "RUST_PARITY_DESELECT"] = "\n".join(deselect)
args = [
sys.executable,
"-m",
"pytest",
"-q",
"-rfE",
"--tb=short",
"-p",
"no:cacheprovider",
"-p",
"rust_transport_plugin",
]
args.append(rel_file)
try:
r = subprocess.run(
args, env=env, cwd=str(spark_py), capture_output=True, text=True, timeout=timeout
)
text = r.stdout + "\n" + r.stderr
except subprocess.TimeoutExpired:
return {"passed": 0, "failed": 0, "error": 0, "skipped": 0, "timeout": True, "rc": -1}, []
counts = parse_counts(text)
# Gate on pytest's exit code, NOT just the scraped summary: a plugin/extension load
# failure, collection/import error, or mid-file crash exits nonzero with NO summary
# line, which would otherwise parse as all-zeros and score the file (and the whole
# gate) a vacuous "ok" while testing nothing. pytest exit codes: 0=all passed,
# 1=tests failed, 2=interrupted, 3=internal error, 4=usage error, 5=no tests
# collected. 5 is fine here (a fully-deselected file legitimately collects nothing).
counts["rc"] = 0 if r.returncode == 5 else r.returncode
failed_ids = [
m.group(1) for m in map(_FAIL_RE.match, (ln.strip() for ln in text.splitlines())) if m
]
return counts, failed_ids
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--spark", required=True, help="Spark source tree (has python/)")
ap.add_argument(
"--remote", default=os.environ.get("SPARK_CONNECT_TESTING_REMOTE", "sc://localhost:15002")
)
ap.add_argument("--manifest", default=str(DEFAULT_MANIFEST))
ap.add_argument("--jobs", type=int, default=1)
ap.add_argument("--timeout", type=int, default=360)
ap.add_argument(
"--retries",
type=int,
default=2,
help="Max extra attempts for the event-timing-driven files ONLY (see "
"FLAKY_FILES): the streaming-query-listener/observation tests wait on real "
"server-pushed events and can transiently exceed the per-file cap late in a "
"long serial run on a resource-starved single-node server, yet pass reliably "
"in isolation. All other files are treated as deterministic and get zero "
"retries, so a non-deterministic client bug there is not masked by re-running.",
)
ap.add_argument(
"--no-drift-check",
action="store_true",
help="Skip the post-run check that re-runs skiplisted tests to report any that "
"now pass (candidates to remove from the manifest).",
)
ap.add_argument(
"--only",
default="",
help="Run ONLY this comma-separated list of tests instead of discovering the "
"whole sql/tests/connect suite. Each entry is a dotted module name (like "
"Apache's run-tests --testnames, e.g. "
"pyspark.resource.tests.test_connect_resources) or a spark_py-relative .py path. "
"Used by the local-cluster phase to run the resource/artifact tests, which need "
"a real multi-executor cluster.",
)
ap.add_argument(
"--exclude",
default="",
help="Comma-separated test-file basenames to drop from discovery (e.g. the "
"resource/artifact files that only run in the local-cluster phase). Ignored "
"when --only is set.",
)
args = ap.parse_args()
if not os.environ.get("RUST_PYSPARK_SO"):
print("!! set RUST_PYSPARK_SO to the built extension")
return 2
spark_py = Path(os.path.expanduser(args.spark)) / "python"
if args.only:
files = resolve_only(args.only, spark_py)
missing = [f for f in files if not f.exists()]
if missing:
print("!! --only test file(s) not found:", *(str(m) for m in missing))
return 2
else:
files = discover(spark_py)
if args.exclude:
drop = {x.strip() for x in args.exclude.split(",") if x.strip()}
files = [f for f in files if f.name not in drop]
if not files:
print("!! no connect test files found under", spark_py)
return 2
per_file, whole_file = load_manifest(Path(args.manifest))
n_skip = sum(len(v) for v in per_file.values())
print(
f"Discovered {len(files)} connect test files; manifest skips "
f"{n_skip} tests + {len(whole_file)} whole files.\n",
flush=True,
)
def is_bad(counts):
# A file failed if pytest exited nonzero (covers failures, errors, collection/
# plugin/usage errors, crashes - even when no summary line is printed), it timed
# out, or the scraped counts show failures/errors (defense in depth).
return bool(
counts.get("rc", 0)
or counts.get("timeout")
or counts.get("failed", 0)
or counts.get("error", 0)
)
def work(tf: Path):
rel = tf.relative_to(spark_py).as_posix()
if rel in whole_file:
return tf.name, None, [], True, 1 # skipped whole file
deselect = per_file.get(rel, [])
# Slow-but-deterministic files get a larger per-file timeout (see
# SLOW_FILE_TIMEOUTS); everything else uses the default cap.
file_timeout = SLOW_FILE_TIMEOUTS.get(tf.name, args.timeout)
counts, failed_ids = run_ours(tf, spark_py, args.remote, deselect, file_timeout)
# Retry ONLY the event-timing-driven files (see FLAKY_FILES): for them a flake
# clears on a fresh run while a genuine failure persists. Deterministic files
# are NOT retried, so a non-deterministic bug there surfaces immediately rather
# than needing to reproduce on every attempt.
retries = args.retries if tf.name in FLAKY_FILES else 0
attempts = 1
while is_bad(counts) and attempts <= retries:
attempts += 1
counts, failed_ids = run_ours(tf, spark_py, args.remote, deselect, file_timeout)
return tf.name, counts, failed_ids, False, attempts
failures = [] # (file, [nodeids])
done = 0
ex = concurrent.futures.ThreadPoolExecutor(max_workers=max(1, args.jobs))
for name, counts, failed_ids, skipped_file, attempts in ex.map(work, files):
done += 1
if skipped_file:
print(f"[{done}/{len(files)}] skip(env) {name}", flush=True)
continue
bad = is_bad(counts)
tag = "FAIL" if bad else "ok"
retry_note = f" (after {attempts} attempts)" if attempts > 1 else ""
rc = counts.get("rc", 0)
# Surface a nonzero exit with no failures/errors (e.g. plugin load / collection
# error, exit 4/2/3) so a vacuous "no summary line" run is not mistaken for ok.
rc_note = f" rc={rc}" if rc and not (counts.get("failed") or counts.get("error")) else ""
print(
f"[{done}/{len(files)}] {tag:<10} {name:<48} "
f"p={counts['passed']} f={counts['failed']} e={counts['error']} "
f"skip={counts['skipped']}"
+ (" TIMEOUT" if counts.get("timeout") else "")
+ rc_note
+ retry_note,
flush=True,
)
if bad:
failures.append((name, failed_ids or [f"<no summary; pytest rc={rc}>"]))
ex.shutdown()
# Drift check: re-run the skiplisted tests (selecting ONLY them) and report any
# that now PASS. Without this the manifest can only rot toward less coverage - a
# test stays skipped forever and a later regression in a skiplisted area is
# invisible. This never fails the gate (a genuine environmental skip keeps
# failing here); it only surfaces entries to remove so the list stays honest.
if not args.no_drift_check:
now_passing = []
files_by_rel = {tf.relative_to(spark_py).as_posix(): tf for tf in files}
for rel, suffixes in sorted(per_file.items()):
tf = files_by_rel.get(rel)
if tf is None:
continue
counts, _ = run_ours(
tf, spark_py, args.remote, suffixes, args.timeout, select_only=True
)
# All selected tests passed (some ran, and pytest exited clean).
if counts.get("passed", 0) and not is_bad(counts):
now_passing.append((rel, counts.get("passed", 0), counts.get("skipped", 0)))
if now_passing:
print(
"\nDrift check: skiplisted tests that now PASS (candidates to remove "
"from\nscripts/parity_known_failures.txt so the gate exercises them):"
)
for rel, npass, nskip in now_passing:
print(f" {rel}: {npass} passed, {nskip} skipped")
print(f"\n{len(failures)} file(s) with unexpected failures out of {len(files)}.")
if failures:
print(
"\nUnexpected failures (a regression, or a new environmental failure to add\n"
"to scripts/parity_known_failures.txt via scripts/gen_parity_skiplist.py):"
)
for _name, ids in failures:
for nid in ids:
print(f" {nid}")
return 1
return 0
if __name__ == "__main__":
raise SystemExit(main())