Skip to content

[SPARK-58119][CORE][PYTHON] Introduce PythonWorkerHandle abstraction for the Python worker path#57247

Open
fapaul wants to merge 4 commits into
apache:masterfrom
fapaul:processhandle-oss-demo
Open

[SPARK-58119][CORE][PYTHON] Introduce PythonWorkerHandle abstraction for the Python worker path#57247
fapaul wants to merge 4 commits into
apache:masterfrom
fapaul:processhandle-oss-demo

Conversation

@fapaul

@fapaul fapaul commented Jul 14, 2026

Copy link
Copy Markdown

What changes were proposed in this pull request?

Introduce a Spark-owned PythonWorkerHandle trait to replace direct use of java.lang.ProcessHandle in the Python worker path. The runner then depends only on the small set of operations it actually needs — isAlive(), destroy(), terminationDiagnostics(faultHandlerEnabled).

  • New PythonWorkerHandle trait + LocalPythonWorkerHandle (wraps java.lang.ProcessHandle) and a PythonWorkerHandle.of(pid, faultHandlerLog) factory (core/.../api/python/PythonWorkerHandle.scala).
  • SparkEnv.createPythonWorker, PythonWorkerFactory, PythonRunner, PythonUDFRunner, PythonArrowOutput, PythonPlannerRunner rewired from Option[ProcessHandle] to Option[PythonWorkerHandle].
  • Post-mortem diagnostics flow through handle.terminationDiagnostics(faultHandlerEnabled): the runner supplies the (SQLConf-resolved) faulthandler flag, and the handle locates its own worker's faulthandler log via an injected closure. The reader no longer needs to know about faulthandler log files.
  • The OS pid is deliberately not part of the handle contract. Its one consumer — the daemon kill-by-pid protocol — is served from a factory-internal daemonWorkers map (a DaemonWorker(handle, pid) record), keeping the pid a local-process detail.
  • Adds PythonWorkerHandleSuite.

Behavior-preserving: LocalPythonWorkerHandle delegates 1:1 to the JDK handle and the existing faulthandler read.

Why are the changes needed?

java.lang.ProcessHandle is a sealed JDK type hardcoded across the Python worker path, and the faulthandler-log reading is duplicated inline across the runners. Introducing a small Spark-owned interface decouples the runner from the concrete JDK type and centralizes "what the runner needs from a worker handle" — including the diagnostics read — into a single, testable place.

Does this PR introduce any user-facing change?

No. Internal refactor; no config, API, or behavior change.

How was this patch tested?

  • New PythonWorkerHandleSuite (5 tests): the injected faulthandler-log reader is invoked only when enabled and its result returned verbatim; the default reader yields None; of binds the pid into the reader; isAlive/destroy delegate to the underlying process (spawn a subprocess, assert alive → destroy → reaped → not alive); and of returns None for a pid with no live process.
  • core (main + test) and sql/core compile cleanly (Scala 2.13).

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

Generated-by: Claude (Anthropic)

Fabian Paul added 2 commits July 14, 2026 07:28
…for the Python worker path

Replace direct use of java.lang.ProcessHandle in the Python worker path with a
Spark-owned PythonWorkerHandle trait, so the runner depends only on the small set
of operations it needs (isAlive, destroy, terminationDiagnostics) rather than on
the sealed java.lang.ProcessHandle type, and the faulthandler-diagnostics logic
lives behind one testable method instead of being scattered across the runners.

- New PythonWorkerHandle trait + LocalPythonWorkerHandle (wraps java.lang.ProcessHandle)
  and PythonWorkerHandle.of factory.
- SparkEnv.createPythonWorker, PythonWorkerFactory, PythonRunner, PythonUDFRunner,
  PythonArrowOutput, PythonPlannerRunner rewired to the trait.
- Diagnostics flow through the handle; the runner supplies the (SQLConf-resolved)
  faulthandler flag, the handle locates its worker's log. The OS pid stays a
  factory-internal detail (daemon kill-by-pid), not part of the handle contract.
- Adds PythonWorkerHandleSuite.

Draft / design proposal for discussion on a personal fork; see SPARK-58119.
… state and simplify diagnostics closure

- Fold the parallel daemonWorkers + daemonWorkerPids maps into a single
  daemonWorkers map valued by a DaemonWorker(handle, pid) record, so the
  worker handle and its kill-by-pid are one source of truth.
- Simplify the injected faulthandler closure to () => Option[String]; the
  enabled gate now lives in terminationDiagnostics(faultHandlerEnabled),
  which the runner supplies from its per-session SQLConf value.
- Add subprocess-backed PythonWorkerHandleSuite tests covering isAlive /
  destroy delegation and of() returning None for a dead pid.

@Yicong-Huang Yicong-Huang 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 had a prior pass on fapaul#1 already. This PR LGTM with a minor comment in test suite.

val pid = proc.pid()
proc.destroyForcibly()
assert(proc.waitFor(10, TimeUnit.SECONDS))
assert(PythonWorkerHandle.of(pid).isEmpty)

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.

Minor flake risk: the OS may reuse pid after the process is reaped, making of(pid) return a live handle. Idiomatic in Spark suites, but worth a note; alternatively assert on a handle taken before destroyForcibly.

@Yicong-Huang

Copy link
Copy Markdown
Contributor

cc @HyukjinKwon @cloud-fan for a pass

@HyukjinKwon

Copy link
Copy Markdown
Member

cc @gaogaotiantian too

@gaogaotiantian

Copy link
Copy Markdown
Contributor

I'm a bit confused about the purpose and design. I have a few questions that are probably in different area.

  1. Why is pid not part of the contract? What benefit do we gain from not having it? I saw an extra private small class required for daemon workers because we don't have the pid in the new class. Do we expect worker handles that are not 1:1 to os process?
  2. The design is a bit twisted in the following ways:
    a. pid is not part of contract, but the handle is created by of(pid) - it's still part of the contract.
    b. PythonWorkerHandle is an abstract class with the awareness of faulthandler, which is an implementation detail for current Python workers. What is the clear interface of it? If it knows about faulthandler, why should the logging method and switch passed to it externally? Why can't we just set up everything when we create the handle? Or should we design the interface in a more general way that is irrelevant to faulthandler?
    c. Why is DaemonWorker even needed? Just because pid is not part of the contract?
  3. What's the next step? Because currently there's not duplicated code removed. We basically just changed everything and add some extra code which makes things a bit more complicated.

…lake-free

Capture the PythonWorkerHandle while the process is alive and assert it
observes the death after the kill, instead of re-querying of(reapedPid),
which could return a live handle if the OS recycled the reaped pid.
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.

4 participants