spark-test commented on code in PR #59713:
URL: https://github.com/apache/spark/pull/59713#discussion_r4352743294
##########
sql/core/src/test/scala/org/apache/spark/sql/execution/python/PythonUDTFSuite.scala:
##########
@@ -118,6 +130,85 @@ class PythonUDTFSuite extends SharedSparkSession {
Some(StructType.fromDDL("v ARRAY<VARIANT>"))
)
+ test("SPARK-54055: local session eviction does not stop an active Python
UDTF planner") {
+ assume(shouldTestPythonUDFs && !Utils.isWindows)
+ withTempDir { tempDir =>
+ val uuid = UUID.randomUUID().toString
+ val started = new File(tempDir, "started")
+ val resume = new File(tempDir, "resume")
+ val script = s"""
+ |import os
+ |import time
+ |from pyspark.sql.types import StructType
+ |from pyspark.sql.udtf import AnalyzeResult
+ |
+ |class BlockingAnalyze:
+ | @staticmethod
+ | def analyze():
+ | with open("${started.getCanonicalPath}", "w") as started:
+ | started.write(str(os.getpid()))
+ | deadline = time.monotonic() + 30
+ | while not os.path.exists("${resume.getCanonicalPath}"):
+ | if time.monotonic() > deadline:
+ | raise TimeoutError("Planner was not resumed")
+ | time.sleep(0.01)
+ | return AnalyzeResult(StructType().add("value", "integer"))
+ |
+ | def eval(self):
+ | yield 1,
+ |""".stripMargin
+ val udtf = createUserDefinedPythonTableFunction("BlockingAnalyze",
script, None)
+ val loader = new MutableURLClassLoader(Array.empty,
getClass.getClassLoader)
+ val session = new IsolatedSessionState(
+ uuid, loader, loader, new HashMap[String, Long], new HashMap[String,
Long],
+ new HashMap[String, Long], None)
+ IsolatedSessionState.sessions.put(uuid, session)
+ val pool =
ThreadUtils.newDaemonSingleThreadExecutor("python-planner-eviction")
+ implicit val executionContext: ExecutionContext =
ExecutionContext.fromExecutor(pool)
+ val planning = Future {
+ spark.withActive {
+ JobArtifactSet.withActiveJobArtifactState(JobArtifactState(uuid,
None)) {
+ val runner = new UserDefinedPythonTableFunctionAnalyzeRunner(
+ udtf.name, udtf.func, Seq.empty, Seq.empty,
spark.sessionState.sqlParser)
+ runner.runInPython(useDaemon = true)
+ }
+ }
+ }
+ try {
+ eventually(timeout(10.seconds), interval(10.milliseconds)) {
+ assert(started.isFile)
+ assert(Files.readString(started.toPath).nonEmpty)
+ }
+ val child =
ProcessHandle.of(Files.readString(started.toPath).toLong).get()
+ val daemon = child.parent().get()
+ val directory = new File(SparkFiles.getRootDirectory(), uuid)
+ assert(directory.isDirectory)
+
+ // No executor tasks hold this session, but the driver is still
planning its query.
+ session.markEvicted()
+ assert(!directory.exists())
+ assert(!IsolatedSessionState.sessions.containsKey(uuid))
+ assert(child.isAlive)
+ assert(daemon.isAlive)
+ Files.writeString(resume.toPath, "resume")
+ assert(ThreadUtils.awaitResult(planning, 10.seconds).schema ==
+ StructType.fromDDL("value int"))
+ } finally {
+ Files.writeString(resume.toPath, "resume")
+ pool.shutdown()
+ try {
+ assert(pool.awaitTermination(10, TimeUnit.SECONDS))
+ } finally {
+ pool.shutdownNow()
+ if (IsolatedSessionState.sessions.containsKey(uuid)) {
+ session.markEvicted()
+ }
+ SparkEnv.get.removePythonWorkerFactories(uuid)
Review Comment:
**Nit (P3):** `removePythonWorkerFactories` filters out `driverOwned` keys,
and the factory this test creates comes from `PythonPlannerRunner` with
`driverOwned = true`, so this cleanup call does nothing for it. The planner
daemon (whose working directory the test just deleted) and its pooled worker
stay alive in the shared SparkEnv until the suite's SparkContext stops, even
though this line reads as if it retires them. The test needs a way to stop and
remove the driver-owned factory for `uuid`, and could then assert that the
daemon observed above has exited.
**Verification:**
- **Regression:** After the test's cleanup, the planner daemon process
observed during the test has exited and SparkEnv holds no factory for the
test's session UUID.
- **Compatibility:** Executor session eviction still leaves an active
driver-owned planner running, as the test already asserts.
##########
core/src/main/scala/org/apache/spark/SparkEnv.scala:
##########
@@ -370,8 +389,19 @@ class SparkEnv (
daemonModule: String,
envVars: Map[String, String],
worker: PythonWorker): Unit = {
+ destroyPythonWorker(
+ pythonExec, workerModule, daemonModule, envVars, worker, driverOwned =
false)
+ }
+
+ private[spark] def destroyPythonWorker(
+ pythonExec: String,
+ workerModule: String,
+ daemonModule: String,
+ envVars: Map[String, String],
+ worker: PythonWorker,
+ driverOwned: Boolean): Unit = {
synchronized {
- val key = PythonWorkersKey(pythonExec, workerModule, daemonModule,
envVars)
+ val key = PythonWorkersKey(pythonExec, workerModule, daemonModule,
envVars, driverOwned)
Review Comment:
**Non-blocking (P2):** When the factory for `key` has already been removed
by `removePythonWorkerFactories`, this `pythonWorkers.get(key).foreach(...)` is
a silent no-op, and the same holds for the `releasePythonWorker` overload
below. A reachable sequence is: `PythonRunner.MonitorThread` wins
`releasedOrClosed.compareAndSet` for an interrupted task (so the task
completion listener skips `worker.stop()`), the task then completes, and
`Executor`'s finally block calls `isolatedSession.release()` on an evicted
session. `cleanup()` removes and stops the factory before the monitor's
`destroyPythonWorker` gets the SparkEnv lock. `factory.stop()` kills the daemon
(or the simple workers), but it never closes this `PythonWorker`'s JVM channel
and selector, so they leak and nothing is logged. At the merge base the factory
was never removed at runtime, so this call always reached `stopWorker`, which
ends with `worker.stop()`. The PR description says workers that finish after
their factory wa
s removed are closed, but nothing does that. Falling back to `worker.stop()`
when the lookup misses, for both destroy and release, would make that hold.
**Verification:**
- **Regression:** After the session's factories are removed, destroying and
releasing one of its workers each close that worker's channel. Neither is
silently ignored.
- **Behavior:** With the factory still registered, release still returns the
worker to that factory's pool and destroy still goes through the factory's
stopWorker.
--
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]