Uh oh!
There was an error while loading. Please reload this page.
[SPARK-58021][CONNECT] Recover abandoned local pool launches - #58367
[SPARK-58021][CONNECT] Recover abandoned local pool launches#58367ericm-db wants to merge 3 commits into
Conversation
### What changes were proposed in this pull request? This is layer 5 of the ten-PR local Connect pool stack: #57684 -> #57685 -> #57907 -> #57686 -> #58247 -> #58367 -> #57687 -> #58248 -> #57102 -> #57688 This layer adds recovery after a pool member has been published or claimed: - a janitor for ready, claimed, and retiring members; - retirement of dead, unreachable, incompatible, idle, and malformed ready members; - retirement of claims whose client or server process generation has disappeared; - persisted claimant process identities so PID reuse cannot strand an orphaned claim; - recovery of independently valid server handles from malformed records; and - bounded, PID-reuse-safe completion of retirement. It also addresses the two follow-ups from the review of #57686: successful SIGTERM delivery is persisted so young retirement passes do not repeat the process inspection, and the daemon-pid fallback now has coverage for removing state after that process exits. Pending-launch, attendant, conf-file, and unreferenced member-directory recovery remain in #58367. ### Why are the changes needed? Pool members outlive individual Python call frames. A client can be killed without releasing its claim, a ready server can die or become unreachable, and persisted process IDs can be reused. Without a conservative janitor these cases can strand JVMs or make unusable members count toward the pool indefinitely. Separating post-publication recovery from launch recovery gives each process-ownership model its own review unit: this layer authorizes server cleanup from persisted process generations, while the next layer handles attendant commands and launch process groups. ### Does this PR introduce _any_ user-facing change? No. The pool is not wired into SparkSession in this layer. ### How was this patch tested? The focused pool suite's 50 tests passed: ```bash PYTHONPATH=python:python/lib/pyspark.zip:python/lib/py4j-0.10.9.9-src.zip \ SPARK_TESTING=1 \ .venv/bin/python -m unittest -v \ pyspark.sql.tests.connect.test_connect_local_server_pool ``` The changed files also passed Python AST parsing, Ruff checking and formatting, targeted mypy, `git diff --check`, and changed-file ASCII and 100-column checks. ### Was this patch authored or co-authored using generative AI tooling? Generated-by: Claude Code (Fable 5) and OpenAI Codex (GPT-5) Closes#58247 from ericm-db/local-connect-pool-recovery. Authored-by: Eric Marnadi <eric.marnadi@databricks.com> Signed-off-by: Daniel Tenedorio <daniel.tenedorio@databricks.com>
### What changes were proposed in this pull request? This is layer 5 of the ten-PR local Connect pool stack: #57684 -> #57685 -> #57907 -> #57686 -> #58247 -> #58367 -> #57687 -> #58248 -> #57102 -> #57688 This layer adds recovery after a pool member has been published or claimed: - a janitor for ready, claimed, and retiring members; - retirement of dead, unreachable, incompatible, idle, and malformed ready members; - retirement of claims whose client or server process generation has disappeared; - persisted claimant process identities so PID reuse cannot strand an orphaned claim; - recovery of independently valid server handles from malformed records; and - bounded, PID-reuse-safe completion of retirement. It also addresses the two follow-ups from the review of #57686: successful SIGTERM delivery is persisted so young retirement passes do not repeat the process inspection, and the daemon-pid fallback now has coverage for removing state after that process exits. Pending-launch, attendant, conf-file, and unreferenced member-directory recovery remain in #58367. ### Why are the changes needed? Pool members outlive individual Python call frames. A client can be killed without releasing its claim, a ready server can die or become unreachable, and persisted process IDs can be reused. Without a conservative janitor these cases can strand JVMs or make unusable members count toward the pool indefinitely. Separating post-publication recovery from launch recovery gives each process-ownership model its own review unit: this layer authorizes server cleanup from persisted process generations, while the next layer handles attendant commands and launch process groups. ### Does this PR introduce _any_ user-facing change? No. The pool is not wired into SparkSession in this layer. ### How was this patch tested? The focused pool suite's 50 tests passed: ```bash PYTHONPATH=python:python/lib/pyspark.zip:python/lib/py4j-0.10.9.9-src.zip \ SPARK_TESTING=1 \ .venv/bin/python -m unittest -v \ pyspark.sql.tests.connect.test_connect_local_server_pool ``` The changed files also passed Python AST parsing, Ruff checking and formatting, targeted mypy, `git diff --check`, and changed-file ASCII and 100-column checks. ### Was this patch authored or co-authored using generative AI tooling? Generated-by: Claude Code (Fable 5) and OpenAI Codex (GPT-5) Closes#58247 from ericm-db/local-connect-pool-recovery. Authored-by: Eric Marnadi <eric.marnadi@databricks.com> Signed-off-by: Daniel Tenedorio <daniel.tenedorio@databricks.com> (cherry picked from commit ed95123) Signed-off-by: Daniel Tenedorio <daniel.tenedorio@databricks.com>
a5dc822 to
05637e3Compare
dtenedor
left a comment
There was a problem hiding this comment.
Reviewed at head acfbb688 (diffed against its base ed95123c03b), including the sbin/spark-daemon.sh launch chain the recovery relies on and the new tests.
Overall this is careful work — the PID-reuse guards, generation-ID checks, and re-entrancy handling are thorough and well-commented. I found one correctness gap worth addressing before this lands, plus a couple of minor notes. Security posture looks good.
Correctness: the pending-recovery group kill can SIGKILL a published/claimed (in-use) server
reap() runs _reap_pending before _reap_server/_reap_claimed, and for a dead attendant _reap_pending signals the attendant's entire process group with SIGKILL unconditionally — without checking whether the launch already produced a server/claimed record:
ifnotattendant_alive:
ifpendingisnotNoneandnotself._signal_attendant_group(
attendant_pid, signal.SIGKILL, leader_may_be_dead=True
):
...
self.abort_launch(uid)The daemonized server JVM stays in the attendant's process group: sbin/spark-daemon.sh backgrounds it with nohup -- "$@" ... & and no setsid, and nothing in start-connect-server.sh -> spark-daemon.sh -> spark-class calls setsid either. Since the attendant is the group leader (start_new_session=True), the server's pgid equals the attendant's pid, so killpg(attendant_pid, SIGKILL) hits the server itself.
Reachable sequence (all pool ops are serialized by the pool-dir flock):
- Attendant dies mid-publish — after
os.replace(... server-*.json)but beforeos.remove(pending-*.json)— leaving{server, pending}. Death releases the lock between those two syscalls, so the intermediate state is observable. This is in scope per the PR description ("die at any point ... between ... and publishing the ready server"). - A client wins the next lock and claims it.
claim()inspects onlyserver-*records +is_usable(); it does not check for apendingmarker. State becomes{claimed, pending}and the client is now serving on that JVM. - A janitor runs
reap(uid)->_reap_pendingsees the dead attendant ->killpg-> SIGKILLs the in-use server, and_reap_claimedthen retires the now-dead claim. The client's Connect session dies.
The unclaimed published case is fine (discarding matches intent), but test_reap_pending_with_published_server_retires_server uses a standalone sleep process that isn't in any attendant group, so it never exercises the killpg path.
I confirmed the OS mechanism with a standalone repro (attendant as session leader; a daemonized child left in its group; attendant killed+reaped; then the PR's exact _signal_attendant_group(..., leader_may_be_dead=True)):
server pgid=57356 (== attendant_pid? True)
after kill+reap: _pid_alive(attendant)=False server_alive=True
getpgid(attendant): ProcessLookupError (leader gone, group may persist)
_signal_attendant_group -> True; server_alive_now=False
RESULT: the surviving in-group server was KILLED by the group signal.
Severity is low-likelihood (needs an unclean death in the 2-syscall publish window, then a claim racing the janitor) but high-impact (kills a live session). Two possible directions, either of which resolves it:
- In
_reap_pending, when aserverorclaimedrecord already exists for the uid, skip the process-group kill and let the generation-verified_reap_server/_reap_claimed/abort_launchpaths own the server's lifecycle. By publish time the launch scaffolding (bash/spark-daemon.sh) has already exited, so the group's only remaining member is the server you don't want to blanket-kill. (abort_launchwould then also want to avoid synthesizing a daemon-pid retirement for a liveclaimedserver — only withdrawpending/confin that case.) - Or have
claim()skip servers that still have apending-*marker, so a server isn't claimable until pending recovery has resolved.
Minor
- Malformed record collapses the grace period. In
_reap_pending, ifPendingState.from_datareturnsNonefor any reason (e.g. a missing/emptyfingerprint) whileattendant_pidandcreatedare individually valid,createdis discarded andagebecomes_LAUNCH_TIMEOUT_SECONDS + 1(immediately "expired"). You already recoverattendant_pidindependently; recoveringcreatedthe same way would avoid removing the launch-timeout grace for an otherwise-fresh launch. Bounded by the_is_pool_attendantidentity check before any kill, so only your own uid is affected. - Daemon-pid-only server can linger. If a half-started JVM is recorded only via the
spark-daemon.shpid file (noserver-*record, hence noprocess_start_id) and it survives the group kill,abort_launchretires it by pid only and_reap_retiredreturns early forever while it is alive (if process_start_id is None: return), never signalling or cleaning up. This is the intended "don't signal an unverifiable/possibly-reused pid" trade-off and the group kill normally handles it — just flagging the retention corner.
Security
No concerns. Server signals go through generation IDs (boot_id:start_tick on Linux, else ps lstart); attendant-group signals combine getpgid(pid) == pid, the re-_pid_alive check under leader_may_be_dead, the POSIX guarantee that a PGID isn't recycled while the group is non-empty, and the ps-command identity check (_is_pool_attendant) on the still-alive path. The residual TOCTOU (pid recycled into a new group leader between getpgid raising and killpg) is negligible and same-user only. ps is invoked list-form with the pid rendered as str(pid) (no shell), state files stay 0600 / the pool dir 0700, and remove_member_dir only touches hex-validated member-<uid> paths.
Tests
Good breadth (dead / timed-out / malformed / reused-pid attendants, launch-group kill, published-then-orphaned server, interrupted-cleanup re-entrancy, conf GC, member-dir GC). The one gap that maps to the finding above: there is no test where a pending marker coexists with a claimed (or real in-group published) server whose attendant is dead — placing a real in-group process behind a claimed/server record and asserting it survives reap would catch it.
ericm-db
commented
Sep 3, 2026
Thanks for the detailed review, @dtenedor. Fixed in |
dtenedor
left a comment
There was a problem hiding this comment.
I re-reviewed the current head (3ffdbe2, which incorporates my earlier feedback) against its merge base: 3 files, +542/-17. I read all three in full context, traced every new branch, and ran the cheap CI-parity checks locally (py_compile, 100-col, non-ASCII -- all clean; 66 test methods).
Verdict
High-quality, defensive work. I found no correctness or security defects that block merge. The hard part -- signalling abandoned launches without ever touching an unrelated process after PID reuse -- is handled carefully and, as far as I can tell, correctly. The notes below are one test-coverage gap worth closing and a few low-severity observations.
Correctness
The change rests on the POSIX invariant stated in _signal_attendant_group's docstring: a process group outlives its leader while any member remains, and its id cannot be recycled during that time. That holds (on Linux the leader's struct pid stays pinned while it is referenced as any task's pgrp, so the value is reused neither as a PID nor a PGID until the group empties), and the code leans on it rather than on leader liveness.
The reuse handling -- the part I scrutinized most -- is sound in each path:
- Dead recorded attendant (
leader_may_be_dead=True): a reaped leader whose group is still non-empty is allowed through tokillpg(viagetpgid->ESRCH); a reused pid is rejected bygetpgid(pid) != pid(reused as a non-leader) or by the_pid_alive(pid)re-check (reused as a new leader). - Timed-out live attendant (
leader_may_be_dead=False): identity is confirmed by_is_pool_attendant(module +--attend+ matching--uid) before signalling, so a live-but-reused pid or an unrelated process is never killed. - Server retirement still gates every signal on the process-generation id via
_signal_server/_same_server_instance.
Also verified end-to-end: reap ordering and the had_retired guard against double-processing a freshly-created retired record; abort_launch's idempotent retired-already-present recovery and its non-double-removal of pending; and claim skipping any uid with a live pending marker, which closes the {server,pending} -> {claimed,pending} race from the last round.
One design choice worth confirming (not a bug): a fully-published, still-usable server whose attendant dies in the write server-* -> remove pending-* window is retired (killed), not adopted (test_reap_pending_with_published_server_retires_server). That discards a good warm JVM in a rare race. It's the safe choice and I think it's fine -- just flagging it as intentional.
Security
Trust boundary is the OS user (per-user 0700 dir, 0600 token, forced loopback bind; SECURITY.md -> the Spark security doc). This PR stays within it:
- No signal to a wrong / out-of-boundary process (see above).
- No command injection --
ps -ww -p <int> -o command=only ever interpolatesstr(pid). - File perms unchanged; no new state escapes the
0600/0700regime. - A same-user process could spoof an
--attend --uid <uid>command line, but the only effect is that the pool might SIGKILL that process -- self-inflicted, inside the trust boundary. Not a vulnerability.
Test coverage
Strong and precise -- most new branches have a dedicated test. One gap I'd close:
The case-A PID-reuse guard is untested.test_reap_does_not_signal_reused_attendant_pid exercises only the timed-out live path (is_attendant is False). The symmetric, more security-relevant path -- a dead recorded attendant whose pid is reused by a live process in the check->signal window, guarded here:
spark/python/pyspark/sql/connect/local_server_pool.py
Lines 717 to 729 in 3ffdbe2
-- has no test. It's easy to add by patching _pid_alive to report dead-then-alive (or reusing the live-process fixture) and asserting the group is never signalled. The pid == os.getpgrp() self-protection guards and _attendant_group_alive's EPERM->alive branch are similarly untested, though lower priority.
Low-severity / nits
- Malformed-pending launch-group leak: in
_reap_pending, when the record fails validation (pending is None) but a pid is recovered and the attendant is dead, group signalling is skipped (if pending is not None and ...), so a surviving launch group would be orphaned. Only reachable via a corruptpending-*.json(single atomic writer), so rare -- a one-line comment noting the deliberate skip would help the next reader. - CLI-contract coupling:
_is_pool_attendanthard-codes a space-separated-m <module> ... --attend ... --uid <uid>argv shape (no--uid=<uid>form;-mmust immediately precede the module). Since a later layer implements the actual--attendentrypoint, consider sharing a constant / cross-reference so the two can't silently drift. claimlists the directory twice (paths_of_kind("pending")then("server")); negligible at pool sizes, noting only for completeness.
Nice work -- this layer is easy to reason about in isolation.
What changes were proposed in this pull request?
This is layer 6 of the ten-PR local Connect pool stack:
#57684 -> #57685 -> #57907 -> #57686 -> #58247 -> #58367 ->
#57687 -> #58248 -> #57102 -> #57688
This layer adds recovery before a pool member has finished publishing:
Acquisition, forceful purge, SparkSession integration, and warmup remain in later PRs.
Why are the changes needed?
An attendant or its spawning client can die at any point between writing the launch seed, starting
the JVM, and publishing the ready server. Those partial states must stop counting toward refills,
but cleanup must not signal an unrelated process after PID reuse.
This layer builds on #58247's server-retirement machinery while keeping the separate attendant and
process-group ownership contract independently reviewable.
Does this PR introduce any user-facing change?
No. The pool is not wired into SparkSession in this layer.
How was this patch tested?
All 59 focused pool tests at this stack layer passed:
The changed files also passed Python AST parsing, Ruff checking and formatting,
git diff --check, and changed-file ASCII and 100-column checks.Was this patch authored or co-authored using generative AI tooling?
Generated-by: Claude Code (Fable 5) and OpenAI Codex (GPT-5)