ericm-db commented on code in PR #56907:
URL: https://github.com/apache/spark/pull/56907#discussion_r3540188980


##########
python/pyspark/sql/connect/session.py:
##########
@@ -1247,6 +1249,347 @@ def _start_connect_server(master: str, opts: Dict[str, 
Any]) -> None:
                 messageParameters={},
             )
 
+    # Opt-in reuse of a persistent local Spark Connect server. By default 
``.remote("local[*]")``
+    # boots a fresh in-process server every process (see 
``_start_connect_server`` above); when
+    # reuse is enabled, the first run starts a detached server 
(``connect/local_server.py``) and
+    # records it in a discovery file, and later runs reconnect to it instead 
of re-paying the cold
+    # start.
+
+    @staticmethod
+    def _local_connect_discovery_path() -> str:
+        """Location of the discovery file describing the running persistent 
local server."""
+        override = os.environ.get("SPARK_LOCAL_CONNECT_DISCOVERY")
+        if override:
+            return override
+        return os.path.join(os.path.expanduser("~"), ".spark", 
"connect-local.json")
+
+    @staticmethod
+    def _read_local_connect_discovery() -> Optional[Dict[str, Any]]:
+        """Read and validate the discovery file, returning ``None`` if it is 
absent or malformed."""
+        path = SparkSession._local_connect_discovery_path()
+        try:
+            with open(path, "r") as f:
+                disc = json.load(f)
+        except (OSError, ValueError):
+            return None
+        if not isinstance(disc, dict) or not all(
+            k in disc for k in ("host", "port", "token", "pid", 
"spark_version")
+        ):
+            return None
+        return disc
+
+    @staticmethod
+    def _local_connect_server_is_reusable(disc: Dict[str, Any]) -> bool:
+        """Decide whether the server described by ``disc`` can be reused by 
this process.
+
+        Reuse requires that the recorded Spark version matches this client's, 
the recorded process
+        is still alive, and it is accepting connections on the recorded port. 
A version mismatch,
+        dead pid, or closed port means we must start our own server instead.
+        """
+        import socket
+        from pyspark.version import __version__
+
+        if disc.get("spark_version") != __version__:
+            return False
+        try:
+            os.kill(int(disc["pid"]), 0)
+        except (ProcessLookupError, ValueError, TypeError):
+            return False
+        except OSError:
+            # The process exists but is not ours to signal (e.g. 
PermissionError) -- treat as alive.
+            pass
+        with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
+            sock.settimeout(0.5)
+            if sock.connect_ex((disc["host"], int(disc["port"]))) != 0:
+                return False
+        return True
+
+    @staticmethod
+    def _reuse_from_discovery() -> Optional[str]:
+        """Return an endpoint for the recorded server if it is reusable, else 
``None``.
+
+        On success it also sets ``SPARK_CONNECT_AUTHENTICATE_TOKEN`` so the 
client authenticates
+        against that server.
+        """
+        disc = SparkSession._read_local_connect_discovery()
+        if disc is not None and 
SparkSession._local_connect_server_is_reusable(disc):
+            os.environ["SPARK_CONNECT_AUTHENTICATE_TOKEN"] = disc["token"]
+            return "sc://{}:{}".format(disc["host"], disc["port"])
+        return None
+
+    @staticmethod
+    def _acquire_local_connect_start_lock() -> Any:
+        """Take an exclusive file lock guarding persistent-server start-up.
+
+        Returns the open fd to pass to ``_release_local_connect_start_lock``, 
or ``None`` when file
+        locking is unavailable (e.g. Windows, which has no ``fcntl``); there 
we rely on the
+        reconnect-the-winner fallback in 
``_start_persistent_local_connect_server`` instead.
+        """
+        try:
+            import fcntl
+        except ImportError:
+            return None
+        path = SparkSession._local_connect_discovery_path() + ".lock"
+        parent = os.path.dirname(path)
+        if parent and not os.path.isdir(parent):
+            os.makedirs(parent, exist_ok=True)
+        fd = os.open(path, os.O_RDWR | os.O_CREAT, 0o600)
+        fcntl.flock(fd, fcntl.LOCK_EX)
+        return fd
+
+    @staticmethod
+    def _release_local_connect_start_lock(fd: Any) -> None:
+        if fd is None:
+            return
+        try:
+            import fcntl
+
+            fcntl.flock(fd, fcntl.LOCK_UN)
+        finally:
+            os.close(fd)
+
+    @staticmethod
+    def _reuse_or_start_local_connect_server(master: str, opts: Dict[str, 
Any]) -> str:
+        """Reuse a running persistent local Connect server, or start one if 
none is reusable.
+
+        Returns the ``sc://host:port`` endpoint to connect to. This is the 
opt-in counterpart of
+        ``_start_connect_server`` and is only reached for a ``local`` master 
when
+        ``spark.local.connect.reuse`` / ``SPARK_LOCAL_CONNECT_REUSE`` is set.
+        """
+        # Fast path: reuse an already-running server without taking the 
cross-process lock.
+        endpoint = SparkSession._reuse_from_discovery()
+        if endpoint is not None:
+            return endpoint
+        # No reusable server yet. Serialize start-up across processes so 
concurrent opted-in
+        # processes (parallel workers, an IDE spawning scripts) do not each 
spawn a server and
+        # collide on the port; the winner writes the discovery file and the 
others reuse it.
+        lock_fd = SparkSession._acquire_local_connect_start_lock()
+        try:
+            endpoint = SparkSession._reuse_from_discovery()
+            if endpoint is not None:
+                return endpoint
+            return SparkSession._start_persistent_local_connect_server(master, 
opts)
+        finally:
+            SparkSession._release_local_connect_start_lock(lock_fd)
+
+    @staticmethod
+    def _local_port_available(port: int) -> bool:
+        """Whether ``port`` can currently be bound on localhost (best effort, 
subject to races)."""
+        import socket
+
+        with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
+            try:
+                sock.bind(("localhost", port))
+                return True
+            except OSError:
+                return False
+
+    @staticmethod
+    def _local_connect_server_conf(opts: Dict[str, Any]) -> Dict[str, Any]:
+        """Start-up configs to seed a freshly started persistent server.
+
+        Mirrors the merge that the in-process ``_start_connect_server`` 
applies to its ``SparkConf``
+        so that first-run behavior matches (warehouse dir, app name, 
jars/packages, catalog confs,
+        etc.). Keys the daemon controls itself (master, port, token, plugins) 
and the reuse opt-in
+        keys are excluded. This only seeds the run that *starts* the server; a 
later run
+        reconnecting to an already-warm JVM cannot change its static configs.
+        """
+        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 (
+            "spark.remote",
+            "spark.api.mode",
+            "spark.master",
+            "spark.connect.authenticate.token",
+            "spark.connect.grpc.binding.port",
+            "spark.local.connect.reuse",
+            "spark.local.connect.server.port",
+            "spark.local.connect.server.idleTimeout",
+        ):
+            conf.pop(k, None)
+        return conf
+
+    @staticmethod
+    def _terminate_local_connect_server(proc: Any) -> None:
+        """Terminate a daemon started by 
``_start_persistent_local_connect_server`` and its JVM.
+
+        The daemon is a POSIX session leader (``start_new_session=True``) and 
its child JVM stays in
+        that process group, so signalling the group reaps both -- important 
when the timeout fires
+        before the daemon has wired up its own signal handling. Escalates to 
SIGKILL if a graceful
+        stop does not take. On non-POSIX platforms it falls back to 
terminating just the process.
+        """
+        try:
+            if os.name == "posix":
+                os.killpg(os.getpgid(proc.pid), signal.SIGTERM)
+            else:
+                proc.terminate()
+            try:
+                proc.wait(timeout=10)
+            except Exception:
+                if os.name == "posix":
+                    os.killpg(os.getpgid(proc.pid), signal.SIGKILL)
+                else:
+                    proc.kill()
+        except OSError:
+            pass
+
+    @staticmethod
+    def _start_persistent_local_connect_server(master: str, opts: Dict[str, 
Any]) -> str:
+        """Launch a detached persistent local Connect server and wait until it 
is reachable.
+
+        Callers must hold the start-up lock (see 
``_reuse_or_start_local_connect_server``). If the
+        server cannot be started but another process has meanwhile published a 
reusable one, this
+        reconnects to it rather than failing.
+        """
+        import socket
+        import subprocess
+        import tempfile
+
+        discovery_path = SparkSession._local_connect_discovery_path()
+        token = opts.get("spark.connect.authenticate.token") or 
str(uuid.uuid4())
+
+        # Choose the port. Tests use an ephemeral port (0) so they can run in 
parallel. Otherwise we
+        # honor the configured/default port, but fall back to an ephemeral one 
if it is already
+        # taken -- e.g. by a stale server we just rejected on version mismatch 
-- so a fresh server
+        # can still start instead of failing to bind.
+        if "SPARK_TESTING" in os.environ:
+            port = 0
+        else:
+            port = int(
+                opts.get("spark.local.connect.server.port", 
DefaultChannelBuilder.default_port())
+            )
+            if port != 0 and not SparkSession._local_port_available(port):
+                port = 0
+        idle_timeout = opts.get("spark.local.connect.server.idleTimeout", 
"3600")
+
+        # Seed the server with the caller's start-up confs (warehouse dir, 
jars, catalog, etc.) so
+        # first-run behavior matches the in-process path. Passed as a JSON 
file since confs are
+        # arbitrary key/values. Written under the discovery dir with 0600 
perms as it may hold
+        # sensitive values, and removed once the daemon has started.
+        conf_file = None
+        seed_conf = SparkSession._local_connect_server_conf(opts)
+        if seed_conf:
+            parent = os.path.dirname(discovery_path)
+            if parent and not os.path.isdir(parent):
+                os.makedirs(parent, exist_ok=True)
+            fd, conf_file = tempfile.mkstemp(prefix="connect-local-conf-", 
dir=parent or None)
+            with os.fdopen(fd, "w") as f:
+                json.dump(seed_conf, f)
+            os.chmod(conf_file, 0o600)
+
+        daemon = os.path.join(os.path.dirname(os.path.abspath(__file__)), 
"local_server.py")
+        cmd = [
+            sys.executable,
+            daemon,
+            "--master",
+            master,
+            "--port",
+            str(port),
+            "--token",
+            token,
+            "--discovery",
+            discovery_path,
+            "--idle-timeout",
+            str(idle_timeout),
+        ]
+        if conf_file is not None:
+            cmd += ["--conf-file", conf_file]
+
+        # Launch detached so the server outlives this client process.
+        env = dict(os.environ)
+        for var in ("SPARK_REMOTE", "SPARK_LOCAL_REMOTE", 
"SPARK_CONNECT_MODE_ENABLED"):
+            env.pop(var, None)
+        popen_kwargs: Dict[str, Any] = dict(
+            env=env,
+            stdin=subprocess.DEVNULL,
+            stdout=subprocess.DEVNULL,
+            stderr=subprocess.DEVNULL,
+        )
+        if os.name == "posix":
+            popen_kwargs["start_new_session"] = True
+        else:
+            detached = getattr(subprocess, "DETACHED_PROCESS", 0)
+            new_group = getattr(subprocess, "CREATE_NEW_PROCESS_GROUP", 0)
+            popen_kwargs["creationflags"] = detached | new_group
+        proc = subprocess.Popen(cmd, **popen_kwargs)
+
+        try:
+            # Wait for the server to write the discovery file (which records 
the actual bound port)
+            # and start accepting connections.
+            deadline = time.time() + 120
+            while time.time() < deadline:
+                exit_code = proc.poll()
+                if exit_code is not None:
+                    # Our daemon died. Another process may have published a 
usable server in the
+                    # meantime (e.g. a port race), so prefer reconnecting to 
it over failing.
+                    endpoint = SparkSession._reuse_from_discovery()
+                    if endpoint is not None:
+                        return endpoint
+                    raise PySparkRuntimeError(
+                        errorClass="LOCAL_CONNECT_SERVER_START_FAILED",
+                        messageParameters={
+                            "reason": "the server process exited with code 
{}".format(exit_code)
+                        },
+                    )
+                disc = SparkSession._read_local_connect_discovery()
+                if disc is not None and disc.get("pid") == proc.pid and 
disc.get("token") == token:
+                    with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as 
sock:
+                        sock.settimeout(0.5)
+                        if sock.connect_ex((disc["host"], int(disc["port"]))) 
== 0:
+                            os.environ["SPARK_CONNECT_AUTHENTICATE_TOKEN"] = 
token
+                            return "sc://{}:{}".format(disc["host"], 
disc["port"])
+                time.sleep(0.25)
+
+            # Timed out. The daemon may still be inside getOrCreate() -- i.e. 
before it has wired up
+            # its own SIGTERM handler -- and it has already spawned a child 
JVM. Signal the whole
+            # process group (the daemon is a session leader via 
start_new_session) and escalate to
+            # SIGKILL, so the JVM is reaped rather than orphaned.
+            SparkSession._terminate_local_connect_server(proc)
+            raise PySparkRuntimeError(
+                errorClass="LOCAL_CONNECT_SERVER_START_FAILED",
+                messageParameters={"reason": "the server did not become ready 
within 120 seconds"},
+            )
+        finally:
+            if conf_file is not None:
+                try:
+                    os.remove(conf_file)
+                except OSError:
+                    pass
+
+    @staticmethod
+    def _stop_local_connect_server() -> bool:

Review Comment:
   Right, that makes sense. done



-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: [email protected]

For queries about this service, please contact Infrastructure at:
[email protected]


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to