Skip to content

[SPARK-58021][CONNECT] Add an opt-in pool of pre-warmed, single-use local Spark Connect servers#57102

Draft
ericm-db wants to merge 11 commits into
apache:masterfrom
ericm-db:local-connect-pool
Draft

[SPARK-58021][CONNECT] Add an opt-in pool of pre-warmed, single-use local Spark Connect servers#57102
ericm-db wants to merge 11 commits into
apache:masterfrom
ericm-db:local-connect-pool

Conversation

@ericm-db

@ericm-db ericm-db commented Jul 8, 2026

Copy link
Copy Markdown
Contributor

What changes were proposed in this pull request?

Adds a second opt-in fast path for local Spark Connect development, complementing the
persistent-server reuse mode of #56907 (SPARK-57787): a pool of pre-warmed, single-use local
Connect servers.

With SPARK_LOCAL_CONNECT_POOL=1 (or .config("spark.local.connect.pool", "true")),
SparkSession.builder.remote("local[*]").getOrCreate():

  1. claims a fresh, never-used server from the pool directory (~/.spark/connect-local-pool,
    override via SPARK_LOCAL_CONNECT_POOL_DIR) -- a claim is an atomic rename of the server's
    discovery file, so exactly one racing client wins;
  2. asynchronously spawns replacements to keep spark.local.connect.pool.size (default 2) members
    warm;
  3. tears the claimed server down asynchronously when the session stops or the client exits --
    no server ever serves two runs.

Mechanics:

  • Every member binds an ephemeral port with its own auth token, is seeded with the spawning
    client's start-up confs, and is stamped with a conf fingerprint; a run only claims members whose
    fingerprint matches its own confs, so a warm JVM is never handed to a run whose static confs
    differ from what it was booted with.
  • A best-effort fcntl lock serializes spawn accounting so concurrent cold starters share one
    complement of launches instead of each spawning their own; claims themselves are lock-free
    atomic renames. Platforms without fcntl may transiently over-spawn; extras are reclaimed by
    the idle timeout (same degradation posture as the reuse path's start lock).
  • A janitor reaps stale state on every acquire: launches whose daemon died, unreachable or
    version-mismatched members, servers claimed by clients that died uncleanly (e.g. SIGKILL), and
    retired servers that hang in shutdown (SIGTERM at release, escalated to a hard kill after 30s).
  • Members JIT-warm themselves while idle: after publishing their discovery file they run a
    fixed set of synthetic queries (a classic pass, then a self-connect pass exercising the
    Connect planner / Arrow / gRPC path), so the one real run each member serves starts with hot
    JVM-global JIT and codegen caches. No user code or state is involved, and every member gets
    the identical warmup. Disable with SPARK_LOCAL_CONNECT_POOL_WARMUP=0.
  • Server-side backstops: pool members run with --exit-after-use (self-terminate once their one
    client's session has come and gone) and an idle timeout
    (SPARK_LOCAL_CONNECT_POOL_IDLE_TIMEOUT, default 1800s), so an idle machine drains to zero.
  • Detached daemons are debuggable via SPARK_LOCAL_CONNECT_SERVER_DEBUG_LOG=<file>, which logs
    timestamped lifecycle phases (boot, warmup, signals, shutdown).
  • SparkSession._purge_local_connect_pool() force-kills every member (warm, in-flight, or
    claimed) and empties the pool directory -- a one-command "really clean state" escape hatch.

Relationship to the reuse mode (#56907): reuse keeps one long-lived server that all runs
reconnect to -- cheapest, but state backed by the shared SparkContext (persistent catalog,
global temp views, cached data) carries across runs and the JVM ages. The pool trades standing
memory (~550 MB RSS per warm member measured on macOS with default confs; the default pool of 2
is about 1.1 GB while actively iterating) for per-run isolation identical to today's default
behavior. This directly addresses the determinism concern raised on the dev list about reusing a
long-lived backend server:
https://lists.apache.org/thread/sg9o2gbb3nttz74f0s01v8f167zy8ltt

Stacked on #56907: the earlier commits belong to that PR; only the last commit is new here.
Will rebase once #56907 merges.

Why are the changes needed?

Local getOrCreate() pays a multi-second JVM + SparkContext cold start in every process.
#56907 removes that cost by reusing one long-lived server, but reuse was flagged on the dev list
as a determinism/isolation risk if it ever became a default: a shared backend can be left in a
bad state that no fresh environment reproduces. The pool keeps the latency win while preserving
fresh-server-per-run semantics, making it a candidate for an eventual default-on story.

Measured on a dev laptop (medians; local[*], default confs). "Time to first result" is what
the user waits between launching python script.py and the first query returning: import +
getOrCreate() + first query. The rows:

  • default: today's behavior, no flags. Every run boots a private JVM + SparkContext +
    Connect server in-process and discards it at exit, so every run pays the full cold start plus
    the fresh JVM's first-query JIT cost.
  • pool, warmup disabled (SPARK_LOCAL_CONNECT_POOL_WARMUP=0): the run claims a pre-booted,
    never-used server, so the session comes up fast -- but that JVM has never executed a query,
    so the first query still pays the full JIT/codegen cost. Not a recommended configuration;
    shown only to isolate what the self-warmup contributes.
  • pool with warmup: this PR with defaults. Same fast claim, and the member JIT-warmed
    itself on synthetic queries while idling in the pool, so the first real query is also fast.
    This row is the user experience of this PR.
  • reuse ([SPARK-57787][CONNECT] Reuse a persistent local Spark Connect server for faster local startup #56907), warm: the sibling reuse mode in steady state -- second and later runs
    reconnecting to the one long-lived shared server, hot from previous runs. Included as the
    speed ceiling: the fastest any design can be, achieved by sharing a JVM across runs.
mode getOrCreate first query time to first result
default (in-process, every run cold) 2.23s 1.34s ~3.7s
pool, warmup disabled 0.25s 1.41s ~1.9s
pool with warmup (default) 0.24s 0.04s ~0.38s
reuse (#56907), warm 0.23s 0.03s ~0.35s

The pool and reuse rows assume the once-per-work-session bootstrap already happened (the first
run on a cold pool costs ~2.8-3.0s, a cold reuse start ~2.5s); the default row has no such
footnote because every run is its own bootstrap.

Over a 16-query session (4 shapes x 4 rounds): warmed pool 1.14s total vs 1.21s for a reused
server seeing those shapes for the first time vs 0.75s for a reused server that has executed the
exact same queries before. The fresh-JVM warmup tax is front-loaded and bounded, not per-query.

Does this PR introduce any user-facing change?

Only when the opt-in is enabled. With SPARK_LOCAL_CONNECT_POOL=1 (or
spark.local.connect.pool=true), a local remote getOrCreate() claims a pre-warmed, never-used
local Connect server and replaces it in the background, instead of booting an in-process server.
With the opt-in unset (the default), behavior is unchanged. If both this and
spark.local.connect.reuse are set, the pool takes precedence.

How was this patch tested?

New python/pyspark/sql/tests/connect/test_connect_local_server_pool.py:

  • Unit tests (no real servers): claim atomicity, fingerprint matching, and consumption of launch
    bookkeeping; janitor rules (dead launches, unreachable members, claims orphaned by SIGKILLed
    clients, retired-server escalation, conf-seed cleanup) with live process groups standing in for
    servers; release writes the retired marker and stops the server; purge kills all members and
    empties the directory; pool lock roundtrip; pool-size parsing.
  • End-to-end (real servers): sequential runs get distinct fresh servers and each used server is
    torn down afterwards; two concurrent cold clients get distinct servers.
  • The 4-parallel-client acquisition race (a cold waiter misreading transient marker states as
    "all launches died") was reproduced with instrumented clients, fixed, and revalidated with 8
    rounds x 4 concurrent clients, all clean. A teardown hang (release mid-warmup SIGTERMs the
    group; the self-connect warmup then wedged against the dying JVM, leaving an unkillable daemon
    and zombie JVM) was reproduced via the daemon phase log, fixed (stop-flag checks between warmup
    steps, bounded connect-warmup thread, exit-when-JVM-dead in the reaper), and re-verified.

Also verified manually on macOS: two simultaneous cold clients share one spawn complement under
the pool lock; a global temp view and a cached table created in one run are not visible to the
next (the state that leaks by design under the reuse mode); a SIGKILLed client's orphaned server
is reaped by the next run's janitor while that run still connects in ~0.4s; purge leaves zero
pool processes; idle RSS per warm member measured at 534-557 MB.

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

Generated-by: Claude Code (Fable 5)

ericm-db added 11 commits June 30, 2026 17:46
…for faster local startup

Adds an opt-in (SPARK_LOCAL_CONNECT_REUSE / spark.local.connect.reuse) so that
`SparkSession.builder.remote("local[*]").getOrCreate()` reconnects to a persistent local
Spark Connect server -- starting a detached one on the first run and reconnecting to it on
later runs -- instead of booting a fresh in-process server in every process. The first run
pays the cold start once; later runs reconnect in a fraction of a second.

Default behavior is unchanged when the opt-in is off. No protocol or Scala changes.
Trim verbose comments/docstrings, drop redundant noqa and the private-method example from the
user docs, and consolidate exception handling. No functional change.
…d seed server confs

Fixes two issues raised in review of the persistent local Connect server path:

1. Concurrent first-time startup could fail spuriously. Opted-in processes starting at the same
   time (or after a version upgrade) would each spawn a server on the fixed port; the loser's JVM
   failed to bind and raised LOCAL_CONNECT_SERVER_START_FAILED even though a usable server was now
   up. Serialize start-up with a discovery-file lock, reconnect to the winner if our own daemon
   exits, and fall back to an ephemeral port when the configured one is already held.

2. The daemon dropped most builder options on first startup. Forward the caller's start-up confs
   (warehouse dir, jars/packages, catalog, app name, etc.) to the server via a JSON conf file so
   first-run behavior matches the in-process path. A later run reconnecting to an already-warm
   JVM still cannot change its static confs, as documented.
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
…ocal Spark Connect servers

Adds spark.local.connect.pool / SPARK_LOCAL_CONNECT_POOL: each run claims a
fresh, never-used local Connect server from a pre-warmed pool (atomic rename
of its discovery file), replacements spawn asynchronously, and the claimed
server is torn down asynchronously when the session stops. No JVM ever serves
two runs. Builds on the SPARK-57787 daemon and discovery plumbing.

Co-authored-by: Isaac
…e pool members

Race fix: a cold-pool waiter could conclude every in-flight launch had died
and fail, because 'no live pending launches' is a transient observation --
a claim consumes its launch marker an instant before the winner's refill
publishes a replacement, and a fresh marker briefly carries a -1 placeholder
pid. Markers now record their spawner pid (a fresh placeholder is live while
its spawner is), the dry check is debounced across polls, and a persistently
dry pool is respawned under the lock; only the deadline fails an acquire.
Reproduced with 4 concurrent clients, fixed, and revalidated (8 rounds clean).

Warmup: pool members now run fixed synthetic queries (classic pass plus a
self-connect pass) after publishing their discovery file, so JVM-global JIT
and codegen caches are hot before the member's single real run. First-query
latency drops from ~1.4s to ~0.04s; a 16-query run on a warmed fresh server
(1.14s) matches a reused server seeing those shapes first (1.21s). Disable
with SPARK_LOCAL_CONNECT_POOL_WARMUP=0.

Daemon hardening found while testing the above: the self-connect warmup runs
in a bounded daemon thread and checks the stop flag between steps (a release
mid-warmup SIGTERMs the group; a Connect handshake against the dying JVM
otherwise wedges the main thread forever, leaving an unkillable daemon and a
zombie JVM); the reaper loop exits when the child JVM process is gone instead
of failing open; shutdown survives a dead gateway; and an env-gated phase log
(SPARK_LOCAL_CONNECT_SERVER_DEBUG_LOG) makes detached daemons debuggable.

Co-authored-by: Isaac
… cannot outlive the process group

test_terminate_reaps_daemon_and_jvm fails in CI because the job container's
PID 1 does not reap orphans: on group SIGTERM the daemon exits before its
child JVM finishes shutting down, the JVM is reparented to PID 1, and its
exit leaves a permanent zombie. A zombie still counts as a process-group
member, so killpg(pgid, 0) never raises ProcessLookupError -- and no amount
of SIGKILL (the previous hardening attempt) can remove it. The daemon now
stops Spark on a bounded thread, closes the JVM's stdin pipe (the gateway
exits cleanly on stdin EOF), and waits for / kills and reaps the JVM before
exiting, so the group always drains to empty.

Also replace the star-import __main__ block in
test_connect_local_server_pool.py with the pyspark.testing.main convention
used by the sibling suites, fixing the ruff F403/RUF100 lint failure.

Co-authored-by: Isaac
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.

1 participant