HyukjinKwon commented on code in PR #59332:
URL: https://github.com/apache/spark/pull/59332#discussion_r4255354335


##########
core/src/main/scala/org/apache/spark/TaskContextImpl.scala:
##########
@@ -169,37 +196,110 @@ private[spark] class TaskContextImpl(
   }
 
   private[spark] override def markTaskFailed(error: Throwable): Unit = {
+    markTaskFailed(error, Claim)
+  }
+
+  /**
+   * @param mode `Nested` if this thread is already invoking listeners, i.e. a 
listener failed. The
+   *             failure listeners then run as part of that invocation, which 
this thread keeps.
+   */
+  private def markTaskFailed(error: Throwable, mode: InvocationMode): Unit = {
     synchronized {
       if (failureCauseOpt.isDefined) return
       failureCauseOpt = Some(error)
     }
-    invokeTaskFailureListeners(error)
+    invokeTaskFailureListeners(error, mode)
   }
 
   private[spark] override def markTaskCompleted(error: Option[Throwable]): 
Unit = {
-    synchronized {
+    // The completion listeners always run on this thread, and this method 
returns only after they
+    // have run. If any other thread could run them, it could be one the 
listeners are meant to
+    // stop and wait for, e.g. a pipelined Python UDF writer that adds 
listeners lazily while it
+    // pulls its input, which would then wait for itself. And if this method 
returned before they
+    // ran, the task could release its memory while that writer still uses it. 
So the listener
+    // invocation is claimed together with setting `completed`; if another 
thread holds it (e.g. a
+    // kill thread running failure listeners), that thread hands it over to 
this one when done.
+    //
+    // That wait has no time bound: if the other thread's listeners block, so 
does this task. It
+    // logs a warning with that thread's stack trace periodically. If such a 
task is killed,
+    // `spark.task.reaper.enabled` with a positive 
`spark.task.reaper.killTimeout` bounds how long

Review Comment:
   **Nit (P3):** This bound does not hold for the example holder named above, a 
kill thread running failure listeners. With `spark.task.reaper.enabled`, 
`Executor.killTask` runs the kill on a `TaskReaper`, and `TaskReaper.run` calls 
`taskRunner.kill()` synchronously before it enters its `killTimeout` loop. 
`kill()` goes through `Task.kill` to `markInterrupted`, and if an interrupt 
listener throws, `markTaskFailed` runs the failure listeners on that same 
reaper thread. If one of them blocks, `kill()` never returns, 
`timeoutExceeded()` is never checked, and this thread stays in 
`awaitListenerInvocationHandover` with no JVM kill. The bound does hold when 
the holder is some other thread, and for the writer-exit wait in 
`startPipelinedWriter`, where the task thread holds the invocation. Could the 
comment say that the reaper bounds this wait only when the blocked listeners do 
not run inside the reaper's own kill call, and that otherwise only the periodic 
warning shows it?
   
   **Verification:**
   - **Inspection:** The comment's recovery statement agrees with the 
TaskReaper sequence, where kill() runs synchronously before the killTimeout 
loop, and with markInterrupted running failure listeners on the kill thread 
when an interrupt listener throws.



##########
core/src/main/scala/org/apache/spark/api/python/PythonRunner.scala:
##########
@@ -183,6 +183,97 @@ private[spark] object BasePythonRunner extends Logging {
     ThreadUtils.newDaemonCachedThreadPool("python-udf-pipelined-writer")
   }
 
+  /** How often to log while task completion waits for a pipelined writer to 
exit. */
+  private val pipelinedWriterExitWarnIntervalMs = 10000L
+
+  /**
+   * Runs `writer` on [[pipelinedWriterThreadPool]] and registers a task 
pre-completion listener
+   * that interrupts it and blocks until it has exited.
+   *
+   * Task completion listeners free memory that the writer may still read or 
write, e.g. the
+   * pages of the HybridRowQueue that buffers the input rows, or the off-heap 
column vectors of a
+   * vectorized reader. If the writer were still running at that point, it 
would be a
+   * use-after-free that can crash the executor (SPARK-33277). The listener is 
added with
+   * `addTaskPreCompletionListener` so that it runs before all of them, 
including the ones that
+   * the writer thread adds lazily while it pulls the upstream iterator, which 
would run first if
+   * this were an ordinary completion listener. `Future.get()` cannot be used 
for this wait:
+   * once `cancel(true)` succeeds it throws `CancellationException` 
immediately, without waiting
+   * for the interrupted runnable to return.
+   */
+  private[python] def startPipelinedWriter(writer: Runnable, context: 
TaskContext): Unit = {
+    val writerTask = new PipelinedWriterTask(writer, taskIdentifier(context))
+    val writerFuture = pipelinedWriterThreadPool.submit(writerTask)
+    context.addTaskPreCompletionListener(new TaskCompletionListener {
+      override def onTaskCompletion(context: TaskContext): Unit =
+        writerTask.stopAndAwaitExit(writerFuture)
+    })
+  }
+
+  /**
+   * Wraps a pipelined writer so that task completion can wait until the 
writer has either
+   * exited or is guaranteed never to run.
+   */
+  private[python] class PipelinedWriterTask(writer: Runnable, taskName: String)
+    extends Runnable {
+    // Set by whichever of run() and stopAndAwaitExit() gets here first. If 
run() wins, the
+    // writer runs and stopAndAwaitExit() waits for it; otherwise run() skips 
the writer.
+    private val claimed = new AtomicBoolean(false)
+    private val exited = new CountDownLatch(1)
+
+    override def run(): Unit = {
+      if (claimed.compareAndSet(false, true)) {
+        try {
+          writer.run()
+        } catch {
+          case t: Throwable =>
+            // The thread pool's FutureTask would keep this to itself, and the 
result of a
+            // cancelled future cannot be retrieved, so log it here.
+            logError(log"Pipelined Python writer of ${MDC(TASK_NAME, 
taskName)} failed", t)
+            throw t
+        } finally {
+          exited.countDown()
+        }
+      }
+    }
+
+    def stopAndAwaitExit(future: Future[_]): Unit = {

Review Comment:
   Thanks, I checked 7ab7ae2e6af. `stopAndAwaitExit` no longer waits when it 
runs on the thread recorded in `PipelinedWriterRunning`, and that branch now 
logs at ERROR and clears the self-interrupt; "stopping on the writer thread 
itself does not wait for the writer" exercises it through a real `FutureTask`. 
The handoff that led there is closed at the source too: `markTaskCompleted` now 
waits for the holder and takes the invocation over directly, and a failing 
listener keeps the invocation while the failure listeners run, so the guard is 
only a safety net now.
   
   <!-- SPARK_DEV_REVIEW_REPLY 
{"feedback_id":"inline:4246906867","thread_id":"inline:4246906867","verdict_sha256":"698cadbb5ec60306a643aac1495c6da95c02eb7786ffec3bc65dd5edfc06c375"}
 -->



##########
core/src/main/scala/org/apache/spark/api/python/PythonRunner.scala:
##########
@@ -1336,9 +1409,9 @@ private[spark] abstract class BasePythonRunner[IN, OUT](
           // automatically closed by the JVM, which will cause Python to 
receive
           // EOF and the reader thread to get IOException.
           Thread.currentThread().interrupt()
-        case NonFatal(t) =>
+        case t: Throwable =>

Review Comment:
   Thanks, verified. The new test drives a real `PipelinedWriterRunnable` 
against a socket pair and checks both `writer.exception` and the EOF on the 
Python side. Narrowing the branch back to `NonFatal` would leave 
`writer.exception` empty, and dropping the rethrow would fail the 
`intercept[LinkageError]`, so both regressions are caught.
   
   <!-- SPARK_DEV_REVIEW_REPLY 
{"feedback_id":"inline:4246906877","thread_id":"inline:4246906877","verdict_sha256":"698cadbb5ec60306a643aac1495c6da95c02eb7786ffec3bc65dd5edfc06c375"}
 -->



##########
core/src/test/scala/org/apache/spark/scheduler/TaskContextSuite.scala:
##########
@@ -757,6 +757,113 @@ class TaskContextSuite extends SparkFunSuite with 
BeforeAndAfter with LocalSpark
     assert(invocationOrder === Seq("C", "B", "A", "D"))
   }
 
+  test("SPARK-60113: pre-completion listeners run before all completion 
listeners") {
+    val context = TaskContext.empty()
+    val invocationOrder = ArrayBuffer.empty[String]
+
+    def makeLoggingListener(id: String): TaskCompletionListener = new 
TaskCompletionListener {
+      override def onTaskCompletion(context: TaskContext): Unit = {
+        invocationOrder += id
+      }
+    }
+    context.addTaskCompletionListener(makeLoggingListener("A"))
+    context.addTaskPreCompletionListener(makeLoggingListener("P1"))
+    context.addTaskPreCompletionListener(makeLoggingListener("P2"))
+    // Added after the pre-completion listeners, e.g. lazily by the thread 
they stop. An ordinary

Review Comment:
   Thanks, the comment now matches the asserted order.
   
   <!-- SPARK_DEV_REVIEW_REPLY 
{"feedback_id":"inline:4246906884","thread_id":"inline:4246906884","verdict_sha256":"698cadbb5ec60306a643aac1495c6da95c02eb7786ffec3bc65dd5edfc06c375"}
 -->



-- 
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]

Reply via email to