This is an automated email from the ASF dual-hosted git repository.
dtenedor pushed a commit to branch branch-4.x
in repository https://gitbox.apache.org/repos/asf/spark.git
The following commit(s) were added to refs/heads/branch-4.x by this push:
new 11d0c54a6607 [SPARK-58021][CONNECT] Generalize local Connect server
launching
11d0c54a6607 is described below
commit 11d0c54a6607ce8fa2977eae92490f30695fd6af
Author: Eric Marnadi <[email protected]>
AuthorDate: Mon Aug 10 10:03:44 2026 -0700
[SPARK-58021][CONNECT] Generalize local Connect server launching
### What changes were proposed in this pull request?
This is layer 1 of the six-PR local Connect pool stack:
#57684 -> #57685 -> #57686 -> #57687 -> #57102 -> #57688
This patch extracts the reusable pieces of local Spark Connect server
startup from the persistent-server reuse path:
- expose the per-user runtime directory and startup seed configuration as
module helpers;
- add `LocalConnectServer.start()` as the common lifecycle-owned launch
entry point; and
- let the launcher use an ephemeral port and a precomputed startup
configuration when requested.
The existing reuse path now calls the same `LocalConnectServer.start()`
method. Focused tests cover seed configuration and delegation of the new launch
options.
### Why are the changes needed?
The persistent reuse mode currently combines reusable local-server
lifecycle handling with assumptions specific to its one fixed daemon. The
single-use server pool needs the same secure configuration seeding, process
launch, discovery, and readiness handling, but with independent runtime
directories and ephemeral ports. Sharing one launch path keeps those behaviors
consistent and isolates the larger feature from the already-working reuse
implementation.
### Does this PR introduce _any_ user-facing change?
No.
### How was this patch tested?
Added focused unit coverage to
`pyspark.sql.tests.connect.test_connect_local_server.LocalConnectServerReuseTests`
and ran the full suite, including real daemon startup, reuse, session
isolation, and static configuration seeding:
```bash
python -m unittest -v pyspark.sql.tests.connect.test_connect_local_server
```
All 15 tests passed. Ruff check, Ruff format check, and `git diff --check`
passed.
### Was this patch authored or co-authored using generative AI tooling?
Generated-by: Claude Code (Fable 5) and OpenAI Codex (GPT-5)
Closes #57684 from ericm-db/local-connect-pool-refactor.
Authored-by: Eric Marnadi <[email protected]>
Signed-off-by: Daniel Tenedorio <[email protected]>
(cherry picked from commit 343fbc39f52fd3ee8bdcb125e52c683a0a90ab08)
Signed-off-by: Daniel Tenedorio <[email protected]>
---
python/pyspark/sql/connect/local_server.py | 146 +++++++++++-----
.../sql/tests/connect/test_connect_local_server.py | 184 +++++++++++++++++++++
2 files changed, 285 insertions(+), 45 deletions(-)
diff --git a/python/pyspark/sql/connect/local_server.py
b/python/pyspark/sql/connect/local_server.py
index 4f06d884aff4..4aa88a400487 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 @@ class LocalConnectServer:
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,58 @@ class LocalConnectServer:
return stopped
+def _strip_launcher_conf(conf: Dict[str, Any]) -> Dict[str, Any]:
+ """Drop keys the launcher sets itself (master, binding port, auth token)
and the
+ ``spark.local.connect.*`` opt-in keys, returning a new dict. Idempotent,
so it is safe to
+ apply to a conf that is already sanitized.
+ """
+ stripped = dict(conf)
+ for k in list(stripped):
+ 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."):
+ stripped.pop(k)
+ return stripped
+
+
+def startup_seed_conf(opts: Dict[str, Any]) -> Dict[str, Any]:
+ """Compute startup confs using the same merge as the in-process server
path, then strip
+ the keys the launcher manages (see ``_strip_launcher_conf``).
+ """
+ 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)
+ return _strip_launcher_conf(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 +332,12 @@ class ServerLauncher:
)
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 +345,7 @@ class ServerLauncher:
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
@@ -297,26 +360,19 @@ class ServerLauncher:
return free_port()
def _seed_conf(self) -> Dict[str, Any]:
- """Startup confs for the new server, merged like the in-process
- ``_start_connect_server`` merges ``PYSPARK_REMOTE_INIT_CONF_*`` with
the builder
- opts, minus the keys the launcher sets itself and the
``spark.local.connect.*``
- opt-in keys. Only the run that starts the server can seed static
confs; later runs
- find the JVM already warm.
+ """Startup confs for the new server, minus the keys the launcher sets
itself and the
+ ``spark.local.connect.*`` opt-in keys. Only the run that starts the
server can seed
+ static confs; later runs find the JVM already warm.
+
+ With no ``seed_conf`` override, this merges
``PYSPARK_REMOTE_INIT_CONF_*`` with the
+ builder opts like the in-process ``_start_connect_server`` does. When
an override is
+ given, it is used verbatim instead of the merge. Either way the result
is run through
+ ``_strip_launcher_conf``, so the launcher-managed keys never reach
+ ``--properties-file`` even if a caller passes raw opts as the override.
"""
- 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 _strip_launcher_conf(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/tests/connect/test_connect_local_server.py
b/python/pyspark/sql/tests/connect/test_connect_local_server.py
index eba26972ae8c..ae09eb04646f 100644
--- a/python/pyspark/sql/tests/connect/test_connect_local_server.py
+++ b/python/pyspark/sql/tests/connect/test_connect_local_server.py
@@ -104,6 +104,26 @@ class LocalConnectServerReuseTests(unittest.TestCase):
with Discovery() as discovery:
return LocalConnectServer(discovery)
+ def _launcher_discovery(self):
+ # A stand-in Discovery for ServerLauncher unit tests: only its
directory is read
+ # (for the log dir and the seed properties file), so point it at the
temp dir.
+ from unittest import mock
+
+ discovery = mock.Mock()
+ discovery.directory = self._tmpdir
+ return discovery
+
+ @contextlib.contextmanager
+ def _without_spark_testing(self):
+ # _pick_port's ephemeral branch is a no-op when SPARK_TESTING is set
(as it is under
+ # the test runner), so drop it to exercise the production behavior.
+ saved = os.environ.pop("SPARK_TESTING", None)
+ try:
+ yield
+ finally:
+ if saved is not None:
+ os.environ["SPARK_TESTING"] = saved
+
def test_discovery_location(self) -> None:
self.assertEqual(Discovery().path, self._discovery_path)
# Without the override the file lives in a per-user 0700 dir under the
temp dir.
@@ -114,6 +134,170 @@ class LocalConnectServerReuseTests(unittest.TestCase):
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_pick_port_uses_ephemeral_port_when_requested(self) -> None:
+ # This is the production path for pool attendants, which run without
SPARK_TESTING.
+ # A non-integer configured port would raise int() in the
configured/default branch;
+ # the ephemeral branch never reads it, so returning a clean
OS-assigned port proves
+ # the free-port path was taken even with SPARK_TESTING unset.
+ launcher = local_server.ServerLauncher(
+ "local[2]",
+ {"spark.local.connect.server.port": "not-a-port"},
+ self._launcher_discovery(),
+ use_ephemeral_port=True,
+ )
+ with self._without_spark_testing():
+ port = launcher._pick_port()
+ self.assertGreater(port, 0)
+
+ def test_pick_port_honors_configured_port_without_testing(self) -> None:
+ # With neither the ephemeral flag nor SPARK_TESTING, a free configured
port is used
+ # as-is rather than replaced by an OS-assigned one.
+ with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
+ sock.bind(("localhost", 0))
+ free = sock.getsockname()[1]
+ launcher = local_server.ServerLauncher(
+ "local[2]",
+ {"spark.local.connect.server.port": str(free)},
+ self._launcher_discovery(),
+ use_ephemeral_port=False,
+ )
+ with self._without_spark_testing():
+ self.assertEqual(launcher._pick_port(), free)
+
+ def test_seed_conf_override_is_used_and_sanitized(self) -> None:
+ # The override is taken verbatim except for launcher-managed keys,
which are stripped
+ # so raw builder opts passed as a seed cannot land in
--properties-file.
+ launcher = local_server.ServerLauncher(
+ "local[2]",
+ {},
+ self._launcher_discovery(),
+ seed_conf={
+ "spark.sql.shuffle.partitions": "4",
+ "spark.master": "local[9]",
+ "spark.local.connect.reuse": "true",
+ },
+ )
+ self.assertEqual(launcher._seed_conf(),
{"spark.sql.shuffle.partitions": "4"})
+
+ def test_seed_conf_empty_override_does_not_fall_through_to_env(self) ->
None:
+ from unittest import mock
+
+ # Load-bearing for the pool attendant: an empty override means "seed
nothing", and
+ # must not silently pick up PYSPARK_REMOTE_INIT_CONF_* the way
opts=None would.
+ env = {
+ "PYSPARK_REMOTE_INIT_CONF_LEN": "1",
+ "PYSPARK_REMOTE_INIT_CONF_0":
json.dumps({"spark.sql.shuffle.partitions": "8"}),
+ }
+ launcher = local_server.ServerLauncher(
+ "local[2]", {}, self._launcher_discovery(), seed_conf={}
+ )
+ with mock.patch.dict(os.environ, env):
+ self.assertEqual(launcher._seed_conf(), {})
+
+ def test_seed_conf_none_override_merges_env_and_opts(self) -> None:
+ from unittest import mock
+
+ # No override: the env-plus-opts merge (minus launcher-managed keys)
is used.
+ env = {
+ "PYSPARK_REMOTE_INIT_CONF_LEN": "1",
+ "PYSPARK_REMOTE_INIT_CONF_0":
json.dumps({"spark.sql.shuffle.partitions": "8"}),
+ }
+ launcher = local_server.ServerLauncher(
+ "local[2]",
+ {"spark.sql.warehouse.dir": os.path.join(self._tmpdir, "wh")},
+ self._launcher_discovery(),
+ seed_conf=None,
+ )
+ with mock.patch.dict(os.environ, env):
+ self.assertEqual(
+ launcher._seed_conf(),
+ {
+ "spark.sql.shuffle.partitions": "8",
+ "spark.sql.warehouse.dir": os.path.join(self._tmpdir,
"wh"),
+ },
+ )
+
+ def test_seed_properties_file_reflects_seed_conf(self) -> None:
+ # An empty seed yields no properties file, so start-connect-server.sh
gets no
+ # --properties-file; a non-empty seed writes a 0600 file with the
seeded confs.
+ launcher = local_server.ServerLauncher(
+ "local[2]", {}, self._launcher_discovery(), seed_conf={}
+ )
+ with launcher._seed_properties_file() as path:
+ self.assertIsNone(path)
+
+ launcher = local_server.ServerLauncher(
+ "local[2]",
+ {},
+ self._launcher_discovery(),
+ seed_conf={"spark.sql.shuffle.partitions": "4"},
+ )
+ with launcher._seed_properties_file() as path:
+ self.assertIsNotNone(path)
+ self.assertEqual(os.stat(path).st_mode & 0o777, 0o600)
+ with open(path) as f:
+ contents = f.read()
+ self.assertIn("spark.sql.shuffle.partitions=4", contents)
+
def test_discovery_roundtrip(self) -> None:
with Discovery() as discovery:
saved = self._server(port=15002)
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]