This is an automated email from the ASF dual-hosted git repository.
dtenedor pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/spark.git
The following commit(s) were added to refs/heads/master by this push:
new ee11a92a9f1a [SPARK-58021][CONNECT] Add local server pool member
claiming
ee11a92a9f1a is described below
commit ee11a92a9f1a1f685b301b79d8267fb80e57d1e0
Author: Eric Marnadi <[email protected]>
AuthorDate: Fri Aug 21 13:20:07 2026 -0700
[SPARK-58021][CONNECT] Add local server pool member claiming
### What changes were proposed in this pull request?
This is layer 3 of the seven-PR local Connect pool stack:
#57684 -> #57685 -> #57907 -> #57686 -> #57687 -> #57102 -> #57688
The two lower layers are now merged, so GitHub shows only this layer's
two-file diff.
This layer adds member compatibility and claiming on top of the filesystem
state model:
- compatibility fingerprints covering the master, startup conf, working
directory, Python
executables, PySpark and Spark paths, and `PYTHONPATH`, using the full
SHA-256 digest;
- strict validation of persisted member records followed by explicit typed
`PoolMember` fields;
- Spark-version, process-liveness (including Linux zombies), and
socket-reachability checks; and
- well-defined, approximately-FIFO claiming through an atomic state-file
rename, including safe
concurrent claims from separate processes. Ordering is by the wall-clock
`created` timestamp
(comparable across the independent publisher processes, unlike
`time.monotonic()`) with stable
`sorted()` tie-breaking, so it is well defined but only approximately
FIFO, not guaranteed: a
backward clock step (NTP, suspend/resume) can perturb it.
Pool sizing lives with its first consumer in acquisition layer #57687.
Process lifecycle,
acquisition, SparkSession integration, and JIT warmup remain in later PRs.
### Why are the changes needed?
The filesystem layer defines safe state storage, but a client also needs to
distinguish compatible
servers and claim exactly one member without racing other processes.
Isolating that contract keeps
record validation and the ready-to-claimed transition independently
reviewable before lifecycle
and launch orchestration are added.
### Does this PR introduce _any_ user-facing change?
No. Claiming is internal and is not wired into SparkSession in this layer.
### How was this patch tested?
Added fifteen focused tests at this layer, bringing the suite to 25 tests.
They cover deterministic
Linux zombie detection, fingerprint identity and Python-interpreter
precedence, Python and Spark
path compatibility, JVM-environment coverage, strict member-record
validation, fingerprint-aware
and approximately-FIFO claiming, a real two-process claim race, unreachable
members, and malformed,
dead, or version-incompatible records.
```bash
python/run-tests --testnames
pyspark.sql.tests.connect.test_connect_local_server_pool
```
All 25 focused tests passed locally. Ruff lint and format checks, targeted
mypy, Python compilation,
custom-error validation, `git diff --check`, and changed-file ASCII and
line-length checks also
passed.
[GitHub Actions run
32406852918](https://github.com/ericm-db/spark/actions/runs/32406852918) is
running against the latest commit (`36c787ab5d6`).
### Was this patch authored or co-authored using generative AI tooling?
Generated-by: Claude Code (Opus 4.8) and OpenAI Codex (GPT-5)
Closes #57907 from ericm-db/local-connect-pool-claiming.
Authored-by: Eric Marnadi <[email protected]>
Signed-off-by: Daniel Tenedorio <[email protected]>
---
python/pyspark/sql/connect/local_server.py | 55 ++-
python/pyspark/sql/connect/local_server_pool.py | 182 ++++++++-
.../connect/test_connect_local_server_pool.py | 419 ++++++++++++++++++++-
3 files changed, 641 insertions(+), 15 deletions(-)
diff --git a/python/pyspark/sql/connect/local_server.py
b/python/pyspark/sql/connect/local_server.py
index 5ef479ee41c5..f8c0129f3775 100644
--- a/python/pyspark/sql/connect/local_server.py
+++ b/python/pyspark/sql/connect/local_server.py
@@ -62,19 +62,61 @@ _SERVER_CLASS =
"org.apache.spark.sql.connect.service.SparkConnectServer"
# A fixed SPARK_IDENT_STRING keeps the spark-daemon.sh pid and log file names
stable
# regardless of $USER.
_SPARK_IDENT = "local-connect"
+_LINUX_ZOMBIE_STATE = "Z"
def _pid_alive(pid: int) -> bool:
- """Whether ``pid`` exists (POSIX only). A process we cannot signal counts
as alive."""
+ """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 managed server.
+
+ Off POSIX this returns ``True`` without probing: ``os.kill`` there
terminates the target for
+ any signal other than ``CTRL_C_EVENT`` / ``CTRL_BREAK_EVENT``, so signal 0
is not a safe
+ liveness probe, and callers fall through to the port check instead.
Guarding here rather than
+ at each call site keeps every caller (reuse and pool) safe. (The pool path
needs ``fcntl`` and
+ so never runs off POSIX regardless.)
+ """
+ if os.name != "posix":
+ return True
+ if pid <= 0:
+ return False
try:
os.kill(pid, 0)
except ProcessLookupError:
return False
+ except OverflowError:
+ return False
except OSError:
pass
+ if sys.platform.startswith("linux"):
+ try:
+ with open(f"/proc/{pid}/status", encoding="utf-8") as status_file:
+ for line in status_file:
+ key, separator, value = line.partition(":")
+ if separator and key == "State":
+ state, _, _ = value.strip().partition(" ")
+ if state == _LINUX_ZOMBIE_STATE:
+ return False
+ break
+ except FileNotFoundError:
+ return False
+ except OSError:
+ pass
return True
+def _port_open(host: str, port: int, timeout: float = 0.5) -> bool:
+ """Whether a TCP connection to ``host``:``port`` succeeds within
``timeout`` seconds. A
+ socket error or a host that fails to resolve counts as closed.
+ """
+ try:
+ with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
+ sock.settimeout(timeout)
+ return sock.connect_ex((host, port)) == 0
+ except (OSError, UnicodeError):
+ return False
+
+
def _is_local_connect_server(pid: int) -> Optional[bool]:
"""Whether ``pid`` is still the managed Connect server recorded in
discovery.
@@ -223,16 +265,14 @@ class LocalConnectServer:
def is_listening(self) -> bool:
if self.host is None or self.port is None:
return False
- with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
- sock.settimeout(0.5)
- return sock.connect_ex((self.host, self.port)) == 0
+ return _port_open(self.host, self.port)
def is_reusable(self) -> bool:
from pyspark.version import __version__
if self.spark_version != __version__ or self.pid is None:
return False
- if os.name == "posix" and not _pid_alive(self.pid):
+ if not _pid_alive(self.pid):
return False
return self.is_listening()
@@ -488,10 +528,7 @@ class ServerLauncher:
while time.time() < deadline:
pid = self._discovery.daemon_pid()
if pid is not None:
- with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
- sock.settimeout(0.5)
- listening = sock.connect_ex(("localhost", port)) == 0
- if listening:
+ if _port_open("localhost", port):
self._discovery.save(
{
"host": "localhost",
diff --git a/python/pyspark/sql/connect/local_server_pool.py
b/python/pyspark/sql/connect/local_server_pool.py
index 72b13d5b52f4..c506ab676abe 100644
--- a/python/pyspark/sql/connect/local_server_pool.py
+++ b/python/pyspark/sql/connect/local_server_pool.py
@@ -15,17 +15,155 @@
# limitations under the License.
#
-"""Filesystem-backed state storage for local Spark Connect server pools.
+"""Filesystem-backed state model for local Spark Connect server pools.
-This internal foundation owns the pool directory layout, locking, and JSON
state-file access.
+This internal foundation owns member identity, directory locking, state-file
access, and
+claiming. Process lifecycle and server acquisition are layered on top in
follow-up changes.
"""
import contextlib
+import hashlib
import json
+import math
import os
import shutil
+import sys
from typing import Any, Dict, List, Optional, Tuple
+from pyspark.errors import PySparkValueError
+
+
+# Environment variables that shape the JVM the launcher boots through
+# sbin/start-connect-server.sh -> spark-daemon.sh -> load-spark-env.sh /
spark-submit.
+# SPARK_CONF_DIR selects the spark-env.sh and spark-defaults.conf that seed
the server; the
+# rest feed the classpath, heap, and JVM options. This is a curated set, not
an exhaustive
+# one: the launcher inherits the whole environment, so it names the inputs
that most commonly
+# differ between runs rather than every variable a server could read.
+#
+# PATH is a deliberate omission: bin/spark-class prefers ${JAVA_HOME}/bin/java
and only falls
+# back to the first java on PATH, so two runs with different JDKs first on
PATH and no JAVA_HOME
+# would share a member. The identity already tracks PATH indirectly through
shutil.which for the
+# interpreters, and PATH is too volatile to fingerprint whole; a run needing a
specific JDK
+# should set JAVA_HOME.
+_JVM_ENV_VARS = (
+ "SPARK_CONF_DIR",
+ "JAVA_HOME",
+ "SPARK_DIST_CLASSPATH",
+ "SPARK_DAEMON_MEMORY",
+ "SPARK_DRIVER_MEMORY",
+ "SPARK_SUBMIT_OPTS",
+ "SPARK_DAEMON_JAVA_OPTS",
+)
+
+
+def pool_fingerprint(master: str, seed_conf: Dict[str, Any]) -> str:
+ """The identity of a pool member: a curated set of inputs that shape 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. The
set is curated
+ rather than complete because the launcher inherits the full environment
(see
+ ``_JVM_ENV_VARS``) -- it covers the inputs that most commonly differ
between runs.
+
+ Besides the master and the seeded confs, this covers the working directory
(unset warehouse
+ and Derby metastore locations resolve relative to it), the PySpark
installation, the Python
+ interpreters the server would run UDFs and Python data sources with, and
the environment
+ variables that shape the launched JVM.
+ """
+
+ def resolved(command: str) -> str:
+ # Relative commands resolve through PATH server-side; fold that in so
equal command
+ # strings cannot stand for different interpreters on different PATHs.
+ return shutil.which(command) or command
+
+ # Two server code paths resolve the Python interpreter with opposite
precedence, so a run
+ # changing only one of these variables would still have booted a different
server. Include
+ # both resolutions: SparkConnectPlanner.pythonExec (Connect Python UDFs)
prefers
+ # PYSPARK_PYTHON, while PythonUtils.defaultPythonExec (Python data
sources) prefers
+ # PYSPARK_DRIVER_PYTHON. Both fall back to python3 and treat an empty
value as set, matching
+ # the Scala sys.env.getOrElse chains.
+ udf_python = resolved(
+ os.environ.get("PYSPARK_PYTHON",
os.environ.get("PYSPARK_DRIVER_PYTHON", "python3"))
+ )
+ data_source_python = resolved(
+ os.environ.get("PYSPARK_DRIVER_PYTHON",
os.environ.get("PYSPARK_PYTHON", "python3"))
+ )
+ spark_home = os.environ.get("SPARK_HOME")
+ identity = [
+ master,
+ sorted((str(k), str(v)) for k, v in seed_conf.items()),
+ os.getcwd(),
+ sys.executable,
+ udf_python,
+ data_source_python,
+ os.path.realpath(__file__),
+ os.path.realpath(spark_home) if spark_home else "",
+ os.environ.get("PYTHONPATH", ""),
+ [os.environ.get(var, "") for var in _JVM_ENV_VARS],
+ ]
+ return hashlib.sha256(json.dumps(identity).encode("utf-8")).hexdigest()
+
+
+# The end of year 9999 UTC, as a Unix timestamp. ``created`` is a wall-clock
``time.time()``
+# reading, so no real clock reaches this for millennia; rejecting values
beyond it keeps a
+# corrupt far-future timestamp from looking perpetually fresh to age-based
reaping in the
+# layers above, which measure a member's age as ``time.time() - created``.
+_MAX_CREATED = 253402300799
+
+
+class PoolMember:
+ """One published pool server, wrapping its ``server-<uid>.json`` record."""
+
+ def __init__(self, data: Dict[str, Any]):
+ record = dict(data)
+ for key in ("host", "token", "spark_version", "fingerprint"):
+ if not isinstance(record[key], str) or not record[key]:
+ raise PySparkValueError(f"{key} must be a nonempty string")
+ for key in ("port", "pid"):
+ value = record[key]
+ if isinstance(value, bool) or not isinstance(value, int):
+ raise PySparkValueError(f"{key} must be an integer")
+ created = record["created"]
+ if isinstance(created, bool) or not isinstance(created, (int, float)):
+ raise PySparkValueError("created must be a number")
+ created = float(created)
+ if not 1 <= record["port"] <= 65535:
+ raise PySparkValueError("port is out of range")
+ if record["pid"] <= 0:
+ raise PySparkValueError("pid must be positive")
+ if not math.isfinite(created) or not 0 <= created <= _MAX_CREATED:
+ raise PySparkValueError(f"created must be a finite timestamp in
[0, {_MAX_CREATED}]")
+ self.host: str = record["host"]
+ self.port: int = record["port"]
+ self.token: str = record["token"]
+ self.pid: int = record["pid"]
+ self.spark_version: str = record["spark_version"]
+ self.fingerprint: str = record["fingerprint"]
+ self.created: float = created
+ # Set when this process claims the member; the path of its
claimed-<pid>-<uid>.json.
+ self.claim_path: Optional[str] = None
+
+ @classmethod
+ def from_data(cls, data: Dict[str, Any]) -> Optional["PoolMember"]:
+ """Parse a published member record, returning ``None`` when it is
malformed."""
+ try:
+ return cls(data)
+ except (KeyError, TypeError, ValueError, OverflowError):
+ return None
+
+ @property
+ def url(self) -> str:
+ return f"sc://{self.host}:{self.port}"
+
+ def is_usable(self) -> bool:
+ """Whether this member has a matching Spark version, live process, and
open port. Uses
+ the same liveness and reachability probes as the reuse path (see
``local_server``), so
+ the pool and reuse discovery agree on when a recorded server is still
good."""
+ from pyspark.version import __version__
+ from pyspark.sql.connect.local_server import _pid_alive, _port_open
+
+ if self.spark_version != __version__ or not _pid_alive(self.pid):
+ return False
+ return _port_open(self.host, self.port)
+
class PoolDirectory:
"""Path layout, file access, and the cross-process lock of one pool
directory.
@@ -229,3 +367,43 @@ class PoolDirectory:
def remove_member_dir(self, uid: str) -> None:
self._assert_locked()
shutil.rmtree(self.member_dir(uid), ignore_errors=True)
+
+
+class ServerPool:
+ """Claims members from one pool directory; lifecycle operations are added
later."""
+
+ def __init__(self, directory: Optional[PoolDirectory] = None):
+ self._directory = directory or PoolDirectory()
+
+ def claim(self, fingerprint: str) -> Optional[PoolMember]:
+ """Claim the oldest usable member with this fingerprint, or ``None``.
The rename to
+ ``claimed-<pid>-<uid>.json`` marks the member as owned by this
process; the reaping
+ rules use that pid to retire members whose client died without
releasing them. The
+ caller must hold the directory lock so selection and rename form one
transition.
+
+ Ordering is by ``created``, a wall-clock ``time.time()`` reading. It
is comparable
+ across the independent processes that publish members, which
``time.monotonic()`` is
+ not, at the cost that a backward clock step (NTP, suspend/resume) can
perturb the order.
+ Ties break by the stable ``sorted()`` over the sorted directory
listing, so the order is
+ well defined but only approximately FIFO, not guaranteed.
+
+ ``is_usable`` runs under the held lock and does blocking network I/O
-- up to a 0.5s
+ connect for each candidate that is live but not accepting connections.
The candidate
+ count is bounded by ``spark.local.connect.pool.size``, which is
user-tunable, so a large
+ pool widens the window the lock is held; the reaping rules keep stale
members from
+ accumulating without bound."""
+ candidates = []
+ for uid, path in self._directory.paths_of_kind("server"):
+ data = self._directory.read_json(path)
+ member = PoolMember.from_data(data) if data is not None else None
+ if member is not None and member.fingerprint == fingerprint:
+ candidates.append((member, 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
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
index 7e8d2326fbf8..48ea5970aa8d 100644
--- a/python/pyspark/sql/tests/connect/test_connect_local_server_pool.py
+++ b/python/pyspark/sql/tests/connect/test_connect_local_server_pool.py
@@ -15,20 +15,68 @@
# limitations under the License.
#
+import contextlib
import os
import shutil
import subprocess
import sys
import tempfile
+import time
import unittest
+from unittest import mock
from pyspark.testing.connectutils import should_test_connect,
connect_requirement_message
if should_test_connect:
- from pyspark.sql.connect.local_server_pool import PoolDirectory
+ from pyspark.sql.connect.local_server import _pid_alive
+ from pyspark.sql.connect.local_server_pool import (
+ _JVM_ENV_VARS,
+ PoolDirectory,
+ PoolMember,
+ ServerPool,
+ pool_fingerprint,
+ )
+ from pyspark.version import __version__
-_SAVED_ENV_KEYS = ("SPARK_LOCAL_CONNECT_POOL_DIR",)
[email protected]
+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()
+
+
[email protected]
+def _non_listening_socket():
+ """Reserve a port without listening on it, so connection attempts are
rejected."""
+ import socket
+
+ with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
+ sock.bind(("localhost", 0))
+ yield sock.getsockname()[1]
+
+
+def _spawn_live_process() -> "subprocess.Popen":
+ """A child blocked on its parent pipe, standing in for a live pool
server."""
+ return subprocess.Popen(
+ [sys.executable, "-c", "import sys; sys.stdin.buffer.read()"],
+ stdin=subprocess.PIPE,
+ stdout=subprocess.DEVNULL,
+ stderr=subprocess.DEVNULL,
+ )
+
+
+_SAVED_ENV_KEYS = (
+ "SPARK_LOCAL_CONNECT_POOL_DIR",
+ "PYSPARK_DRIVER_PYTHON",
+ "PYSPARK_PYTHON",
+)
# These tests start no server and exercise only stdlib filesystem code, so
they do not need JVM
@@ -40,7 +88,7 @@ _SAVED_ENV_KEYS = ("SPARK_LOCAL_CONNECT_POOL_DIR",)
)
@unittest.skipUnless(os.name == "posix", "the pool relies on POSIX file locks")
class LocalConnectServerPoolUnitTests(unittest.TestCase):
- """Tests for the pool filesystem storage; no real servers are started."""
+ """Tests for the pool filesystem model; no real servers are started."""
def setUp(self) -> None:
self._tmpdir = tempfile.mkdtemp()
@@ -49,8 +97,16 @@ class LocalConnectServerPoolUnitTests(unittest.TestCase):
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 = []
def tearDown(self) -> 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)
@@ -58,6 +114,33 @@ class LocalConnectServerPoolUnitTests(unittest.TestCase):
os.environ[k] = v
shutil.rmtree(self._tmpdir, ignore_errors=True)
+ def _live_process(self) -> "subprocess.Popen":
+ proc = _spawn_live_process()
+ 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")
@@ -68,7 +151,7 @@ class LocalConnectServerPoolUnitTests(unittest.TestCase):
def test_pool_directory_lock_and_state_file_permissions(self) -> None:
os.makedirs(self._directory.path, mode=0o755)
os.chmod(self._directory.path, 0o755)
- state_path = self._directory.server_path("secure")
+ state_path = self._directory.server_path("abcdef")
with self._directory as directory:
self.assertEqual(os.stat(directory.path).st_mode & 0o777, 0o700)
lock_path = os.path.join(directory.path, ".lock")
@@ -222,6 +305,334 @@ class LocalConnectServerPoolUnitTests(unittest.TestCase):
with self._directory:
pass
+ @unittest.skipUnless(
+ sys.platform.startswith("linux") and os.path.isdir("/proc") and
hasattr(os, "waitid"),
+ "requires Linux process state and waitid",
+ )
+ def test_pid_alive_treats_zombie_as_dead(self) -> None:
+ proc = subprocess.Popen([sys.executable, "-c", "pass"])
+ try:
+ # Wait for the child to exit but leave it waitable, which keeps it
as a zombie
+ # until the finally block reaps it.
+ os.waitid(os.P_PID, proc.pid, os.WEXITED | os.WNOWAIT)
+ self.assertFalse(_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"})
+ )
+ # Match SparkConnectPlanner.pythonExec's PYSPARK_PYTHON ->
PYSPARK_DRIVER_PYTHON ->
+ # python3 precedence for Connect UDFs, so clients never claim a server
that resolved a
+ # different fallback interpreter.
+ os.environ.pop("PYSPARK_PYTHON")
+ os.environ["PYSPARK_DRIVER_PYTHON"] = "python3"
+ self.assertEqual(base, pool_fingerprint("local[*]",
{"spark.sql.shuffle.partitions": "4"}))
+ os.environ["PYSPARK_DRIVER_PYTHON"] = "/driver/python"
+ self.assertNotEqual(
+ base, pool_fingerprint("local[*]",
{"spark.sql.shuffle.partitions": "4"})
+ )
+ # PYSPARK_DRIVER_PYTHON also feeds PythonUtils.defaultPythonExec
(Python data sources),
+ # which prefers it over PYSPARK_PYTHON. So even with PYSPARK_PYTHON
fixed, changing
+ # PYSPARK_DRIVER_PYTHON changes the server a run would boot and must
change the identity.
+ os.environ["PYSPARK_PYTHON"] = "/worker/python"
+ worker_python = pool_fingerprint("local[*]",
{"spark.sql.shuffle.partitions": "4"})
+ os.environ["PYSPARK_DRIVER_PYTHON"] = "/other/driver/python"
+ self.assertNotEqual(
+ worker_python,
+ pool_fingerprint("local[*]", {"spark.sql.shuffle.partitions":
"4"}),
+ )
+
+ def test_fingerprint_resolves_server_python_from_path(self) -> None:
+ # Relative worker commands are resolved through PATH by the server.
Include that
+ # resolution so equal command strings cannot identify different Python
environments.
+ executable_name = "pool-test-python"
+ executable_dirs = [os.path.join(self._tmpdir, name) for name in
("env-a", "env-b")]
+ for directory in executable_dirs:
+ os.makedirs(directory)
+ executable = os.path.join(directory, executable_name)
+ with open(executable, "w") as executable_file:
+ executable_file.write("#!/bin/sh\n")
+ os.chmod(executable, 0o700)
+ os.environ["PYSPARK_PYTHON"] = executable_name
+ with mock.patch.dict(os.environ, {"PATH": executable_dirs[0]}):
+ first_environment = pool_fingerprint("local[*]", {})
+ with mock.patch.dict(os.environ, {"PATH": executable_dirs[1]}):
+ self.assertNotEqual(first_environment,
pool_fingerprint("local[*]", {}))
+
+ def test_fingerprint_includes_python_and_spark_paths(self) -> None:
+ with mock.patch.dict(os.environ, {"PYTHONPATH": "/python/a",
"SPARK_HOME": "/spark/a"}):
+ first_environment = pool_fingerprint("local[*]", {})
+ with mock.patch.dict(os.environ, {"PYTHONPATH": "/python/b",
"SPARK_HOME": "/spark/a"}):
+ self.assertNotEqual(first_environment,
pool_fingerprint("local[*]", {}))
+ with mock.patch.dict(os.environ, {"PYTHONPATH": "/python/a",
"SPARK_HOME": "/spark/b"}):
+ self.assertNotEqual(first_environment,
pool_fingerprint("local[*]", {}))
+
+ def test_fingerprint_conf_order_independent_and_string_keyed(self) -> None:
+ # sorted() over seed_conf makes the identity independent of dict
insertion order, so a
+ # run that builds the same confs in a different order still matches.
+ self.assertEqual(
+ pool_fingerprint("local[*]", {"a": "1", "b": "2"}),
+ pool_fingerprint("local[*]", {"b": "2", "a": "1"}),
+ )
+ # Confs serialize to a properties file, so values are compared as
strings: 1 and "1"
+ # are the same seed and share an identity.
+ self.assertEqual(
+ pool_fingerprint("local[*]", {"k": 1}),
pool_fingerprint("local[*]", {"k": "1"})
+ )
+
+ def test_fingerprint_includes_python_executable(self) -> None:
+ # sys.executable is the client interpreter the server inherits; a
packaging change that
+ # moves it must not silently reuse a server booted under the old one.
+ base = pool_fingerprint("local[*]", {})
+ with mock.patch.object(sys, "executable", "/other/python"):
+ self.assertNotEqual(base, pool_fingerprint("local[*]", {}))
+
+ def test_fingerprint_includes_jvm_env(self) -> None:
+ # Every JVM-shaping variable must change the identity. SPARK_CONF_DIR
is the common CI
+ # case (it selects the spark-defaults.conf / spark-env.sh the server
reads); the rest
+ # feed the classpath, heap, and JVM options. The expected list is
spelled out here
+ # independently rather than derived from _JVM_ENV_VARS: the
fingerprint reads that same
+ # tuple, so dropping a variable from it would silently leave the
fingerprint AND a loop
+ # over it in agreement. The equality check catches such drift (a
removal or an unlisted
+ # addition), and the loop proves each variable still changes the
identity.
+ expected = (
+ "SPARK_CONF_DIR",
+ "JAVA_HOME",
+ "SPARK_DIST_CLASSPATH",
+ "SPARK_DAEMON_MEMORY",
+ "SPARK_DRIVER_MEMORY",
+ "SPARK_SUBMIT_OPTS",
+ "SPARK_DAEMON_JAVA_OPTS",
+ )
+ self.assertEqual(set(_JVM_ENV_VARS), set(expected))
+ for var in expected:
+ with self.subTest(var=var):
+ with mock.patch.dict(os.environ, {var: "/value/a"}):
+ with_a = pool_fingerprint("local[*]", {})
+ with mock.patch.dict(os.environ, {var: "/value/b"}):
+ self.assertNotEqual(with_a, pool_fingerprint("local[*]",
{}))
+
+ def test_pool_member_validation(self) -> None:
+ valid = self._server_data(12345, 123, created=1)
+ member = PoolMember.from_data(valid)
+ self.assertIsNotNone(member)
+ self.assertEqual(member.host, "localhost")
+ self.assertEqual(member.port, 12345)
+ self.assertEqual(member.pid, 123)
+ self.assertEqual(member.created, 1.0)
+ self.assertEqual(member.url, "sc://localhost:12345")
+
+ invalid_records = {
+ "missing fields": {"fingerprint": "fp"},
+ "empty token": self._server_data(12345, 123, token=""),
+ "non-string host": self._server_data(12345, 123, host=None),
+ "boolean port": self._server_data(True, 123),
+ "string port": self._server_data("12345", 123),
+ "fractional port": self._server_data(12345.5, 123),
+ "zero port": self._server_data(0, 123),
+ "out-of-range port": self._server_data(65536, 123),
+ "boolean pid": self._server_data(12345, True),
+ "string pid": self._server_data(12345, "123"),
+ "fractional pid": self._server_data(12345, 123.5),
+ "zero pid": self._server_data(12345, 0),
+ "string created": self._server_data(12345, 123, created="1"),
+ "boolean created": self._server_data(12345, 123, created=True),
+ "negative created": self._server_data(12345, 123, created=-1),
+ "nan created": self._server_data(12345, 123, created=float("nan")),
+ "infinite created": self._server_data(12345, 123,
created=float("inf")),
+ # A finite float, but far past any real clock: rejected so it
cannot look
+ # perpetually fresh to age-based reaping.
+ "far-future created": self._server_data(12345, 123,
created=2**100),
+ # Too large to convert to float at all -- the OverflowError guard
in from_data keeps
+ # a corrupt state file (this round-trips through json) from
crashing the caller.
+ "overflow created": self._server_data(12345, 123, created=10**400),
+ }
+ for name, data in invalid_records.items():
+ with self.subTest(name=name):
+ self.assertIsNone(PoolMember.from_data(data))
+
+ def test_claim_matches_fingerprint_and_renames(self) -> None:
+ with _listening_socket() as port:
+ server_process = self._live_process()
+ self._write_state(
+ self._directory.server_path("aaa"),
+ self._server_data(port, server_process.pid,
fingerprint="other-fp"),
+ )
+ self._write_state(
+ self._directory.server_path("bbb"),
+ self._server_data(port, server_process.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:
+ server_process = self._live_process()
+ for uid, created in (("aaaa", time.time()), ("bbbb", time.time() -
100)):
+ self._write_state(
+ self._directory.server_path(uid),
+ self._server_data(port, server_process.pid, token="t-" +
uid, created=created),
+ )
+ with self._directory:
+ member = self._pool.claim("fp")
+ # Prefer the oldest ready member. Ordering is by wall-clock created,
so this is
+ # approximate FIFO rather than a guarantee (see ServerPool.claim).
+ self.assertEqual(member.token, "t-bbbb")
+
+ def test_claim_requires_the_lock(self) -> None:
+ # claim reaches the directory only through the locked accessors, so it
inherits their
+ # assertion; pin the caller obligation directly so a future reordering
that touches the
+ # directory before the first locked accessor is still caught.
+ with self.assertRaisesRegex(AssertionError, "context manager"):
+ self._pool.claim("fp")
+
+ def test_claim_ignores_already_claimed_member(self) -> None:
+ # The kind filter is the exclusion invariant: a member already renamed
to claimed-* is
+ # no longer of kind "server", so claim never hands it out a second
time. A live, usable
+ # record under a claimed-* name must still be invisible to a new
claimer.
+ with _listening_socket() as port:
+ server_process = self._live_process()
+ uid = "feed"
+ self._write_state(
+ self._directory.claimed_path(os.getpid() + 1, uid),
+ self._server_data(port, server_process.pid),
+ )
+ with self._directory:
+ self.assertIsNone(self._pool.claim("fp"))
+ self.assertEqual(set(self._states(uid)), {"claimed"})
+
+ def test_concurrent_claimers_claim_one_member_once(self) -> None:
+ child = (
+ "import sys\n"
+ "from pyspark.sql.connect.local_server_pool import PoolDirectory,
ServerPool\n"
+ "directory = PoolDirectory(sys.argv[1])\n"
+ "with directory:\n"
+ " member = ServerPool(directory).claim('fp')\n"
+ "print(member.token if member is not None else 'NONE')\n"
+ )
+ claimers = []
+ results = []
+ with _listening_socket() as port:
+ server_process = self._live_process()
+ uid = "cafe"
+ self._write_state(
+ self._directory.server_path(uid),
+ self._server_data(port, server_process.pid,
token="claimed-once"),
+ )
+ try:
+ with self._directory:
+ for _ in range(2):
+ claimers.append(
+ subprocess.Popen(
+ [sys.executable, "-c", child,
self._directory.path],
+ stdout=subprocess.PIPE,
+ stderr=subprocess.PIPE,
+ text=True,
+ )
+ )
+ for proc in claimers:
+ stdout, stderr = proc.communicate(timeout=20)
+ self.assertEqual(proc.returncode, 0, stderr)
+ results.append(stdout.strip())
+ finally:
+ for proc in claimers:
+ if proc.poll() is None:
+ proc.kill()
+ proc.communicate(timeout=10)
+
+ # Pins "claimed at most once": one child claims the member and the
other sees none. It
+ # does not guarantee the two ever contend on the lock -- if the first
child finishes
+ # before the second reaches flock(), the second simply finds no
"server" entry -- so
+ # read this as a regression test for the exclusion invariant, not for
lock contention.
+ self.assertEqual(sorted(results), ["NONE", "claimed-once"])
+ states = self._states(uid)
+ self.assertEqual(set(states), {"claimed"})
+ claiming_pid = PoolDirectory.claiming_pid(states["claimed"])
+ self.assertIn(claiming_pid, [proc.pid for proc in claimers])
+
+ def test_claim_skips_unreachable_member(self) -> None:
+ with _non_listening_socket() as port:
+ self._write_state(
+ self._directory.server_path("ccc"), self._server_data(port,
os.getpid())
+ )
+ with self._directory:
+ self.assertIsNone(self._pool.claim("fp"))
+
+ def test_claim_skips_malformed_and_incompatible_members(self) -> None:
+ with _listening_socket() as port:
+ server_process = self._live_process()
+ bad_pid = self._server_data(port, server_process.pid)
+ bad_pid["pid"] = "not-a-pid"
+ bad_port = self._server_data(port, server_process.pid)
+ bad_port["port"] = "not-a-port"
+ bad_created = self._server_data(port, server_process.pid)
+ bad_created["created"] = "not-a-time"
+ non_finite_created = self._server_data(port, server_process.pid)
+ non_finite_created["created"] = float("nan")
+ out_of_range_port = self._server_data(port, server_process.pid)
+ out_of_range_port["port"] = 65536
+ bad_host = self._server_data(port, server_process.pid)
+ bad_host["host"] = None
+ records = {
+ "a0": {"fingerprint": "fp"},
+ "a1": bad_pid,
+ "a2": bad_port,
+ "a3": bad_created,
+ "a4": non_finite_created,
+ "a5": out_of_range_port,
+ "a6": bad_host,
+ "a7": self._server_data(port, server_process.pid,
spark_version="not-this-version"),
+ "a8": self._server_data(port, 2**31 - 1),
+ "a9": self._server_data(port, 2**100),
+ }
+ for uid, data in records.items():
+ self._write_state(self._directory.server_path(uid), data)
+ self._write_state(
+ self._directory.server_path("b0"),
+ self._server_data(port, server_process.pid,
token="valid-token"),
+ )
+
+ with self._directory:
+ member = self._pool.claim("fp")
+
+ self.assertIsInstance(member, PoolMember)
+ self.assertEqual(member.token, "valid-token")
+ for uid in records:
+ self.assertEqual(set(self._states(uid)), {"server"})
+
if __name__ == "__main__":
from pyspark.testing import main
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]