Skip to content

[SPARK-57787][CONNECT] Reuse a persistent local Spark Connect server for faster local startup - #56907

Closed
ericm-db wants to merge 24 commits into
apache:masterfrom
ericm-db:local-connect-reuse
Closed

[SPARK-57787][CONNECT] Reuse a persistent local Spark Connect server for faster local startup#56907
ericm-db wants to merge 24 commits into
apache:masterfrom
ericm-db:local-connect-reuse

Conversation

@ericm-db

@ericm-db ericm-db commented Jun 30, 2026

Copy link
Copy Markdown
Contributor

What changes were proposed in this pull request?

Adds an opt-in fast path for local Spark Connect development. Today
SparkSession.builder.remote("local[*]").getOrCreate() starts a fresh in-process Connect server
that lives only as long as that Python process, so every run re-pays the cold start (JVM warmup,
SparkContext + server boot).

When SPARK_LOCAL_CONNECT_REUSE=1 is set (or spark.local.connect.reuse=true on the builder), a
local-mode remote session instead reconnects to a persistent local Connect server. The first run
starts one through the standard sbin/start-connect-server.sh script (daemonized by
sbin/spark-daemon.sh, the same daemon a user would start by hand) and records host, port, auth
token, pid and Spark version in a discovery file. Later runs reuse that server if its Spark
version matches, its pid is alive, and its port accepts connections; otherwise they start a fresh
one. If a live but non-reusable server is still running (e.g. after a Spark upgrade), start-up
fails with an error pointing at the --stop command below, so old servers cannot silently
accumulate. User code is unchanged: the first run pays the cold start once, later runs reconnect
in a fraction of a second.

The implementation is pyspark/sql/connect/local_server.py, with Discovery handling the
discovery file (location, load/save, and the cross-process lock -- Discovery is a context
manager and all reads and writes happen under the lock), LocalConnectServer representing one
recorded server (its URL, reusability probe, stop), and ServerLauncher running the sbin script
and waiting for readiness. The first caller's startup confs are forwarded to the new server via a
--properties-file, mirroring what the in-process path does with its SparkConf.

The feature is off by default, Python-only, and POSIX-only (it relies on the sbin/ shell
scripts). All state -- discovery file, daemon pid file, logs -- lives in a per-user 0700
directory under the system temp dir rather than the home directory, so nothing accumulates
across reboots; SPARK_LOCAL_CONNECT_DISCOVERY overrides the location. The auth token is stored
with 0600 and the server binds localhost, so other users on the machine can neither read the
token nor reach the server. Each run is its own Connect session, so session-local state (temp
views, runtime SQL confs, artifacts) stays per-run; only shared SparkContext state (catalog,
global temp views, cached data) carries across runs.

The server runs until stopped with python -m pyspark.sql.connect.local_server --stop. It is an
ordinary spark-daemon.sh daemon, but it runs with a per-user pid directory and ident string so
it cannot collide with a manually started server -- which also means a plain
sbin/stop-connect-server.sh does not find it. The --stop command signals the recorded pid and
clears the discovery file; killing the pid directly also works, and the next run notices the dead
server and starts a fresh one. The earlier idle-timeout self-reaping was dropped with the switch
to the standard sbin daemon; a bounded lifetime would belong server-side (e.g. in
SparkConnectService), which also fits the follow-up discussed below of moving the
discovery-file write path into the server so any language client (and the proposed spark connect CLI) can use the same mechanism.

Why are the changes needed?

Creating a local Spark session for a quick edit/run loop takes a few seconds, and that cost is
one-time-per-process -- it does not amortize across separate runs. Keeping a warm server alive and
reconnecting to it is the only way to make a repeated local dev/test loop fast. This makes that
behavior available behind a single opt-in, without changing user code or default behavior.

Does this PR introduce any user-facing change?

Only when the opt-in is enabled. With SPARK_LOCAL_CONNECT_REUSE=1 (or
spark.local.connect.reuse=true on the builder),
SparkSession.builder.remote("local[*]").getOrCreate() starts a persistent local Connect server on
the first run and reconnects on later runs, instead of booting a fresh in-process server each time.
With the opt-in unset (the default), behavior is unchanged. A new documentation section describes
the manual persistent-server workflow.

How was this patch tested?

New python/pyspark/sql/tests/connect/test_connect_local_server.py. Unit tests cover the
Discovery save/load round-trip, file permissions and malformed-input rejection, the
is_reusable decision (version mismatch, dead pid, listening, closed port, and the Windows
pid-probe guard), stop semantics, the --stop CLI, and the POSIX guard. Four end-to-end tests
start a real server via the sbin scripts: builder integration with the opt-in, three concurrent
first-time startups converging on one server, reuse across calls with session isolation between
two connections, and the first caller's startup confs reaching the server's SparkConf.

Was this patch authored or co-authored using generative AI tooling?

Generated-by: Claude Code (Opus 4.8, Fable 5)

@gaogaotiantian

Copy link
Copy Markdown
Contributor

@ericm-db

ericm-db commented Jun 30, 2026

Copy link
Copy Markdown
Contributor Author

Oh yeah this was to address a comment that Nicholas had left on the doc he had linked in that thread.

@ericm-db

Copy link
Copy Markdown
Contributor Author

@nchammas I wasn't aware of the thread, only of the comment you left on the doc. Since you created the thread first - would you like me to close this PR so you can open what you had in mind? I'm totally fine either way.

@dbtsai

dbtsai commented Jun 30, 2026

Copy link
Copy Markdown
Member

Review feedback

Two issues in the persistent local Connect server path that are worth addressing before merge.

1. Concurrent first-time startup can fail spuriously (python/pyspark/sql/connect/session.py:1379)

In non-test mode _start_persistent_local_connect_server requests the fixed default port 15002 (session.py:1336-1338), and the wait loop hard-fails the moment the spawned daemon exits:

exit_code = proc.poll()
if exit_code is not None:
    raise PySparkRuntimeError(errorClass="LOCAL_CONNECT_SERVER_START_FAILED", ...)

If two opted-in processes start with no discovery file yet (parallel workers, an IDE spawning processes, several dev scripts), both spawn a daemon on 15002; one wins and writes discovery, the other's JVM can't bind and exits. The losing client then raises LOCAL_CONNECT_SERVER_START_FAILED immediately even though a reusable server is now available. The same port-collision mechanism also bites after a version upgrade: _local_connect_server_is_reusable rejects the stale server on version mismatch (session.py:1292), then we try to start a new one on 15002 that the old process still holds.

There is no lock around discovery creation and no reuse-the-winner retry on child exit. Suggested fix: before raising on exit_code is not None, do a final _read_local_connect_discovery() / _local_connect_server_is_reusable() check and reconnect if a usable server is now up; ideally also hold a file lock spanning discovery creation so only one process starts a server. (Note this is masked in tests because SPARK_TESTING forces an ephemeral port 0.)

2. The daemon drops most builder options on first startup (python/pyspark/sql/connect/local_server.py:140)

The in-process path _start_connect_server (session.py:1190-1234) merges the full opts into the SparkConf before SparkContext.getOrCreate(conf), so static/server-side settings (warehouse dir, app name, catalog configs, spark.jars/packages, driver settings, etc.) take effect. The persistent path only forwards --master, --port, --token, --idle-timeout (session.py:1342-1355), and local_server.py:140-151 builds the JVM with just master / plugins / port / artifact-isolation / token. The remaining opts are dropped.

After connecting, the client's _apply_options (session.py:204-228) can only apply runtime SQL confs per-session via conf._set_all(...) — it cannot establish static confs on an already-running JVM. So .remote("local[*]").config("spark.sql.warehouse.dir", ...) (and jars/packages/catalog/app-name) silently has no effect on first startup, diverging from the in-process behavior.

Caveat: a shared persistent server fundamentally can't honor per-client static confs for the second+ client (the JVM is already warm), so this promise is inherently limited. But at minimum the first client's relevant startup confs should seed the daemon so first-run behavior matches the in-process path.

@nchammas

nchammas commented Jul 1, 2026

Copy link
Copy Markdown
Contributor

@nchammas I wasn't aware of the thread, only of the comment you left on the doc. Since you created the thread first - would you like me to close this PR so you can open what you had in mind? I'm totally fine either way.

If the committers prefer your approach, continue with your PR.

For the record, what I concluded in that thread, after feedback from Holden and Tian, is that it would be better to take a different approach.

My proposal was to create a new spark CLI that has a subcommand for connect and document it accordingly. This would surface our sbin scripts to users in a more accessible way. No new configs or environment variables.

@ericm-db

ericm-db commented Jul 1, 2026

Copy link
Copy Markdown
Contributor Author

Thanks @dbtsai, both are real — fixed in df7b09a.

1. Concurrent first-time startup race. You're right, and it was masked in tests because SPARK_TESTING forces port 0. Fixed with three changes:

  • A discovery-file lock (fcntl.flock on <discovery>.lock) serializes start-up across processes, with a double-checked reuse inside the lock so only one process starts a server and the rest reconnect.
  • If our own spawned daemon exits, we now do a final _reuse_from_discovery() and reconnect to the winner before raising.
  • For the version-upgrade collision you described (stale server still holding the fixed port), if the configured port isn't bindable we fall back to an ephemeral port instead of failing.

Verified with two processes launched simultaneously against the fixed port: both return the same endpoint, one starts and one reconnects, neither raises. Also verified the upgrade case falls back to an ephemeral port.

2. Builder options dropped on first startup. Also fixed. The starting client's confs (minus the keys the daemon controls itself — master/port/token/plugins and the reuse opt-in keys) are now merged the same way _start_connect_server merges PYSPARK_REMOTE_INIT_CONF_* + opts, and passed to the daemon via a 0600 JSON conf file that seeds its SparkConf. New end-to-end test asserts a static conf (spark.sql.warehouse.dir) takes effect on the server — which the per-session _apply_options path can't do, so it proves the seed was forwarded.

As you noted, this is inherently first-client-only: a later run reconnecting to an already-warm JVM can't change static confs. I've documented that limitation.

@dbtsai

dbtsai commented Jul 1, 2026

Copy link
Copy Markdown
Member

Review feedback (follow-up)

Timeout path can orphan the child JVM (python/pyspark/sql/connect/session.py:1523)

On the 120s start-up timeout path, proc.terminate() sends SIGTERM only to the detached daemon Python process, which was started with start_new_session=True (a new session / process-group leader). That daemon launches a child JVM (SparkContext) inside local_server.py:main()'s builder.getOrCreate(), and its SIGTERM handler + spark.stop() cleanup are only wired up after getOrCreate() returns.

If the timeout fires while the daemon is still inside getOrCreate() — exactly the slow/hung-startup case that triggers the timeout — terminating just the daemon leader leaves the JVM orphaned with no cleanup. The leaked JVM keeps consuming memory/CPU and may later finish binding the requested port, confusing subsequent reuse attempts. This is most likely to hit precisely when the host is already resource-constrained.

Suggested fix: signal the whole process group rather than just the daemon PID, and escalate to kill if it does not exit, so the orphaned JVM is reaped. On POSIX, kill the session/group created by start_new_session=True; on the daemon side, install the SIGTERM handler / cleanup before or around the JVM launch.

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

@ericm-db

ericm-db commented Jul 1, 2026

Copy link
Copy Markdown
Contributor Author

Good catch, fixed in bd938e7.

The timeout path now signals the whole process group and escalates to SIGKILL if a graceful stop doesn't take, so the child JVM is reaped rather than orphaned:

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()

This works because the daemon is a session leader (start_new_session=True) and pyspark launches the JVM without its own new process group (java_gateway.py only sets a preexec_fn to ignore SIGINT), so the JVM stays in the daemon's group and killpg reaps both.

I kept the daemon's own SIGTERM handler where it is (after getOrCreate()), since the group-kill + SIGKILL escalation covers the pre-handler window regardless. Verified manually: started a daemon, waited until it had spawned its JVM child, then ran the terminate helper — both the daemon and the JVM are gone afterward.

@ericm-db

ericm-db commented Jul 1, 2026

Copy link
Copy Markdown
Contributor Author

Added a regression test for this in 321128d (test_terminate_reaps_daemon_and_jvm): it starts the detached daemon, waits until it has actually spawned its child JVM, then calls _terminate_local_connect_server and asserts the whole process group is gone (os.killpg(pgid, 0) raises ProcessLookupError). POSIX-only, since the process-group reaping is POSIX-specific.

Also tightened the test tearDown to wait for the server port to close after stopping, so a stopped server's JVM can't linger into the next test.

@ericm-db

ericm-db commented Jul 1, 2026

Copy link
Copy Markdown
Contributor Author

@nchammas, I had misread the intention of your comment, I didn't think your proposal and this PR were in conflict, I thought we could proceed with both.
I think your idea is probably the direction we want to move in. Let me close this PR

@ericm-db ericm-db closed this Jul 1, 2026
@nchammas

nchammas commented Jul 1, 2026

Copy link
Copy Markdown
Contributor

@nchammas, I had misread the intention of your comment, I didn't think your proposal and this PR were in conflict, I thought we could proceed with both. I think your idea is probably the direction we want to move in. Let me close this PR

Yes, we could proceed with both. There's no need to close this PR on my account!

I have a working prototype of the unified CLI on my local branch. I am just waiting for some committer to show interest in the idea -- especially since it's a new user interface -- before pushing it and opening a PR.

@gaogaotiantian

Copy link
Copy Markdown
Contributor

Yes, we could proceed with both. There's no need to close this PR on my account!

I have a working prototype of the unified CLI on my local branch. I am just waiting for some committer to show interest in the idea -- especially since it's a new user interface -- before pushing it and opening a PR.

Hi @nchammas , I had an offline discussion with @ericm-db . We think you've put a lot of effort into the discussion and you probably want to start this project. Also the interface needs to be designed (if local[*] should be reused with a config, or a new syntax should be used).

I think the idea of a new CLI interface is interesting, but you probably need more than my support.

Anyway, I believe @ericm-db is also very interested in this project and you could work together on this!

@nchammas

nchammas commented Jul 2, 2026

Copy link
Copy Markdown
Contributor

I think the idea of a new CLI interface is interesting, but you probably need more than my support.

Do you think I need to create an SPIP, or shall I just open a PR and share it on the dev list? I wasn't sure if an SPIP would be overkill so I stuck to the dev list.

Anyway, I believe @ericm-db is also very interested in this project and you could work together on this!

Happy to collaborate. I think a good sketch of what a collaboration might look like, given @ericm-db's work on this PR, is to figure out how automatic local discovery of an existing Spark Connect server would work.

The unified CLI would be a relatively standalone piece of work. But as we make it easier for users to manage Connect servers via the new CLI, Eric can lead the effort to make leveraging that more seamless.

In other words:

  1. I will lead the effort for the unified CLI. This will include a new spark connect subcommand.
  2. Eric will lead the effort to enhance local sessions to automatically discover and use an existing local Connect server.
  3. Eric and I will collaborate on the local Connect server discovery mechanism. I think of it as the interface between spark connect start and SparkSession.builder.remote("local[*]").getOrCreate().

What do you think?

@ericm-db

ericm-db commented Jul 2, 2026

Copy link
Copy Markdown
Contributor Author

@nchammas sounds great to me!

@ericm-db

ericm-db commented Jul 6, 2026

Copy link
Copy Markdown
Contributor Author

Reopening after offline discussion with @nchammas

@ericm-db ericm-db reopened this Jul 6, 2026
@nchammas

nchammas commented Jul 6, 2026

Copy link
Copy Markdown
Contributor

Just to be clear to others reading along, our offline discussion was simply a reiteration of the plan I proposed earlier in this thread.

I am waiting for feedback from @gaogaotiantian or some other committer on how best to proceed with regards to the unified CLI.

@dtenedor dtenedor left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for working on this! Mostly LGTM, the test coverage is solid. We can figure this out on an opt-in basis for now while we think about next steps on the mailing list.

Comment thread python/pyspark/sql/connect/session.py Outdated
pass

@staticmethod
def _stop_local_connect_server() -> bool:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This signals only the daemon PID, not the process group.
Unlike the timeout path, stop uses os.kill(pid, SIGTERM) only. If called while the daemon is still inside getOrCreate() (before its signal handler is installed), the JVM could be orphaned — same class of bug that was fixed for timeout. Unlikely in normal use (stop is a dev helper after the server is up), but inconsistent with _terminate_local_connect_server. Consider reusing the group-kill helper.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Right, that makes sense. done

Comment thread python/pyspark/sql/tests/connect/test_connect_local_server.py Outdated
Comment thread docs/spark-connect-overview.md Outdated
every run and never leaks between runs. State backed by the shared `SparkContext` (the persistent
catalog/warehouse, global temp views, and cached datasets) *is* shared across runs, so namespace
per-run databases or clear that state yourself if your runs must be fully isolated.
- The server listens on port `15002` by default and authenticates with a token written, together

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

On version mismatch, a new server starts on an ephemeral port and writes discovery. The old server on 15002 keeps running until idle timeout. Over repeated upgrades, multiple idle JVMs could accumulate. Docs could mention killing the old pid from a stale discovery file.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

added to the docs

not should_test_connect or is_remote_only(),
connect_requirement_message or "Requires JVM access to start a local Connect server",
)
class LocalConnectServerReuseTests(unittest.TestCase):

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could we also add an automated concurrent-startup test?
And one integration test through SparkSession.builder.remote("local[*]").getOrCreate() with the env var set?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added the test.

@dbtsai

dbtsai commented Jul 7, 2026

Copy link
Copy Markdown
Member

@zhengruifeng @viirya , can you take a look as well? Thanks.

@nchammas nchammas left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I am not a Connect expert, but it feels a bit off to be creating the discovery file from Python vs. via SparkConnectServer (the class that gets run when you call start-connect-server.sh) or SparkConnectService.

Ideally, we want any language client to be able to leverage the Connect server discovery
mechanism. Doesn't that mean the write/manage path for this mechanism should live in Scala?

Comment thread docs/spark-connect-overview.md Outdated
Comment thread docs/spark-connect-overview.md Outdated
catalog/warehouse, global temp views, and cached datasets) *is* shared across runs, so namespace
per-run databases or clear that state yourself if your runs must be fully isolated.
- The server listens on port `15002` by default and authenticates with a token written, together
with the host, port, pid and Spark version, to `~/.spark/connect-local.json` (mode `0600`).

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As far as I can tell, this is the first use of ~/.spark/ in the project. There are some aspects to discuss here since there has been some pushback against storing app data under ~/, even though it is very common.

On Linux, there is the XDG Base Directory Specification. On macOS, there is ~/Library/ and the various directories underneath. Windows probably has its own setup, too.

Python has the platformdirs library for taking care of these platform-specific differences. Perhaps Scala/Java also has something similar.

We should consider whether we want to try to follow these platform-specific conventions, even though our lives would be easier in the short term to just put things under ~/.spark/. Another idea is to use a specific path under SPARK_HOME. I think it would be a small win if we could eliminate the need for a new config or env var that's specific to this file's location.

@ericm-db ericm-db Jul 8, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

One hard constraint: the file holds a per-user auth token, so it must live in a per-user private location - that rules out SPARK_HOME, which is commonly shared/read-only (a system install serving multiple users would either collide or leak the token).

Within per-user options I don't feel strongly. ~/.spark/ follows the dev-tool convention Spark users already touch (~/.ivy2, ~/.m2, ~/.aws), but I'd be fine honoring $XDG_STATE_HOME/spark/ when that variable is set (no new dependency needed) and falling back to ~/.spark/ otherwise - platformdirs would mean a new runtime dep, which PySpark tries to avoid. SPARK_LOCAL_CONNECT_DISCOVERY already exists as the explicit override. If the Scala-side follow-up from your other comment happens, that's probably the right moment to settle the canonical path project-wide.

@ericm-db
ericm-db requested a review from nchammas July 8, 2026 17:13

@dtenedor dtenedor left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM, let's work with @nchammas and the items he points out as well before merging it. Thanks for working on this!

@ericm-db

ericm-db commented Jul 8, 2026

Copy link
Copy Markdown
Contributor Author

I am not a Connect expert, but it feels a bit off to be creating the discovery file from Python vs. via SparkConnectServer (the class that gets run when you call start-connect-server.sh) or SparkConnectService.

Ideally, we want any language client to be able to leverage the Connect server discovery mechanism. Doesn't that mean the write/manage path for this mechanism should live in Scala?

@nchammas Agreed on the end state: language-neutral discovery is the right goal. The natural design is SparkConnectService writing the discovery file itself when it binds (behind something like spark.connect.discovery.file) and removing it on stop. That gets three things this PR can't: any language client can discover servers, your spark CLI can read the same file, and servers started manually via sbin/start-connect-server.sh become discoverable too.

I kept the write path in Python here for scope reasons. The opt-in replaces a spawn path that's already Python-owned, and a Python component has to exist regardless, something client-side needs to make the reuse decision, launch the detached server on first use, and run the idle-timeout reaper. Moving the write into the server doesn't remove that piece, it just thins it: Python (and every other client) becomes a pure reader/launcher.

My proposal: land this as the Python opt-in, then file a follow-up to move the write/manage path into SparkConnectService. That change touches the server and effectively defines the discovery file as a cross-language contract (format, location, multi-server behavior), so it deserves Connect-reviewer attention and probably belongs alongside the CLI SPIP discussion. Happy to file that JIRA and take it. If you'd rather settle the contract first, I'm open to that too, but I'd rather not grow this PR into a server change.

@gaogaotiantian gaogaotiantian left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this implementation has plenty of details that need to be sorted out. But I want to talk about structures before we dig into details.

This whole feature is designed to be used locally for debug purpose. How safe do we want to make it? Do we need to protect from other users on the same machine? We might but that's a question.

We are saving about 2-3s per process spawn - that's a very important guidance. That's what time range we should work for. Having the process server persistent for 1 hr seems a bit too much for me.

I don't like how everything is implemented in sql/connect/session.py as static methods. Static methods should rarely be used - it basically means the method does not have to be in the class. As a matter of fact, I do believe most of the code should not be part of the class.

If you take a look at the current implementation, most of it is very specific to how local server is implemented. How the json data is stored, how the server deals with signals.

I believe most of the new session.py code should live in local_server.py, as a separate class. All the server specific code should be there (including how we pass the arguments). It should provide a single interface like class LocalConnectServer which handles all the life cycles (including spawning detached process that actually runs the server).

There should be no implementation detail of the local server itself in session.py. Let's write better Python code :)

Once the server is accepting connections it writes a discovery file (host, the actually bound port,
the auth token, its pid and the Spark version) that later client processes read to reconnect.

It is launched by file path rather than ``python -m`` so it does not require the Spark Connect

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is feels wrong to me. A script in sql/connect that is executed by file - it does not fit the pattern. Why do we need to avoid requiring client requirements? This is already designed as a local debugging tool. I don't think we need to distinguish between "server" and "client". This script is executed by the user script which needs the client to work right?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed. Made the change

# ordinary imports. Drop it before importing anything else (`import sys` cannot be shadowed);
# pyspark stays importable via the remaining sys.path entries.
if sys.path:
del sys.path[0]

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Things like this is why I'm against running it as a file.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Gone with the switch to use python -m instead of executing via file.

def _write_discovery(path: str, host: str, port: int, token: str, version: str) -> None:
"""Atomically write the discovery file with ``0600`` perms (it holds the auth token)."""
parent = os.path.dirname(path)
if parent and not os.path.isdir(parent):

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What's this line protect against?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Removed this line - os.mkdirs(parent, exist_ok = True) protects this.

disc = json.load(f)
except (OSError, ValueError):
return
if disc.get("pid") == os.getpid():

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we care about any racing issues?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah there was one, between read and unlink.
A freshly started server could have replaced the file and the exiting daemon would delete the newcomer's entry.

It's fixed now though, the launching client has a lock, which removal needs to fetch. If the removal can't fetch it (startup is currently being executed), removal is skipped. Added a test for this.

parser.add_argument(
"--idle-timeout",
type=float,
default=3600.0,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For local development, this seems a bit too long.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Lowered to 1800s. Can tune further if you have something specific in mind.

Comment thread python/pyspark/sql/connect/session.py Outdated
Comment thread python/pyspark/sql/connect/session.py Outdated
Comment thread python/pyspark/sql/connect/session.py Outdated
if disc.get("spark_version") != __version__:
return False
try:
os.kill(int(disc["pid"]), 0)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think this is a no-op on Windows.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yeah you're right - we need to skip on non POSIX systems. Added a test.

Comment thread python/pyspark/sql/connect/session.py Outdated
Comment thread python/pyspark/sql/connect/session.py Outdated

It is launched by file path rather than ``python -m`` so it does not require the Spark Connect
*client* dependencies (grpc, etc.): a server only needs a classic PySpark install plus the Connect
server jar, like ``sbin/start-connect-server.sh``. It imports only the classic ``pyspark.sql`` API.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In order to do this, I think more correct way is directly invoke sbin/start-connect-server.sh instead of going through Py4J. The current way of using Py4J (without this change) itself is quite hacky

@ericm-db
ericm-db requested a review from gaogaotiantian July 8, 2026 23:30
ericm-db added 20 commits July 29, 2026 18:01
On the start-up timeout path, terminating only the detached daemon process left its child JVM
orphaned when the timeout fired before the daemon had installed its own signal handling (the slow-
startup case that triggers the timeout). Signal the whole process group -- the daemon is a session
leader and the JVM stays in its group -- and escalate to SIGKILL if a graceful stop does not take,
so the JVM is reaped instead of leaking.
Adds a POSIX-only test that starts the detached daemon, waits until it has spawned its child JVM,
then calls _terminate_local_connect_server and asserts the whole process group is gone -- covering
the orphaned-JVM case the timeout-path fix addresses. Also waits for the server port to close in
tearDown so a stopped server's JVM cannot linger into the next test.
…ing the local server

The stop path signals the pid recorded in the discovery file via killpg. If
that pid is stale (e.g. the daemon was SIGKILLed and left its discovery file
behind) and has been recycled by an unrelated process, group-killing it could
take down the caller's own process group. The daemon is always launched as a
session leader, so its group id equals its pid; only signal the group when
that holds, and fall back to a plain kill otherwise.

Adds a unit test covering both directions (with the signal syscalls mocked so
a regression cannot kill the test runner), and a ruff-format fix for a missing
blank line after the local_server.py module docstring.

Co-authored-by: Isaac
…ves the daemon

test_terminate_reaps_daemon_and_jvm fails in CI with "ProcessLookupError not
raised": _terminate_local_connect_server keyed SIGKILL escalation on the
daemon timing out, so when the daemon exits promptly on SIGTERM while its
JVM's graceful shutdown lingers (or wedges), nothing ever escalates and the
group survives. Terminate now waits for the whole process group to disappear
after the daemon settles and group-SIGKILLs any survivors past a 10s grace
period. It signals the group id directly since the leader may already be
reaped by then, and polls the daemon so a zombie leader does not keep the
group looking alive.

Adds a deterministic regression test (a session leader that exits on SIGTERM
after spawning a SIGTERM-immune child standing in for the slow JVM), and
hardens both terminate tests against macOS zombie semantics: killpg on a
group holding only a zombie yields EPERM, and kill(pid, 0) succeeds for a
zombie until it is reaped.

Co-authored-by: Isaac
…te tests

Both terminate tests fail in CI with "process group survived": the tests
checked group death via killpg(pgid, 0), but a zombie keeps its group
signalable, and in CI containers whose pid 1 never reaps, the daemon's JVM
reparents on the daemon's death and lingers as a zombie indefinitely -- so
the check never turns negative even though nothing is running. (macOS
launchd reaps promptly, which is why the tests pass locally.)

The tests now assert that no *running* (non-zombie) process remains in the
group, using ps to inspect member states.

Co-authored-by: Isaac
…tion

Review feedback: prefer fenced code blocks over Liquid highlight tags in the
new documentation section; they render the same and keep raw-Markdown
editors from mistaking comment hashes for headings.

Co-authored-by: Isaac
… run the daemon via -m

Addresses review comments:
- Launch the daemon with `python -m pyspark.sql.connect.local_server` instead
  of by file path, dropping the sys.path workaround; the daemon always runs
  from the client's environment, which has the Connect client dependencies.
- Move all client-side reuse helpers off SparkSession (they were all
  staticmethods) into pyspark/sql/connect/local_server.py as module
  functions; connect/session.py is back to its upstream state.
- Pass the auth token only through SPARK_CONNECT_AUTHENTICATE_TOKEN (same
  precedence as the in-process path); it no longer appears on the daemon
  argv (visible in ps) nor as a duplicate conf (the server falls back to
  the env var when the conf is unset).
- Probe pid liveness only on POSIX: os.kill(pid, 0) terminates the target
  process on Windows.
- Close the remove-vs-publish race on the discovery file by claiming the
  start-up lock non-blockingly in _remove_discovery_if_ours.
- Turn the start-up lock into a contextmanager; validate discovery value
  types on read and index the dict directly afterwards; simplify
  _discovery_path and the makedirs guards; drop the SIGINT try/except
  (main only runs on the main thread); reap the SIGKILLed daemon with a
  short wait instead of a bare poll.
- Lower the default idle timeout to 1800s, add --stop to the module CLI,
  and update the docs accordingly.

Co-authored-by: Isaac
…rt-connect-server.sh

Reworks the opt-in local Connect server reuse path per review feedback:
instead of a detached Python daemon that boots a classic Py4J session with
the Connect plugin and babysits it, the launcher now drives the standard
`sbin/start-connect-server.sh` (spark-daemon.sh submit SparkConnectServer)
and lets spark-daemon.sh own the JVM (pid file, logs, daemonization).

- No Python server process any more: PySpark runs the script with the auth
  token in its env, waits for the port, writes the discovery file (pid
  taken from the spark-daemon pid file), and returns the endpoint.
- Deletes the Py4J usage, signal handling, idle-session polling,
  process-group management and SIGKILL escalation, and the discovery-file
  removal race guard -- none of it has an equivalent in the new shape.
- Idle self-termination goes away with the daemon; the server runs until
  stopped (`python -m pyspark.sql.connect.local_server --stop`, or
  `sbin/stop-connect-server.sh`). `spark.local.connect.server.idleTimeout`
  is removed.
- The server port is picked client-side (configured port when free, else
  an OS-assigned one), since the standalone script cannot report a bound
  ephemeral port back.
- Seed confs travel via a 0600 spark-submit --properties-file instead of a
  JSON file parsed by the daemon. Artifact isolation needs no special
  handling: SparkConnectServer enables it itself.
- The reuse path is POSIX-only now (it drives the sbin shell scripts) and
  says so explicitly; server logs land next to the discovery file
  (~/.spark/logs by default) for debuggability.
- Ships start/stop-connect-server.sh in the pip sbin package (their
  dependencies, bin/* and spark-daemon.sh, were already packaged).

Net effect vs the previous approach: -313 lines, and the server lifecycle
is exactly the one users get from the documented manual workflow.

Co-authored-by: Isaac
…e manual server workflow

Per review discussion, spark.local.connect.reuse / SPARK_LOCAL_CONNECT_REUSE
stays an internal, undocumented conf. The docs now cover only the explicit
workflow (sbin/start-connect-server.sh + .remote("sc://...")) plus a note on
what is and is not isolated between runs against a shared local server.

Co-authored-by: Isaac
… move its state to a per-user temp dir

Address review feedback on code structure, state location, and test strategy:

- Rebuild local_server.py around three components instead of a flat list of
  module functions: LocalConnectServer (a typed record of one server with the
  operations scoped to it: url, is_reusable, stop), Discovery (the discovery
  file: location, load/save/clear, and the cross-process start-up lock), and
  ServerLauncher (port choice, seed confs, running the sbin script, readiness
  wait). The module entry points and the session.py integration are unchanged.
- Move the discovery file, daemon pid file, and logs from ~/.spark to a
  per-user 0700 directory under the system temp dir, so nothing accumulates in
  permanent directories and stale records do not survive a reboot. The
  directory's ownership is verified before trusting it, and the module
  docstring now states the threat model explicitly.
- Hold the start-up lock in stop_local_connect_server so a stop racing a
  concurrent launch cannot unlink the newcomer's record mid-start.
- Rewrite the tests against the components' public interfaces instead of
  enumerating private functions: 19 tests become 13 with the same behavioral
  coverage, folding the reusability cases into subtests, dropping low-value
  tests of internal helpers, and keeping all four end-to-end tests.

Co-authored-by: Isaac
Cut the module docstring down to what the code cannot say itself, drop
docstrings that restated method names, fold the remaining comments into
the few places with a non-obvious constraint (Windows os.kill semantics,
token and conf handling off argv, the world-writable temp dir check),
and remove the section banners from the test file.

Co-authored-by: Isaac
…ry per review

Address review feedback: document SPARK_LOCAL_CONNECT_REUSE in the Connect
overview docs alongside the manual sbin workflow; normalize the discovery path
so the getcwd/dirname special cases disappear; drop the atomic-rename dance in
save() since all access holds the exclusive lock (re-asserting 0600 via fchmod);
explain runtime-dir claim failures in the raised OSError; turn the seed
properties file into a NamedTemporaryFile-backed context manager; move daemon
pid reading into Discovery; and fix the stale upgrade story in the docs and the
_pick_port docstring.

Co-authored-by: Isaac
Claude-Session: https://claude.ai/code/session_0146yRfjDdT8pWGFoR3Ydtez
@ericm-db
ericm-db force-pushed the local-connect-reuse branch from f4d9735 to 5f1f97b Compare July 30, 2026 01:01
…r cannot be claimed

The PySpark custom errors check rejects raising the builtin PermissionError
under python/pyspark/sql. Raise PySparkRuntimeError with a new
LOCAL_CONNECT_RUNTIME_DIR_UNAVAILABLE condition instead, matching the rest
of local_server.py.

Co-authored-by: Isaac
Claude-Session: https://claude.ai/code/session_01CFe4ZLGQWimrzCDdzvGMVU
@ericm-db

Copy link
Copy Markdown
Contributor Author

@gaogaotiantian @dtenedor CI is green and this PR is ready to merge

gaogaotiantian pushed a commit that referenced this pull request Jul 30, 2026
…for faster local startup

### What changes were proposed in this pull request?

Adds an opt-in fast path for local Spark Connect development. Today
`SparkSession.builder.remote("local[*]").getOrCreate()` starts a fresh in-process Connect server
that lives only as long as that Python process, so every run re-pays the cold start (JVM warmup,
`SparkContext` + server boot).

When `SPARK_LOCAL_CONNECT_REUSE=1` is set (or `spark.local.connect.reuse=true` on the builder), a
`local`-mode remote session instead reconnects to a persistent local Connect server. The first run
starts one through the standard `sbin/start-connect-server.sh` script (daemonized by
`sbin/spark-daemon.sh`, the same daemon a user would start by hand) and records host, port, auth
token, pid and Spark version in a discovery file. Later runs reuse that server if its Spark
version matches, its pid is alive, and its port accepts connections; otherwise they start a fresh
one. If a live but non-reusable server is still running (e.g. after a Spark upgrade), start-up
fails with an error pointing at the `--stop` command below, so old servers cannot silently
accumulate. User code is unchanged: the first run pays the cold start once, later runs reconnect
in a fraction of a second.

The implementation is `pyspark/sql/connect/local_server.py`, with `Discovery` handling the
discovery file (location, load/save, and the cross-process lock -- `Discovery` is a context
manager and all reads and writes happen under the lock), `LocalConnectServer` representing one
recorded server (its URL, reusability probe, stop), and `ServerLauncher` running the sbin script
and waiting for readiness. The first caller's startup confs are forwarded to the new server via a
`--properties-file`, mirroring what the in-process path does with its `SparkConf`.

The feature is off by default, Python-only, and POSIX-only (it relies on the `sbin/` shell
scripts). All state -- discovery file, daemon pid file, logs -- lives in a per-user `0700`
directory under the system temp dir rather than the home directory, so nothing accumulates
across reboots; `SPARK_LOCAL_CONNECT_DISCOVERY` overrides the location. The auth token is stored
with `0600` and the server binds localhost, so other users on the machine can neither read the
token nor reach the server. Each run is its own Connect session, so session-local state (temp
views, runtime SQL confs, artifacts) stays per-run; only shared `SparkContext` state (catalog,
global temp views, cached data) carries across runs.

The server runs until stopped with `python -m pyspark.sql.connect.local_server --stop`. It is an
ordinary `spark-daemon.sh` daemon, but it runs with a per-user pid directory and ident string so
it cannot collide with a manually started server -- which also means a plain
`sbin/stop-connect-server.sh` does not find it. The `--stop` command signals the recorded pid and
clears the discovery file; killing the pid directly also works, and the next run notices the dead
server and starts a fresh one. The earlier idle-timeout self-reaping was dropped with the switch
to the standard sbin daemon; a bounded lifetime would belong server-side (e.g. in
`SparkConnectService`), which also fits the follow-up discussed below of moving the
discovery-file write path into the server so any language client (and the proposed `spark
connect` CLI) can use the same mechanism.

### Why are the changes needed?

Creating a local Spark session for a quick edit/run loop takes a few seconds, and that cost is
one-time-per-process -- it does not amortize across separate runs. Keeping a warm server alive and
reconnecting to it is the only way to make a repeated local dev/test loop fast. This makes that
behavior available behind a single opt-in, without changing user code or default behavior.

### Does this PR introduce _any_ user-facing change?

Only when the opt-in is enabled. With `SPARK_LOCAL_CONNECT_REUSE=1` (or
`spark.local.connect.reuse=true` on the builder),
`SparkSession.builder.remote("local[*]").getOrCreate()` starts a persistent local Connect server on
the first run and reconnects on later runs, instead of booting a fresh in-process server each time.
With the opt-in unset (the default), behavior is unchanged. A new documentation section describes
the manual persistent-server workflow.

### How was this patch tested?

New `python/pyspark/sql/tests/connect/test_connect_local_server.py`. Unit tests cover the
`Discovery` save/load round-trip, file permissions and malformed-input rejection, the
`is_reusable` decision (version mismatch, dead pid, listening, closed port, and the Windows
pid-probe guard), stop semantics, the `--stop` CLI, and the POSIX guard. Four end-to-end tests
start a real server via the sbin scripts: builder integration with the opt-in, three concurrent
first-time startups converging on one server, reuse across calls with session isolation between
two connections, and the first caller's startup confs reaching the server's `SparkConf`.

### Was this patch authored or co-authored using generative AI tooling?

Generated-by: Claude Code (Opus 4.8, Fable 5)

Closes #56907 from ericm-db/local-connect-reuse.

Authored-by: Eric Marnadi <eric.marnadi@databricks.com>
Signed-off-by: Tian Gao <gaogaotiantian@hotmail.com>
(cherry picked from commit 0366e48)
Signed-off-by: Tian Gao <gaogaotiantian@hotmail.com>
@gaogaotiantian

Copy link
Copy Markdown
Contributor

Merge Summary:

Posted by merge_spark_pr.py

@gaogaotiantian

Copy link
Copy Markdown
Contributor

Thank you for your patience @ericm-db !

@ericm-db

Copy link
Copy Markdown
Contributor Author

Thanks for all the help and review @gaogaotiantian!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

6 participants