diff --git a/dev/sparktestsupport/modules.py b/dev/sparktestsupport/modules.py index 898a3831f484b..b90e28a20d005 100644 --- a/dev/sparktestsupport/modules.py +++ b/dev/sparktestsupport/modules.py @@ -1179,6 +1179,7 @@ def __hash__(self): "pyspark.sql.tests.connect.test_connect_retry", "pyspark.sql.tests.connect.test_connect_session", "pyspark.sql.tests.connect.test_connect_local_server", + "pyspark.sql.tests.connect.test_connect_local_server_pool", "pyspark.sql.tests.connect.test_connect_stat", "pyspark.sql.tests.connect.test_parity_geographytype", "pyspark.sql.tests.connect.test_parity_geometrytype", diff --git a/docs/spark-connect-overview.md b/docs/spark-connect-overview.md index 2f1c0836c3837..4f9628b767e88 100644 --- a/docs/spark-connect-overview.md +++ b/docs/spark-connect-overview.md @@ -343,6 +343,35 @@ backed by the shared `SparkContext` (the persistent catalog/warehouse, global te cached datasets) *is* shared across runs, so namespace per-run databases or clear that state yourself if your runs must be fully isolated. +### Fully isolated runs with a pool of single-use servers + +If your runs must be fully isolated from each other but you still want to skip the per-run +startup cost, a second experimental opt-in keeps a small pool of booted servers that have +never been assigned to an application run instead of one shared one. With +`SPARK_LOCAL_CONNECT_POOL=1` set (or +`spark.local.connect.pool=true` on the builder), each run *claims* a fresh server from the pool, +a replacement is booted in the background, and the claimed server is torn down when the run's +session stops -- no server ever serves two runs, so runs are as isolated as with the default +in-process server: + +```bash +export SPARK_LOCAL_CONNECT_POOL=1 + +# Each run claims a booted server not previously assigned to another run. +python -c 'from pyspark.sql import SparkSession; SparkSession.builder.remote("local[*]").getOrCreate()' + +# Force-stop all pool servers and start over from a clean slate. +python -m pyspark.sql.connect.local_server_pool --purge +``` + +The trade-off relative to the reuse mode above is memory: `spark.local.connect.pool.size` +(default 2) idle servers stay resident while you iterate, several hundred MB each. They shut +down on their own after sitting unclaimed for `SPARK_LOCAL_CONNECT_POOL_IDLE_TIMEOUT` seconds +(default 1800), so an idle machine drains back to zero servers. Pool servers are only handed to +runs whose master, startup configurations, working directory, and Python environment match the +ones they were booted with; runs that differ in any of these boot their own pool members. If +both this and `spark.local.connect.reuse` are set, the pool takes precedence. + ## Use Spark Connect in standalone applications
diff --git a/python/pyspark/sql/connect/local_server.py b/python/pyspark/sql/connect/local_server.py index 4f06d884aff49..30f4b1939fe3e 100644 --- a/python/pyspark/sql/connect/local_server.py +++ b/python/pyspark/sql/connect/local_server.py @@ -75,6 +75,22 @@ def _pid_alive(pid: int) -> bool: return True +def runtime_dir() -> str: + """Return the private per-user directory holding local-server state.""" + path = os.path.join(tempfile.gettempdir(), "spark-connect-{}".format(getpass.getuser())) + try: + # exist_ok also covers two first runs racing to create the directory; chmod + # re-asserts 0700 and fails if another user owns the path. + os.makedirs(path, mode=0o700, exist_ok=True) + os.chmod(path, 0o700) + except OSError as e: + raise PySparkRuntimeError( + errorClass="LOCAL_CONNECT_RUNTIME_DIR_UNAVAILABLE", + messageParameters={"path": path}, + ) from e + return path + + class Discovery: """Reads and writes the discovery file recording the persistent local server. @@ -82,26 +98,11 @@ class Discovery: ``SPARK_LOCAL_CONNECT_DISCOVERY`` points; the daemon's pid file and logs sit next to it. """ - @staticmethod - def _runtime_dir() -> str: - path = os.path.join(tempfile.gettempdir(), "spark-connect-{}".format(getpass.getuser())) - try: - # exist_ok also covers two first runs racing to create the directory; chmod - # re-asserts 0700 and fails if another user owns the path. - os.makedirs(path, mode=0o700, exist_ok=True) - os.chmod(path, 0o700) - except OSError as e: - raise PySparkRuntimeError( - errorClass="LOCAL_CONNECT_RUNTIME_DIR_UNAVAILABLE", - messageParameters={"path": path}, - ) from e - return path - def __init__(self, path: Optional[str] = None): self.path = os.path.abspath( path or os.environ.get("SPARK_LOCAL_CONNECT_DISCOVERY") - or os.path.join(self._runtime_dir(), "connect-local.json") + or os.path.join(runtime_dir(), "connect-local.json") ) self._lock_file: Optional[TextIO] = None @@ -219,12 +220,33 @@ def is_reusable(self) -> bool: def reuse_or_start(self, master: str, opts: Dict[str, Any]) -> str: if not self.is_reusable(): - ServerLauncher(master, opts, self._discovery).launch() - self._reload() + self.start(master, opts) assert self.token is not None os.environ["SPARK_CONNECT_AUTHENTICATE_TOKEN"] = self.token return self.url + def start( + self, + master: str, + opts: Dict[str, Any], + *, + use_ephemeral_port: bool = False, + seed_conf: Optional[Dict[str, Any]] = None, + ) -> None: + """Start this server and reload its discovery record. + + Callers starting isolated daemons can request an ephemeral port and provide a + precomputed startup configuration while sharing the standard launch path. + """ + ServerLauncher( + master, + opts, + self._discovery, + use_ephemeral_port=use_ephemeral_port, + seed_conf=seed_conf, + ).launch() + self._reload() + def stop(self) -> bool: stopped = False if self.pid is not None: @@ -237,17 +259,47 @@ def stop(self) -> bool: return stopped +def startup_seed_conf(opts: Dict[str, Any]) -> Dict[str, Any]: + """Compute startup confs using the same merge as the in-process server path.""" + conf: Dict[str, Any] = {} + for i in range(int(os.environ.get("PYSPARK_REMOTE_INIT_CONF_LEN", "0"))): + conf = json.loads(os.environ["PYSPARK_REMOTE_INIT_CONF_{}".format(i)]) + conf.update(opts) + for k in list(conf): + if k in ( + "spark.remote", + "spark.api.mode", + "spark.master", + "spark.connect.authenticate.token", + "spark.connect.grpc.binding.port", + ) or k.startswith("spark.local.connect."): + conf.pop(k) + return conf + + class ServerLauncher: """Starts a persistent local server via ``sbin/start-connect-server.sh`` and waits until it accepts connections. Callers must hold a ``Discovery`` context. + + ``use_ephemeral_port`` and ``seed_conf`` allow other managed local servers to share this + launch path without duplicating its process and readiness handling. """ _READY_TIMEOUT = 120 - def __init__(self, master: str, opts: Dict[str, Any], discovery: Discovery): + def __init__( + self, + master: str, + opts: Dict[str, Any], + discovery: Discovery, + use_ephemeral_port: bool = False, + seed_conf: Optional[Dict[str, Any]] = None, + ): self._master = master self._opts = opts self._discovery = discovery + self._use_ephemeral_port = use_ephemeral_port + self._seed_override = seed_conf self._log_dir = os.path.join(discovery.directory, "logs") def launch(self) -> None: @@ -269,12 +321,12 @@ def _token(self) -> str: ) def _pick_port(self) -> int: - """Under SPARK_TESTING always use an OS-assigned free port so suites can run in - parallel; otherwise honor the configured/default port, falling back to a free one if - another process holds it. (A live stale server of ours also holds the port, but that - start fails later at spark-daemon.sh's pid-file check regardless of port.) The sbin - script cannot report an ephemeral port back, so the free port is picked and released - here, with a small race until the server binds it. + """Use an OS-assigned free port when requested or under SPARK_TESTING so suites can + run in parallel. Otherwise honor the configured/default port, falling back to a free + one if another process holds it. (A live stale server of ours also holds the port, but + that start fails later at spark-daemon.sh's pid-file check regardless of port.) The + sbin script cannot report an ephemeral port back, so the free port is picked and + released here, with a small race until the server binds it. """ def free_port() -> int: @@ -282,7 +334,7 @@ def free_port() -> int: sock.bind(("localhost", 0)) return sock.getsockname()[1] - if "SPARK_TESTING" in os.environ: + if self._use_ephemeral_port or "SPARK_TESTING" in os.environ: return free_port() from pyspark.sql.connect.client import DefaultChannelBuilder @@ -303,20 +355,9 @@ def _seed_conf(self) -> Dict[str, Any]: opt-in keys. Only the run that starts the server can seed static confs; later runs find the JVM already warm. """ - conf: Dict[str, Any] = {} - for i in range(int(os.environ.get("PYSPARK_REMOTE_INIT_CONF_LEN", "0"))): - conf = json.loads(os.environ["PYSPARK_REMOTE_INIT_CONF_{}".format(i)]) - conf.update(self._opts) - for k in list(conf): - if k in ( - "spark.remote", - "spark.api.mode", - "spark.master", - "spark.connect.authenticate.token", - "spark.connect.grpc.binding.port", - ) or k.startswith("spark.local.connect."): - conf.pop(k) - return conf + if self._seed_override is not None: + return dict(self._seed_override) + return startup_seed_conf(self._opts) @contextlib.contextmanager def _seed_properties_file(self) -> Iterator[Optional[str]]: diff --git a/python/pyspark/sql/connect/local_server_pool.py b/python/pyspark/sql/connect/local_server_pool.py new file mode 100644 index 0000000000000..ad8130486b091 --- /dev/null +++ b/python/pyspark/sql/connect/local_server_pool.py @@ -0,0 +1,814 @@ +# +# 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. +# + +""" +Opt-in pool of single-use local Spark Connect servers +(``spark.local.connect.pool`` / ``SPARK_LOCAL_CONNECT_POOL``). + +The reuse mode (``spark.local.connect.reuse``, see ``pyspark.sql.connect.local_server``) makes +local runs fast by sharing one long-lived server, at the price of state backed by the shared +``SparkContext`` (persistent catalog, global temp views, cached data) carrying across runs. +The pool keeps the speed without the sharing: it maintains a small set of booted servers that +have never been assigned to an application run, and +``SparkSession.builder.remote("local[*]").getOrCreate()`` *claims* one exclusively, spawns a +replacement in the background, and tears the claimed server down when the session stops or the +client exits. No server ever serves two application runs, so runs are as isolated from each +other as with the default in-process server -- at the cost of the idle servers' memory while +you iterate. If both opt-ins are set, the pool takes precedence. + +The pool lives in a ``pool`` subdirectory of the per-user runtime directory (override with +``SPARK_LOCAL_CONNECT_POOL_DIR``). A member is a set of files named by a random ````; +every access happens under the directory's ``.lock`` file lock, so readers always observe +complete states: + + pending-.json an in-flight launch (the attendant process booting the server) + conf-.json startup confs for that launch, read once by its attendant + server-.json a ready, unclaimed server (host/port/token/pid/version) + claimed--.json a server owned by the live client process + retired-.json a server being torn down; hard-killed if it hangs + member-/ the server's pid file and logs (spark-daemon.sh directories) + +Each launch runs an *attendant* (``python -m pyspark.sql.connect.local_server_pool --attend``), +a small detached process that boots the server through ``sbin/start-connect-server.sh``, +publishes its ``server-.json``, and supervises it. It retires the server once it has sat +unclaimed past the idle timeout, or once the client that claimed it has died without releasing +it. A janitor pass on every acquire is the backstop for members whose attendant itself died. + +Servers are only handed to runs they were built for: each member carries a fingerprint of its +master, seeded confs, working directory, and Python executable, and a run only claims members +whose fingerprint matches its own. ``python -m pyspark.sql.connect.local_server_pool --purge`` +force-stops every member and empties the pool directory. + +This mode is experimental. Everything but the opt-in itself -- the pool directory layout, the +attendant, and the ``--purge`` entry point -- is an internal detail that may change or move, +for example into a unified ``spark connect`` CLI. POSIX only, like the reuse mode. +""" + +import argparse +import atexit +import contextlib +import hashlib +import json +import os +import shutil +import signal +import socket +import subprocess +import sys +import time +import uuid +from typing import Any, Dict, List, Optional, Tuple + +from pyspark.errors import PySparkRuntimeError + +# How long one acquire may wait for a member to become ready. A cold launch takes at most +# 120s; this leaves room for one relaunch of a failed one. +_ACQUIRE_TIMEOUT = 180 +# A pending marker older than this belongs to a launch that hung; the janitor kills it. +# Deliberately above the local-server startup timeout so a slow-but-healthy launch is never shot. +_LAUNCH_TIMEOUT = 180 +# A retired server still alive this long after retirement is hard-killed; one that survives +# even that (e.g. not ours to signal) is dropped from tracking after the give-up age. +_RETIRE_KILL_AFTER = 30 +_RETIRE_GIVE_UP = 600 +# Unreferenced member directories older than this are removed. The age gate keeps the logs of +# a just-failed launch around long enough to be looked at. +_DIR_GC_AGE = 24 * 3600 + +_DEFAULT_POOL_SIZE = 2 +_DEFAULT_IDLE_TIMEOUT = 1800 + + +def _pool_size(opts: Dict[str, Any]) -> int: + """The number of ready or in-flight members to keep per fingerprint; at least one. + Malformed values fall back to the default rather than failing session creation over a + tuning knob. + """ + value = opts.get( + "spark.local.connect.pool.size", os.environ.get("SPARK_LOCAL_CONNECT_POOL_SIZE") + ) + try: + return max(1, int(value)) if value is not None else _DEFAULT_POOL_SIZE + except (TypeError, ValueError): + return _DEFAULT_POOL_SIZE + + +def _idle_timeout() -> int: + """Seconds an unclaimed member may sit before it is retired; 0 or negative disables idle + retirement. Read from the environment wherever reaping runs -- clients and attendants + alike -- so there is exactly one source of truth for it. + """ + try: + return int(os.environ["SPARK_LOCAL_CONNECT_POOL_IDLE_TIMEOUT"]) + except (KeyError, ValueError): + return _DEFAULT_IDLE_TIMEOUT + + +def pool_fingerprint(master: str, seed_conf: Dict[str, Any]) -> str: + """The identity of a pool member: everything that shapes the server a run would have + booted for itself. A run only claims members whose fingerprint equals its own, so a + pre-booted JVM is never handed to a run it would not have produced. + + Besides the master and the seeded confs, this covers the working directory (unset + warehouse and Derby metastore locations resolve relative to it) and the Python executable + (the server runs Python UDFs with the interpreter environment it inherited from its + spawner). + """ + identity = [ + master, + sorted((str(k), str(v)) for k, v in seed_conf.items()), + os.getcwd(), + sys.executable, + os.environ.get("PYSPARK_PYTHON", ""), + ] + return hashlib.sha256(json.dumps(identity).encode("utf-8")).hexdigest()[:16] + + +def _pid_alive(pid: int) -> bool: + """Whether ``pid`` is running. A process we cannot signal counts as alive. Linux zombies + count as terminated: they remain signalable until their parent reaps them, but cannot own + or serve a pool member. POSIX only, like everything in this module. + """ + if pid <= 0: + return False + try: + os.kill(pid, 0) + except ProcessLookupError: + return False + except OSError: + pass + if sys.platform.startswith("linux"): + try: + with open(f"/proc/{pid}/stat", encoding="utf-8") as stat_file: + fields = stat_file.read().rpartition(")")[2].split() + except FileNotFoundError: + return False + except OSError: + pass + else: + if fields and fields[0] == "Z": + return False + return True + + +def _signal(pid: int, sig: int) -> bool: + """Best-effort signal; ``False`` when the process is already gone or not ours.""" + if pid <= 0: + return False + try: + os.kill(pid, sig) + return True + except OSError: + return False + + +class PoolMember: + """One published pool server, wrapping its ``server-.json`` record.""" + + def __init__(self, data: Dict[str, Any]): + self.data = data + # Set when this process claims the member; the path of its claimed--.json. + self.claim_path: Optional[str] = None + + @property + def pid(self) -> int: + return int(self.data["pid"]) + + @property + def token(self) -> str: + return self.data["token"] + + @property + def created(self) -> float: + return float(self.data.get("created", 0)) + + @property + def fingerprint(self) -> Optional[str]: + return self.data.get("fingerprint") + + @property + def url(self) -> str: + return f"sc://{self.data['host']}:{self.data['port']}" + + def is_usable(self) -> bool: + """Whether this member can serve a run: complete record, matching Spark version, live + process, and accepting connections.""" + from pyspark.version import __version__ + + if not all(k in self.data for k in ("host", "port", "token", "pid", "spark_version")): + return False + if self.data["spark_version"] != __version__ or not _pid_alive(self.pid): + return False + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock: + sock.settimeout(0.5) + return sock.connect_ex((self.data["host"], int(self.data["port"]))) == 0 + + +class PoolDirectory: + """Path layout, file access, and the cross-process lock of one pool directory. + + Used as a context manager that holds the directory's exclusive lock:: + + with PoolDirectory() as directory: + ...read and write state files... + + Acquire runs once per client process, so the simplicity of one exclusive lock for every + access beats any finer-grained scheme; the rename in ``ServerPool.claim`` is for tidiness, + not lock-free atomicity. The context is reentrant per instance in the sense that it can be + entered again after exiting, which the acquire loop does once per poll so that attendants + get their turn at publishing. + """ + + def __init__(self, path: Optional[str] = None): + if path is None: + path = os.environ.get("SPARK_LOCAL_CONNECT_POOL_DIR") + if path is None: + from pyspark.sql.connect.local_server import runtime_dir + + path = os.path.join(runtime_dir(), "pool") + self.path = os.path.abspath(path) + self._lock_fd: Optional[int] = None + + def __enter__(self) -> "PoolDirectory": + import fcntl + + os.makedirs(self.path, mode=0o700, exist_ok=True) + self._lock_fd = os.open(os.path.join(self.path, ".lock"), os.O_RDWR | os.O_CREAT, 0o600) + fcntl.flock(self._lock_fd, fcntl.LOCK_EX) + return self + + def __exit__(self, exc_type: Any, exc_value: Any, traceback: Any) -> None: + assert self._lock_fd is not None + os.close(self._lock_fd) # closing releases the lock + self._lock_fd = None + + def _assert_locked(self) -> None: + assert self._lock_fd is not None, "PoolDirectory must be used as a context manager" + + # Path builders; these do not touch the filesystem and need no lock. + + def pending_path(self, uid: str) -> str: + return os.path.join(self.path, f"pending-{uid}.json") + + def conf_path(self, uid: str) -> str: + return os.path.join(self.path, f"conf-{uid}.json") + + def server_path(self, uid: str) -> str: + return os.path.join(self.path, f"server-{uid}.json") + + def claimed_path(self, client_pid: int, uid: str) -> str: + return os.path.join(self.path, f"claimed-{client_pid}-{uid}.json") + + def retired_path(self, uid: str) -> str: + return os.path.join(self.path, f"retired-{uid}.json") + + def member_dir(self, uid: str) -> str: + return os.path.join(self.path, f"member-{uid}") + + @staticmethod + def parse_entry(name: str) -> Tuple[Optional[str], Optional[str]]: + """The ``(kind, uid)`` of a pool directory entry, ``(None, None)`` for anything + else (the lock file, editor droppings, ...).""" + if name.startswith("member-"): + return "member", name[len("member-") :] + if not name.endswith(".json"): + return None, None + stem = name[: -len(".json")] + for kind in ("pending", "conf", "server", "retired"): + if stem.startswith(kind + "-"): + return kind, stem[len(kind) + 1 :] + if stem.startswith("claimed-"): + client_pid, sep, uid = stem[len("claimed-") :].partition("-") + if sep and client_pid.isdigit(): + return "claimed", uid + return None, None + + @staticmethod + def claiming_pid(claimed_path: str) -> int: + """The client pid recorded in a ``claimed--.json`` file name.""" + return int(os.path.basename(claimed_path)[len("claimed-") :].split("-", 1)[0]) + + # Locked accessors. + + def uids(self) -> List[str]: + self._assert_locked() + seen = [] + for name in self._entries(): + _, uid = self.parse_entry(name) + if uid is not None and uid not in seen: + seen.append(uid) + return seen + + def states(self, uid: str) -> Dict[str, str]: + """The state entries currently existing for ``uid``, as ``{kind: path}`` with kinds + ``pending``, ``conf``, ``server``, ``claimed``, ``retired``, and ``member`` (the + member's directory).""" + self._assert_locked() + found: Dict[str, str] = {} + for name in self._entries(): + kind, entry_uid = self.parse_entry(name) + if kind is not None and entry_uid == uid: + found[kind] = os.path.join(self.path, name) + return found + + def paths_of_kind(self, kind: str) -> List[Tuple[str, str]]: + """All ``(uid, path)`` of one state kind.""" + self._assert_locked() + return [ + (uid, os.path.join(self.path, name)) + for name in self._entries() + for entry_kind, uid in (self.parse_entry(name),) + if entry_kind == kind and uid is not None + ] + + def _entries(self) -> List[str]: + try: + return sorted(os.listdir(self.path)) + except OSError: + return [] + + def read_json(self, path: str) -> Optional[Dict[str, Any]]: + """``None`` for files that are missing or unreadable -- callers treat both like the + state not existing, and the reaping rules remove unreadable leftovers.""" + self._assert_locked() + try: + with open(path, "r") as f: + data = json.load(f) + except (OSError, ValueError): + return None + return data if isinstance(data, dict) else None + + def write_json(self, path: str, data: Dict[str, Any]) -> None: + self._assert_locked() + # 0600 like the reuse discovery file: server entries hold the auth token. + fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600) + with os.fdopen(fd, "w") as f: + os.fchmod(fd, 0o600) + f.write(json.dumps(data)) + + def rename(self, src: str, dst: str) -> None: + self._assert_locked() + os.rename(src, dst) + + def remove(self, path: str) -> None: + self._assert_locked() + with contextlib.suppress(FileNotFoundError): + os.remove(path) + + def remove_member_dir(self, uid: str) -> None: + self._assert_locked() + shutil.rmtree(self.member_dir(uid), ignore_errors=True) + + +class ServerPool: + """Claiming, refilling, and reaping the members of one pool directory.""" + + def __init__(self, directory: Optional[PoolDirectory] = None): + self._directory = directory or PoolDirectory() + + def acquire(self, master: str, opts: Dict[str, Any]) -> PoolMember: + """Claim one ready, fingerprint-matching member and top the pool back up. + + When a member is ready this returns after one janitor-claim-refill pass; on a cold pool + (first run, conf change, or all members consumed) the refill starts a full complement + and the loop waits for the first member to become ready, which costs one ordinary + cold start. The lock is released between polls so attendants can publish; launches + that die are relaunched by later passes, and only the overall deadline fails. + """ + from pyspark.sql.connect.local_server import startup_seed_conf + + seed_conf = startup_seed_conf(opts) + fingerprint = pool_fingerprint(master, seed_conf) + target = _pool_size(opts) + deadline = time.monotonic() + _ACQUIRE_TIMEOUT + while True: + with self._directory: + self.janitor() + member = self.claim(fingerprint) + self.refill(master, seed_conf, fingerprint, target) + if member is not None: + return member + if time.monotonic() >= deadline: + raise PySparkRuntimeError( + errorClass="LOCAL_CONNECT_SERVER_START_FAILED", + messageParameters={ + "reason": f"no pooled local server became ready within " + f"{_ACQUIRE_TIMEOUT}s; see the attendant and server logs under " + f"{self._directory.path}" + }, + ) + time.sleep(0.25) + + def claim(self, fingerprint: str) -> Optional[PoolMember]: + """Claim the oldest usable member with this fingerprint, or ``None``. The rename to + ``claimed--.json`` marks the member as owned by this process; the reaping + rules use that pid to retire members whose client died without releasing them.""" + candidates = [] + for uid, path in self._directory.paths_of_kind("server"): + data = self._directory.read_json(path) + if data is not None and data.get("fingerprint") == fingerprint: + candidates.append((PoolMember(data), uid, path)) + candidates.sort(key=lambda c: c[0].created) + for member, uid, path in candidates: + if not member.is_usable(): + continue # left for the reaping rules to retire + claim_path = self._directory.claimed_path(os.getpid(), uid) + self._directory.rename(path, claim_path) + member.claim_path = claim_path + return member + return None + + def refill(self, master: str, seed_conf: Dict[str, Any], fingerprint: str, target: int) -> None: + """Launch members until ready or in-flight ones with this fingerprint reach + ``target``. Running under the directory lock is what makes concurrent cold starters + share one complement of launches instead of each spawning their own.""" + available = 0 + for kind in ("server", "pending"): + for _, path in self._directory.paths_of_kind(kind): + data = self._directory.read_json(path) + if data is not None and data.get("fingerprint") == fingerprint: + available += 1 + for _ in range(target - available): + MemberAttendant.spawn(self._directory, master, seed_conf, fingerprint) + + def janitor(self) -> None: + """Reap leftovers of launches, clients, and attendants that died uncleanly. Every + rule is idempotent, so successive passes from any process are safe.""" + for uid in self._directory.uids(): + self.reap(uid) + + def reap(self, uid: str) -> bool: + """Apply the reaping rules to one member; ``True`` when nothing of it remains. + Shared by the janitor (all members) and by each attendant supervising its own member. + """ + states = self._directory.states(uid) + if "pending" in states: + self._reap_pending(uid, states["pending"]) + if "server" in states: + self._reap_server(states["server"]) + if "claimed" in states: + self._reap_claimed(states["claimed"]) + if "retired" in states: + self._reap_retired(uid, states["retired"]) + + remaining = self._directory.states(uid) + if set(remaining) == {"member"}: + # Nothing references the member directory anymore. The age gate keeps the logs + # of a freshly failed launch around long enough to be looked at. + try: + expired = time.time() - os.path.getmtime(remaining["member"]) > _DIR_GC_AGE + except OSError: + expired = True + if expired: + self._directory.remove_member_dir(uid) + remaining = self._directory.states(uid) + return not remaining + + def _reap_pending(self, uid: str, path: str) -> None: + """A launch whose attendant died or hung: kill the attendant and whatever server + spark-daemon.sh may have recorded for it, and withdraw the launch's bookkeeping so + refills stop counting it.""" + data = self._directory.read_json(path) + attendant_pid = int(data["attendant_pid"]) if data else -1 + age = time.time() - float(data["created"]) if data else _LAUNCH_TIMEOUT + 1 + if not _pid_alive(attendant_pid) or age > _LAUNCH_TIMEOUT: + _signal(attendant_pid, signal.SIGTERM) + self.abort_launch(uid) + + def abort_launch(self, uid: str) -> None: + """Withdraw a failed launch and stop any server it started before failing.""" + self._kill_recorded_daemon(uid) + self._directory.remove(self._directory.pending_path(uid)) + self._directory.remove(self._directory.conf_path(uid)) + + def _reap_server(self, path: str) -> None: + """A ready member that is unusable (dead, unreachable, version-mismatched after an + upgrade, or an unreadable record) or has sat unclaimed past the idle timeout: retire + it.""" + data = self._directory.read_json(path) + member = PoolMember(data) if data is not None else None + idle = _idle_timeout() + expired = member is not None and idle > 0 and time.time() - member.created > idle + if member is None or expired or not member.is_usable(): + self._retire(path, member.pid if member is not None else -1) + + def _reap_claimed(self, path: str) -> None: + """A claimed member whose client died without releasing it (e.g. SIGKILL), or whose + server died under its client: retire it. Claims of this live process are its own.""" + data = self._directory.read_json(path) + server_pid = int(data["pid"]) if data else -1 + client_pid = self._directory.claiming_pid(path) + if client_pid == os.getpid(): + return + if not _pid_alive(client_pid) or not _pid_alive(server_pid): + self._retire(path, server_pid) + + def _reap_retired(self, uid: str, path: str) -> None: + """A retiring member: drop it once its server is gone, hard-kill the server if it + hangs in shutdown, and eventually stop tracking one that survives even that (it is + not ours to signal; nothing more can be done).""" + data = self._directory.read_json(path) + server_pid = int(data["pid"]) if data else -1 + age = time.time() - float(data["retired"]) if data else _RETIRE_GIVE_UP + 1 + if not _pid_alive(server_pid) or age > _RETIRE_GIVE_UP: + self._directory.remove(path) + self._directory.remove_member_dir(uid) + elif age > _RETIRE_KILL_AFTER: + _signal(server_pid, signal.SIGKILL) + + def _retire(self, state_path: str, server_pid: int) -> None: + """Move a member into the retired state: signal its server and track the shutdown so + :meth:`_reap_retired` can escalate if the JVM hangs.""" + _, uid = self._directory.parse_entry(os.path.basename(state_path)) + assert uid is not None + _signal(server_pid, signal.SIGTERM) + self._directory.remove(state_path) + self._directory.write_json( + self._directory.retired_path(uid), {"pid": server_pid, "retired": time.time()} + ) + + def _kill_recorded_daemon(self, uid: str) -> None: + """Kill the server pid that spark-daemon.sh recorded in the member directory, if + any. This is how a half-started JVM is reaped when its launch fails or its attendant + dies before publishing.""" + from pyspark.sql.connect.local_server import Discovery + + discovery = Discovery(os.path.join(self._directory.member_dir(uid), "connect-local.json")) + daemon_pid = discovery.daemon_pid() + if daemon_pid is not None: + _signal(daemon_pid, signal.SIGTERM) + + def release(self, member: PoolMember) -> None: + """Retire this process's claimed member; the shutdown completes in the background, + watched by the member's attendant with the janitor as backstop.""" + assert member.claim_path is not None + with self._directory: + self._retire(member.claim_path, member.pid) + + def purge(self) -> int: + """Force-stop every member -- ready, in-flight, or claimed -- and empty the pool + directory; the escape hatch back to a clean slate. Returns the number of processes + signalled. SIGKILL rather than SIGTERM because nothing tracks a member once its + state files are gone, so a shutdown that hangs would leak. Supervising attendants + hold no state file; they notice the emptied directory and exit on their own.""" + signalled = 0 + with self._directory: + for uid in self._directory.uids(): + for kind, path in self._directory.states(uid).items(): + if kind == "member": + continue + data = self._directory.read_json(path) + for key in ("pid", "attendant_pid"): + if data is not None and key in data: + if _signal(int(data[key]), signal.SIGKILL): + signalled += 1 + self._directory.remove(path) + self._kill_recorded_daemon(uid) + self._directory.remove_member_dir(uid) + return signalled + + +# The member this client process has claimed, if any. Module-level so the session's stop +# callback and atexit share one idempotent release path. +_claimed_member: Optional[PoolMember] = None +_release_registered = False + + +def acquire_pooled_local_connect_server(master: str, opts: Dict[str, Any]) -> str: + """Claim a local Connect server not previously assigned to an application run. + + Returns the ``sc://host:port`` endpoint and sets ``SPARK_CONNECT_AUTHENTICATE_TOKEN`` so + the client authenticates against that server. Only reached for a ``local`` master when the + pool opt-in is set; see ``SparkSession.getOrCreate`` in ``pyspark.sql.session``. + """ + global _claimed_member, _release_registered + if os.name != "posix": + raise PySparkRuntimeError( + errorClass="LOCAL_CONNECT_SERVER_START_FAILED", + messageParameters={ + "reason": "spark.local.connect.pool relies on the POSIX scripts under sbin/; " + "on this platform start a server manually (sbin/start-connect-server.sh) and " + 'connect with .remote("sc://...")' + }, + ) + # getOrCreate() may be re-entered while this process already holds a live claimed member + # (the connect layer then returns the existing session); claiming again would strand a + # second server. + if _claimed_member is not None and _pid_alive(_claimed_member.pid): + os.environ["SPARK_CONNECT_AUTHENTICATE_TOKEN"] = _claimed_member.token + return _claimed_member.url + + member = ServerPool().acquire(master, opts) + _claimed_member = member + if not _release_registered: + # A client that exits without stopping its session still releases its member; the + # member's attendant and the janitor cover clients that die uncleanly. + atexit.register(release_pooled_local_connect_server) + _release_registered = True + os.environ["SPARK_CONNECT_AUTHENTICATE_TOKEN"] = member.token + return member.url + + +def release_pooled_local_connect_server() -> None: + """Retire this process's claimed pooled server; safe to call when there is none. The + server winds down in the background while this client moves on.""" + global _claimed_member + member, _claimed_member = _claimed_member, None + if member is not None: + ServerPool().release(member) + + +def purge_local_connect_pool() -> int: + """See :meth:`ServerPool.purge`. Also available as + ``python -m pyspark.sql.connect.local_server_pool --purge``.""" + return ServerPool().purge() + + +class MemberAttendant: + """Boots one pool member, publishes it, and supervises it until it is gone. + + Runs detached from the spawning client (``--attend``). Every phase is appended to + ``member-/attendant.log`` so a member that misbehaved can be debugged after the + fact. A failed boot reaps whatever half-started server spark-daemon.sh recorded and + withdraws the launch's bookkeeping, so refills stop counting it. + """ + + @classmethod + def spawn( + cls, + directory: PoolDirectory, + master: str, + seed_conf: Dict[str, Any], + fingerprint: str, + ) -> None: + """Start one detached attendant and publish its in-flight launch. + + Callers hold ``directory``'s lock, so another refill sees the pending marker before it + can start a duplicate launch for the same pool complement. + """ + uid = uuid.uuid4().hex[:12] + directory.write_json(directory.conf_path(uid), seed_conf) + cmd = [ + sys.executable, + "-m", + "pyspark.sql.connect.local_server_pool", + "--attend", + "--pool-dir", + directory.path, + "--uid", + uid, + "--master", + master, + "--fingerprint", + fingerprint, + ] + env = dict(os.environ) + # The attendant must neither see this client's Connect mode nor inherit its auth + # token: each member gets its own token from LocalConnectServer. + for var in ( + "SPARK_REMOTE", + "SPARK_LOCAL_REMOTE", + "SPARK_CONNECT_MODE_ENABLED", + "SPARK_CONNECT_AUTHENTICATE_TOKEN", + ): + env.pop(var, None) + try: + proc = subprocess.Popen( + cmd, + env=env, + stdin=subprocess.DEVNULL, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + start_new_session=True, + ) + except OSError: + directory.remove(directory.conf_path(uid)) + raise + directory.write_json( + directory.pending_path(uid), + {"attendant_pid": proc.pid, "created": time.time(), "fingerprint": fingerprint}, + ) + + def __init__(self, directory: PoolDirectory, uid: str, master: str, fingerprint: str): + self._directory = directory + self._pool = ServerPool(directory) + self._uid = uid + self._master = master + self._fingerprint = fingerprint + self._member_dir = directory.member_dir(uid) + + def run(self) -> int: + os.makedirs(self._member_dir, mode=0o700, exist_ok=True) + member = self._boot() + if member is None: + return 1 + self._log("supervising") + self._supervise(member.pid) + self._log("done") + return 0 + + def _log(self, message: str) -> None: + with open(os.path.join(self._member_dir, "attendant.log"), "a") as f: + f.write(f"{time.time():.3f} {message}\n") + + def _boot(self) -> Optional[PoolMember]: + """Start the server on a fresh ephemeral port and publish it as ready-to-claim. + Publishing consumes the launch's pending marker and conf seed: from that moment the + member counts as a server, and this process's job shifts to supervising it.""" + from pyspark.sql.connect.local_server import Discovery, LocalConnectServer + + with self._directory: + seed_conf = self._directory.read_json(self._directory.conf_path(self._uid)) or {} + discovery = Discovery(os.path.join(self._member_dir, "connect-local.json")) + self._log(f"booting master={self._master}") + try: + with discovery: + server = LocalConnectServer(discovery) + server.start( + self._master, + {}, + use_ephemeral_port=True, + seed_conf=seed_conf, + ) + data = discovery.load() + assert data is not None # launch() saved it + except Exception as e: + self._log(f"boot failed: {e!r}") + with self._directory: + self._pool.abort_launch(self._uid) + return None + data["fingerprint"] = self._fingerprint + data["created"] = time.time() + with self._directory: + self._directory.write_json(self._directory.server_path(self._uid), data) + self._directory.remove(self._directory.pending_path(self._uid)) + self._directory.remove(self._directory.conf_path(self._uid)) + self._log(f"published pid={data['pid']} port={data['port']}") + return PoolMember(data) + + def _supervise(self, server_pid: int) -> None: + """Watch this member until nothing of it remains, applying the pool's reaping rules. + + This is what lets an idle machine drain to zero servers with no further Spark run: the + idle timeout, dead-client cleanup, and hard-kill escalation all fire from here even + when no client ever runs again. + """ + while True: + with self._directory: + if self._pool.reap(self._uid): + return + states = set(self._directory.states(self._uid)) + if states <= {"member"} and not _pid_alive(server_pid): + # A purge or another process's janitor already tore the member down; only + # the young member directory remains. + self._directory.remove_member_dir(self._uid) + return + time.sleep(2) + + +def main() -> None: + parser = argparse.ArgumentParser( + description="Manage the opt-in pool of single-use local Spark Connect servers " + "(spark.local.connect.pool)." + ) + parser.add_argument( + "--purge", + action="store_true", + help="force-stop every pool member and empty the pool directory", + ) + # Internal entry point spawned by ServerPool. + parser.add_argument("--attend", action="store_true", help=argparse.SUPPRESS) + parser.add_argument("--pool-dir", help=argparse.SUPPRESS) + parser.add_argument("--uid", help=argparse.SUPPRESS) + parser.add_argument("--master", help=argparse.SUPPRESS) + parser.add_argument("--fingerprint", help=argparse.SUPPRESS) + args = parser.parse_args() + + if args.purge: + print(f"Signalled {purge_local_connect_pool()} pool process(es).") + elif args.attend: + attendant = MemberAttendant( + PoolDirectory(args.pool_dir), args.uid, args.master, args.fingerprint + ) + sys.exit(attendant.run()) + else: + parser.print_help(sys.stderr) + sys.exit(2) + + +if __name__ == "__main__": + main() diff --git a/python/pyspark/sql/connect/session.py b/python/pyspark/sql/connect/session.py index 1a3e1c1aebb39..8e1f7e46f30ce 100644 --- a/python/pyspark/sql/connect/session.py +++ b/python/pyspark/sql/connect/session.py @@ -310,8 +310,19 @@ def __init__( ) self._session_id = self._client._session_id + self._initialize_lifecycle_state() + + def _initialize_lifecycle_state(self) -> None: + """Initialize state shared by normally constructed and derived sessions.""" + # Set to false to prevent client.release_session on close() (testing only) self.release_session_on_close = True + self._on_stop_callbacks: List[Callable[[], None]] = [] + + def _register_on_stop_callback(self, callback: Callable[[], None]) -> None: + """Register internal lifecycle cleanup owned by the code that created this session.""" + if callback not in self._on_stop_callbacks: + self._on_stop_callbacks.append(callback) @classmethod def _set_default_and_active_session(cls, session: "SparkSession") -> None: @@ -991,6 +1002,13 @@ def stop(self) -> None: if "SPARK_REMOTE" in os.environ: del os.environ["SPARK_REMOTE"] + callbacks, self._on_stop_callbacks = self._on_stop_callbacks, [] + for callback in callbacks: + try: + callback() + except Exception as e: + logger.warning(f"session.stop(): Cleanup callback failed. Error: {e}") + def __enter__(self) -> "SparkSession": """ Enable 'with SparkSession.builder.(...).getOrCreate() as session: app' syntax. @@ -1370,7 +1388,7 @@ def cloneSession(self, new_session_id: Optional[str] = None) -> "SparkSession": new_session = object.__new__(SparkSession) new_session._client = cloned_client new_session._session_id = cloned_client._session_id - new_session.release_session_on_close = True + new_session._initialize_lifecycle_state() return new_session def newSession(self) -> "SparkSession": @@ -1398,7 +1416,7 @@ def newSession(self) -> "SparkSession": new_session = object.__new__(SparkSession) new_session._client = new_client new_session._session_id = new_client._session_id - new_session.release_session_on_close = True + new_session._initialize_lifecycle_state() return new_session diff --git a/python/pyspark/sql/session.py b/python/pyspark/sql/session.py index e9398bd512782..73789e028bfd5 100644 --- a/python/pyspark/sql/session.py +++ b/python/pyspark/sql/session.py @@ -523,6 +523,14 @@ def getOrCreate(self) -> "SparkSession": messageParameters={}, ) + pool_cleanup: Optional[Callable[[], None]] = None + pool_local = str( + opts.get( + "spark.local.connect.pool", + os.environ.get("SPARK_LOCAL_CONNECT_POOL", ""), + ) + ).lower() in ("1", "true") + reuse_local = str( opts.get( "spark.local.connect.reuse", @@ -530,7 +538,23 @@ def getOrCreate(self) -> "SparkSession": ) ).lower() in ("1", "true") - if url.startswith("local") and reuse_local: + if url.startswith("local") and pool_local: + from pyspark.sql.connect.local_server_pool import ( + acquire_pooled_local_connect_server, + release_pooled_local_connect_server, + ) + + # Opt-in: claim a server not previously assigned to an + # application run from a pool of booted local Connect servers. + # It is torn down when this run's session stops, so no state + # carries across runs. Takes precedence over reuse. See + # `pyspark.sql.connect.local_server_pool`. + url = acquire_pooled_local_connect_server(url, opts) + pool_cleanup = release_pooled_local_connect_server + for k in list(opts): + if k.startswith("spark.local.connect."): + opts.pop(k) + elif url.startswith("local") and reuse_local: from pyspark.sql.connect.local_server import ( reuse_or_start_local_connect_server, ) @@ -551,9 +575,27 @@ def getOrCreate(self) -> "SparkSession": os.environ["SPARK_CONNECT_MODE_ENABLED"] = "1" opts["spark.remote"] = url + try: + remote_session = RemoteSparkSession.builder.config( + map=opts + ).getOrCreate() + except Exception: + if pool_cleanup is not None: + try: + pool_cleanup() + except Exception as cleanup_error: + warnings.warn( + "Failed to clean up a pooled local Connect server " + f"after session creation failed: {cleanup_error}", + RuntimeWarning, + stacklevel=2, + ) + raise + if pool_cleanup is not None: + remote_session._register_on_stop_callback(pool_cleanup) return cast( SparkSession, - RemoteSparkSession.builder.config(map=opts).getOrCreate(), + remote_session, ) elif "SPARK_LOCAL_REMOTE" in os.environ: url = "sc://localhost" diff --git a/python/pyspark/sql/tests/connect/test_connect_local_server.py b/python/pyspark/sql/tests/connect/test_connect_local_server.py index eba26972ae8c3..857ea02e4060e 100644 --- a/python/pyspark/sql/tests/connect/test_connect_local_server.py +++ b/python/pyspark/sql/tests/connect/test_connect_local_server.py @@ -114,6 +114,65 @@ def test_discovery_location(self) -> None: self.assertIn("spark-connect-{}".format(getpass.getuser()), default.directory) self.assertEqual(os.stat(default.directory).st_mode & 0o777, 0o700) + def test_startup_seed_conf(self) -> None: + from unittest import mock + + initial = { + "spark.sql.shuffle.partitions": "8", + "spark.master": "local[1]", + } + opts = { + "spark.sql.warehouse.dir": os.path.join(self._tmpdir, "warehouse"), + "spark.local.connect.reuse": "true", + "spark.connect.grpc.binding.port": "0", + } + env = { + "PYSPARK_REMOTE_INIT_CONF_LEN": "1", + "PYSPARK_REMOTE_INIT_CONF_0": json.dumps(initial), + } + with mock.patch.dict(os.environ, env): + self.assertEqual( + local_server.startup_seed_conf(opts), + { + "spark.sql.shuffle.partitions": "8", + "spark.sql.warehouse.dir": opts["spark.sql.warehouse.dir"], + }, + ) + + def test_start_delegates_launch_options(self) -> None: + from unittest import mock + + discovery = mock.Mock() + discovery.load.side_effect = [ + None, + { + "host": "localhost", + "port": 15002, + "token": "t", + "pid": os.getpid(), + "spark_version": __version__, + }, + ] + server = LocalConnectServer(discovery) + seed_conf = {"spark.sql.shuffle.partitions": "4"} + with mock.patch.object(local_server, "ServerLauncher") as launcher: + server.start( + "local[2]", + {"spark.local.connect.reuse": "true"}, + use_ephemeral_port=True, + seed_conf=seed_conf, + ) + + launcher.assert_called_once_with( + "local[2]", + {"spark.local.connect.reuse": "true"}, + discovery, + use_ephemeral_port=True, + seed_conf=seed_conf, + ) + launcher.return_value.launch.assert_called_once_with() + self.assertEqual(server.port, 15002) + def test_discovery_roundtrip(self) -> None: with Discovery() as discovery: saved = self._server(port=15002) diff --git a/python/pyspark/sql/tests/connect/test_connect_local_server_pool.py b/python/pyspark/sql/tests/connect/test_connect_local_server_pool.py new file mode 100644 index 0000000000000..8b94a75444f81 --- /dev/null +++ b/python/pyspark/sql/tests/connect/test_connect_local_server_pool.py @@ -0,0 +1,598 @@ +# +# 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. +# + +import contextlib +import json +import os +import shutil +import subprocess +import sys +import tempfile +import textwrap +import time +import unittest + +from pyspark.util import is_remote_only +from pyspark.testing.connectutils import should_test_connect, connect_requirement_message + +if should_test_connect: + from pyspark.sql.connect import local_server_pool + from pyspark.sql.connect.local_server_pool import ( + MemberAttendant, + PoolDirectory, + PoolMember, + ServerPool, + pool_fingerprint, + ) + from pyspark.version import __version__ + + +@contextlib.contextmanager +def _listening_socket(): + import socket + + listener = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + try: + listener.bind(("localhost", 0)) + listener.listen(1) + yield listener.getsockname()[1] + finally: + listener.close() + + +def _closed_port() -> int: + """A port with nothing listening on it.""" + import socket + + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock: + sock.bind(("localhost", 0)) + return sock.getsockname()[1] + + +def _spawn_sleeper() -> "subprocess.Popen": + """A long sleeper standing in for a pool server or attendant process.""" + return subprocess.Popen( + [sys.executable, "-c", "import time; time.sleep(300)"], + stdin=subprocess.DEVNULL, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) + + +def _spawn_stubborn_sleeper() -> "subprocess.Popen": + """A sleeper that ignores SIGTERM, standing in for a server hanging in shutdown. It + prints one line once its handler is installed so tests do not signal it too early.""" + proc = subprocess.Popen( + [ + sys.executable, + "-c", + "import signal, time\n" + "signal.signal(signal.SIGTERM, signal.SIG_IGN)\n" + "print('ready', flush=True)\n" + "time.sleep(300)", + ], + stdin=subprocess.DEVNULL, + stdout=subprocess.PIPE, + stderr=subprocess.DEVNULL, + text=True, + ) + assert proc.stdout is not None + proc.stdout.readline() + return proc + + +def _wait_proc_dead(proc: "subprocess.Popen", timeout: float = 30.0) -> bool: + try: + proc.wait(timeout=timeout) + return True + except subprocess.TimeoutExpired: + return False + + +def _wait_pid_gone(pid: int, timeout: float = 60.0) -> bool: + """Wait for a non-child pid to stop running. Linux zombies count as terminated.""" + deadline = time.time() + timeout + while time.time() < deadline: + if not local_server_pool._pid_alive(pid): + return True + time.sleep(0.2) + return False + + +_SAVED_ENV_KEYS = ( + "SPARK_LOCAL_CONNECT_POOL", + "SPARK_LOCAL_CONNECT_POOL_DIR", + "SPARK_LOCAL_CONNECT_POOL_SIZE", + "SPARK_LOCAL_CONNECT_POOL_IDLE_TIMEOUT", + "SPARK_CONNECT_AUTHENTICATE_TOKEN", + "PYSPARK_PYTHON", +) + + +@unittest.skipIf( + not should_test_connect or is_remote_only(), + connect_requirement_message or "Requires Spark Connect test dependencies", +) +class LocalConnectServerPoolUnitTests(unittest.TestCase): + """Tests for the pool filesystem model; no real servers are started.""" + + def setUp(self) -> None: + self._tmpdir = tempfile.mkdtemp() + self._saved_env = {k: os.environ.get(k) for k in _SAVED_ENV_KEYS} + for k in _SAVED_ENV_KEYS: + os.environ.pop(k, None) + os.environ["SPARK_LOCAL_CONNECT_POOL_DIR"] = os.path.join(self._tmpdir, "pool") + self._directory = PoolDirectory() + self._pool = ServerPool(self._directory) + self._procs = [] + local_server_pool._claimed_member = None + + def tearDown(self) -> None: + local_server_pool._claimed_member = None + for proc in self._procs: + try: + proc.kill() + proc.communicate(timeout=10) + except Exception: + pass + for k, v in self._saved_env.items(): + if v is None: + os.environ.pop(k, None) + else: + os.environ[k] = v + shutil.rmtree(self._tmpdir, ignore_errors=True) + + def _sleeper(self) -> "subprocess.Popen": + proc = _spawn_sleeper() + self._procs.append(proc) + return proc + + def _stubborn_sleeper(self) -> "subprocess.Popen": + proc = _spawn_stubborn_sleeper() + self._procs.append(proc) + return proc + + def _server_data(self, port: int, pid: int, fingerprint: str = "fp", **overrides) -> dict: + data = { + "host": "localhost", + "port": port, + "token": "t", + "pid": pid, + "spark_version": __version__, + "fingerprint": fingerprint, + "created": time.time(), + } + data.update(overrides) + return data + + def _write_state(self, path: str, data: dict) -> str: + with self._directory as directory: + directory.write_json(path, data) + return path + + def _states(self, uid: str) -> dict: + with self._directory as directory: + return directory.states(uid) + + def test_pool_directory_location(self) -> None: + self.assertEqual(self._directory.path, os.path.join(self._tmpdir, "pool")) + os.environ.pop("SPARK_LOCAL_CONNECT_POOL_DIR") + default = PoolDirectory() + self.assertEqual(os.path.basename(default.path), "pool") + self.assertTrue(default.path.startswith(tempfile.gettempdir())) + + def test_pool_size_parsing(self) -> None: + from pyspark.sql.connect.local_server_pool import _pool_size + + self.assertEqual(_pool_size({}), 2) + self.assertEqual(_pool_size({"spark.local.connect.pool.size": "3"}), 3) + os.environ["SPARK_LOCAL_CONNECT_POOL_SIZE"] = "5" + self.assertEqual(_pool_size({}), 5) + # Junk falls back to the default; values below one are clamped up. + self.assertEqual(_pool_size({"spark.local.connect.pool.size": "abc"}), 2) + self.assertEqual(_pool_size({"spark.local.connect.pool.size": "0"}), 1) + + @unittest.skipUnless( + sys.platform.startswith("linux") and os.path.isdir("/proc"), + "requires Linux process state", + ) + def test_pid_alive_treats_zombie_as_dead(self) -> None: + proc = subprocess.Popen([sys.executable, "-c", "pass"]) + try: + state = "" + deadline = time.time() + 5 + while time.time() < deadline: + with open(f"/proc/{proc.pid}/stat", encoding="utf-8") as stat_file: + fields = stat_file.read().rpartition(")")[2].split() + state = fields[0] if fields else "" + if state == "Z": + break + time.sleep(0.01) + self.assertEqual(state, "Z", "the child did not enter zombie state") + self.assertFalse(local_server_pool._pid_alive(proc.pid)) + finally: + proc.wait(timeout=10) + + def test_fingerprint_identity(self) -> None: + base = pool_fingerprint("local[*]", {"spark.sql.shuffle.partitions": "4"}) + self.assertEqual(base, pool_fingerprint("local[*]", {"spark.sql.shuffle.partitions": "4"})) + self.assertNotEqual( + base, pool_fingerprint("local[2]", {"spark.sql.shuffle.partitions": "4"}) + ) + self.assertNotEqual( + base, pool_fingerprint("local[*]", {"spark.sql.shuffle.partitions": "8"}) + ) + self.assertNotEqual(base, pool_fingerprint("local[*]", {})) + # The working directory shapes the server (relative warehouse and metastore paths), + # so members are never shared across directories. + cwd = os.getcwd() + try: + os.chdir(self._tmpdir) + self.assertNotEqual(base, pool_fingerprint("local[*]", {"x": "4"})) + in_tmpdir = pool_fingerprint("local[*]", {"spark.sql.shuffle.partitions": "4"}) + finally: + os.chdir(cwd) + self.assertNotEqual(base, in_tmpdir) + # So does the Python environment the server would run UDFs with. + os.environ["PYSPARK_PYTHON"] = "/some/other/python" + self.assertNotEqual( + base, pool_fingerprint("local[*]", {"spark.sql.shuffle.partitions": "4"}) + ) + + def test_claim_matches_fingerprint_and_renames(self) -> None: + with _listening_socket() as port: + sleeper = self._sleeper() + self._write_state( + self._directory.server_path("aaa"), + self._server_data(port, sleeper.pid, fingerprint="other-fp"), + ) + self._write_state( + self._directory.server_path("bbb"), + self._server_data(port, sleeper.pid, fingerprint="my-fp", token="t-bbb"), + ) + with self._directory: + member = self._pool.claim("my-fp") + self.assertIsNotNone(member) + self.assertEqual(member.token, "t-bbb") + claim_name = f"claimed-{os.getpid()}-bbb.json" + self.assertEqual(os.path.basename(member.claim_path), claim_name) + states = self._states("bbb") + self.assertEqual(set(states), {"claimed"}) + # The mismatched member is untouched, and a second claim finds nothing. + self.assertEqual(set(self._states("aaa")), {"server"}) + with self._directory: + self.assertIsNone(self._pool.claim("my-fp")) + + def test_claim_prefers_the_oldest_member(self) -> None: + with _listening_socket() as port: + sleeper = self._sleeper() + for uid, created in (("young", time.time()), ("old", time.time() - 100)): + self._write_state( + self._directory.server_path(uid), + self._server_data(port, sleeper.pid, token="t-" + uid, created=created), + ) + with self._directory: + member = self._pool.claim("fp") + # Prefer the oldest ready member for deterministic FIFO claiming. + self.assertEqual(member.token, "t-old") + + def test_claim_skips_unreachable_member(self) -> None: + self._write_state( + self._directory.server_path("ccc"), self._server_data(_closed_port(), os.getpid()) + ) + with self._directory: + self.assertIsNone(self._pool.claim("fp")) + + def test_reap_pending_of_dead_attendant(self) -> None: + # The attendant died mid-boot: its pending marker and conf seed are withdrawn, and + # the half-started server whose pid spark-daemon.sh recorded is killed. + from pyspark.sql.connect.local_server import Discovery + + half_started = self._sleeper() + member_dir = self._directory.member_dir("boot") + os.makedirs(member_dir) + discovery = Discovery(os.path.join(member_dir, "connect-local.json")) + with open(discovery.daemon_pid_path, "w") as f: + f.write(str(half_started.pid)) + self._write_state( + self._directory.pending_path("boot"), + {"attendant_pid": 2**31 - 1, "created": time.time(), "fingerprint": "fp"}, + ) + self._write_state(self._directory.conf_path("boot"), {"spark.foo": "bar"}) + + with self._directory: + self._pool.reap("boot") + + states = self._states("boot") + self.assertNotIn("pending", states) + self.assertNotIn("conf", states) + self.assertTrue(_wait_proc_dead(half_started), "the half-started server was not reaped") + + def test_reap_keeps_live_pending(self) -> None: + attendant = self._sleeper() + self._write_state( + self._directory.pending_path("live"), + {"attendant_pid": attendant.pid, "created": time.time(), "fingerprint": "fp"}, + ) + with self._directory: + self._pool.reap("live") + self.assertIn("pending", self._states("live")) + self.assertIsNone(attendant.poll()) + + def test_reap_server_unreachable_and_idle(self) -> None: + with self.subTest("unreachable member is retired"): + gone = self._sleeper() + self._write_state( + self._directory.server_path("dead"), self._server_data(_closed_port(), gone.pid) + ) + with self._directory: + self._pool.reap("dead") + self.assertEqual(set(self._states("dead")), {"retired"}) + self.assertTrue(_wait_proc_dead(gone)) + with self.subTest("member idle past the timeout is retired"): + os.environ["SPARK_LOCAL_CONNECT_POOL_IDLE_TIMEOUT"] = "10" + with _listening_socket() as port: + idle = self._sleeper() + self._write_state( + self._directory.server_path("idle"), + self._server_data(port, idle.pid, created=time.time() - 60), + ) + fresh = self._sleeper() + self._write_state( + self._directory.server_path("fresh"), self._server_data(port, fresh.pid) + ) + with self._directory: + self._pool.janitor() + self.assertEqual(set(self._states("idle")), {"retired"}) + self.assertEqual(set(self._states("fresh")), {"server"}) + self.assertTrue(_wait_proc_dead(idle)) + self.assertIsNone(fresh.poll()) + + def test_reap_claimed_of_dead_client(self) -> None: + orphan = self._sleeper() + dead_client = 2**31 - 1 + self._write_state( + self._directory.claimed_path(dead_client, "orphan"), + self._server_data(_closed_port(), orphan.pid), + ) + ours = self._sleeper() + self._write_state( + self._directory.claimed_path(os.getpid(), "mine"), + self._server_data(_closed_port(), ours.pid), + ) + with self._directory: + self._pool.janitor() + # The orphaned claim is retired and its server stopped; our own claim is untouched. + self.assertEqual(set(self._states("orphan")), {"retired"}) + self.assertTrue(_wait_proc_dead(orphan), "the orphaned server was not stopped") + self.assertEqual(set(self._states("mine")), {"claimed"}) + self.assertIsNone(ours.poll()) + + def test_reap_retired_escalates_to_sigkill(self) -> None: + with self.subTest("a fresh retirement is left to shut down gracefully"): + fresh = self._sleeper() + self._write_state( + self._directory.retired_path("fresh"), + {"pid": fresh.pid, "retired": time.time()}, + ) + with self._directory: + self._pool.reap("fresh") + self.assertEqual(set(self._states("fresh")), {"retired"}) + self.assertIsNone(fresh.poll()) + with self.subTest("a hung shutdown is hard-killed"): + stubborn = self._stubborn_sleeper() + self._write_state( + self._directory.retired_path("hung"), + {"pid": stubborn.pid, "retired": time.time() - 31}, + ) + with self._directory: + self._pool.reap("hung") + self.assertTrue(_wait_proc_dead(stubborn), "SIGKILL escalation did not happen") + with self._directory: + self.assertTrue(self._pool.reap("hung")) + + def test_release_retires_the_claimed_member(self) -> None: + server = self._sleeper() + claim_path = self._write_state( + self._directory.claimed_path(os.getpid(), "xyz"), + self._server_data(_closed_port(), server.pid), + ) + member = PoolMember(self._server_data(_closed_port(), server.pid)) + member.claim_path = claim_path + local_server_pool._claimed_member = member + + local_server_pool.release_pooled_local_connect_server() + + self.assertIsNone(local_server_pool._claimed_member) + states = self._states("xyz") + self.assertEqual(set(states), {"retired"}) + with self._directory as directory: + self.assertEqual(directory.read_json(states["retired"])["pid"], server.pid) + self.assertTrue(_wait_proc_dead(server), "release did not stop the server") + # Releasing again is a no-op. + local_server_pool.release_pooled_local_connect_server() + + def test_purge_kills_everything_and_empties_the_directory(self) -> None: + warm = self._sleeper() + attendant = self._sleeper() + self._write_state( + self._directory.server_path("warm"), self._server_data(_closed_port(), warm.pid) + ) + self._write_state( + self._directory.pending_path("boot"), + {"attendant_pid": attendant.pid, "created": time.time(), "fingerprint": "fp"}, + ) + self._write_state(self._directory.conf_path("boot"), {"spark.foo": "bar"}) + os.makedirs(self._directory.member_dir("warm")) + + signalled = local_server_pool.purge_local_connect_pool() + + self.assertGreaterEqual(signalled, 2) + self.assertEqual(os.listdir(self._directory.path), [".lock"]) + self.assertTrue(_wait_proc_dead(warm)) + self.assertTrue(_wait_proc_dead(attendant)) + + def test_refill_only_counts_matching_members(self) -> None: + from unittest import mock + + with self._directory as directory: + directory.write_json( + directory.server_path("srv"), self._server_data(1, os.getpid(), "my-fp") + ) + directory.write_json( + directory.pending_path("pen"), + {"attendant_pid": os.getpid(), "created": time.time(), "fingerprint": "other"}, + ) + with mock.patch.object(MemberAttendant, "spawn") as spawn: + self._pool.refill("local[2]", {}, "my-fp", target=2) + # One matching member exists (the other-fingerprint launch does not count), so one + # launch tops the pool up to the target of two. + self.assertEqual(spawn.call_count, 1) + + def test_acquire_returns_the_member_already_claimed_by_this_process(self) -> None: + member = PoolMember(self._server_data(15002, os.getpid())) + member.claim_path = "unused" + local_server_pool._claimed_member = member + url = local_server_pool.acquire_pooled_local_connect_server("local[2]", {}) + self.assertEqual(url, "sc://localhost:15002") + self.assertEqual(os.environ.get("SPARK_CONNECT_AUTHENTICATE_TOKEN"), "t") + + def test_acquire_requires_posix(self) -> None: + from unittest import mock + + from pyspark.errors import PySparkRuntimeError + + with mock.patch.object(os, "name", "nt"): + with self.assertRaises(PySparkRuntimeError) as ctx: + local_server_pool.acquire_pooled_local_connect_server("local[2]", {}) + self.assertIn("POSIX", str(ctx.exception)) + + +@unittest.skipIf( + not should_test_connect or is_remote_only(), + connect_requirement_message or "Requires JVM access to start local Connect servers", +) +@unittest.skipUnless(os.name == "posix", "the pool relies on the POSIX sbin scripts") +class LocalConnectServerPoolE2ETests(unittest.TestCase): + """End-to-end tests that boot real pooled servers (slow).""" + + CLIENT = textwrap.dedent( + """ + import json + + from pyspark.sql import SparkSession + + spark = ( + SparkSession.builder.remote("local[2]") + .config("spark.local.connect.pool", "true") + .getOrCreate() + ) + from pyspark.sql.connect import local_server_pool + + try: + # Pool cleanup belongs only to the builder-created session that claimed the + # server. Stopping another session must leave the claimed server available. + spark.newSession().stop() + count = spark.range(1).count() + member = local_server_pool._claimed_member + print(json.dumps({"count": count, "server_pid": member.pid})) + finally: + spark.stop() + """ + ) + + def setUp(self) -> None: + self._tmpdir = tempfile.mkdtemp() + self._pool_dir = os.path.join(self._tmpdir, "pool") + self._saved_env = {k: os.environ.get(k) for k in _SAVED_ENV_KEYS} + os.environ["SPARK_LOCAL_CONNECT_POOL_DIR"] = self._pool_dir + + def tearDown(self) -> None: + try: + local_server_pool.purge_local_connect_pool() + deadline = time.time() + 60 + while time.time() < deadline: + if set(os.listdir(self._pool_dir)) <= {".lock"}: + break + # Supervising attendants notice the purged directory and exit on their own; + # purge again in case one republished state in between. + local_server_pool.purge_local_connect_pool() + time.sleep(1) + finally: + for k, v in self._saved_env.items(): + if v is None: + os.environ.pop(k, None) + else: + os.environ[k] = v + shutil.rmtree(self._tmpdir, ignore_errors=True) + + def _run_clients(self, n: int, pool_size: str) -> list: + env = dict(os.environ) + env["SPARK_LOCAL_CONNECT_POOL_DIR"] = self._pool_dir + env["SPARK_LOCAL_CONNECT_POOL"] = "1" + env["SPARK_LOCAL_CONNECT_POOL_SIZE"] = pool_size + env.pop("SPARK_CONNECT_AUTHENTICATE_TOKEN", None) + procs = [ + subprocess.Popen( + [sys.executable, "-c", self.CLIENT], + env=env, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + ) + for _ in range(n) + ] + outputs = [] + try: + for proc in procs: + stdout, stderr = proc.communicate(timeout=300) + self.assertEqual(proc.returncode, 0, stderr) + lines = stdout.strip().splitlines() + self.assertTrue(lines, stderr) + outputs.append(json.loads(lines[-1])) + finally: + for proc in procs: + if proc.poll() is None: + proc.kill() + proc.communicate() + return outputs + + def test_sequential_runs_use_fresh_servers_and_tear_them_down(self) -> None: + first = self._run_clients(1, pool_size="1")[0] + self.assertEqual(first["count"], 1) + # The used server is torn down asynchronously after its run. + self.assertTrue( + _wait_pid_gone(first["server_pid"]), + "the server was not torn down after its run ended", + ) + second = self._run_clients(1, pool_size="1")[0] + self.assertEqual(second["count"], 1) + self.assertNotEqual( + first["server_pid"], second["server_pid"], "a pooled server was reused across runs" + ) + + def test_concurrent_cold_clients_get_distinct_servers(self) -> None: + outputs = self._run_clients(2, pool_size="2") + self.assertEqual({o["count"] for o in outputs}, {1}) + pids = [o["server_pid"] for o in outputs] + self.assertEqual(len(set(pids)), 2, "two concurrent runs shared a pooled server") + + +if __name__ == "__main__": + from pyspark.testing import main + + main()