dongjoon-hyun commented on code in PR #59332:
URL: https://github.com/apache/spark/pull/59332#discussion_r4256073416


##########
core/src/main/scala/org/apache/spark/TaskContextImpl.scala:
##########
@@ -169,37 +196,113 @@ 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
+    // it may stay in this state, by killing the executor JVM, but only if the 
blocked listeners
+    // do not run within the reaper's own kill call: the reaper calls 
`Task.kill` before it starts
+    // checking the timeout, and that call runs the failure listeners on the 
reaper thread if an
+    // interrupt listener throws. Then only the periodic warning shows the 
hang.
+    val mode = synchronized {
       if (completed) return
       completed = true
+      val currentThread = Thread.currentThread()
+      if (listenerInvocationThread.isEmpty) {
+        listenerInvocationThread = Some(currentThread)
+        Claimed
+      } else if (listenerInvocationThread.contains(currentThread)) {
+        // Called from a listener on this thread: run the completion listeners 
as part of that
+        // invocation.
+        Nested
+      } else {
+        nextListenerInvocationThread = Some(currentThread)
+        awaitListenerInvocationHandover(currentThread)
+        Claimed
+      }
+    }
+    invokeTaskCompletionListeners(error, mode)
+  }
+
+  /**
+   * Waits until another thread invoking listeners hands the invocation over 
to `currentThread`.
+   * Interrupts do not stop the wait and are restored afterwards. Must hold 
the TaskContext lock.
+   */
+  private def awaitListenerInvocationHandover(currentThread: Thread): Unit = {
+    val startNs = System.nanoTime()
+    var nextWarningNs = startNs + 
TimeUnit.MILLISECONDS.toNanos(listenerHandoverWarnIntervalMs)
+    var interrupted = false
+    while (!listenerInvocationThread.contains(currentThread)) {
+      try {
+        wait(math.max(1L, TimeUnit.NANOSECONDS.toMillis(nextWarningNs - 
System.nanoTime())))
+      } catch {
+        case _: InterruptedException => interrupted = true
+      }
+      val nowNs = System.nanoTime()
+      if (nowNs >= nextWarningNs && 
!listenerInvocationThread.contains(currentThread)) {
+        listenerInvocationThread.foreach { holder =>
+          val holderStack = new Exception(s"Stack trace of thread 
${holder.getName}")
+          holderStack.setStackTrace(holder.getStackTrace)
+          logWarning(log"Task ${MDC(TASK_ATTEMPT_ID, taskAttemptId)} has been 
waiting for " +
+            log"${MDC(TOTAL_TIME, TimeUnit.NANOSECONDS.toMillis(nowNs - 
startNs))} ms to run " +
+            log"its completion listeners until thread ${MDC(THREAD_NAME, 
holder.getName)} " +
+            log"finishes running its task listeners.", holderStack)
+        }
+        nextWarningNs = nowNs + 
TimeUnit.MILLISECONDS.toNanos(listenerHandoverWarnIntervalMs)
+      }
+    }
+    if (interrupted) {

Review Comment:
   **[Low] An interrupt that arrives during this wait is passed on to the 
completion listeners, while `awaitExit` drops one for that reason.**
   
   This restores the flag before the completion listeners run. 
`stopAndAwaitExit` then sees it as pending on entry and restores it again, so 
the ordinary completion listeners (the `HybridRowQueue` close, `worker.stop()`, 
the upstream reader's close) run with the flag set. 
`PipelinedWriterTask.awaitExit` (PythonRunner L333-336) drops an interrupt that 
arrives during its own wait, because the flag "would make the blocking channel 
and lock operations of the completion listeners that run next fail and skip 
cleanup". So an interrupt that lands while the task thread waits here is passed 
on, and the same interrupt landing a moment later, during the writer wait, is 
dropped.
   
   This wait can be interrupted, e.g. by a second kill with `interruptThread = 
true`, or by the writer thread resolving a pending interrupt in 
`createResourceUninterruptibly`, which interrupts the task thread. Could both 
waits follow one policy, or could the comments say why they differ?



##########
core/src/main/scala/org/apache/spark/TaskContextImpl.scala:
##########
@@ -320,21 +430,44 @@ private[spark] class TaskContextImpl(
           //    [[markTaskFailed]] here will have no effect and we simply 
resume running the
           //    remaining completion handlers.
           try {
-            listenerInvocationThread = None
-            markTaskFailed(e)
+            markTaskFailed(e, Nested)
           } catch {
             case t: Throwable => e.addSuppressed(t)
-          } finally {
-            synchronized {
-              if (listenerInvocationThread.isEmpty) {
-                listenerInvocationThread = Some(Thread.currentThread())
-              }
-            }
           }
           listenerExceptions += e
           logError(log"Error in ${MDC(LISTENER, name)}", e)
       }
     }
+    if (mode != Nested) {
+      // We have executed all listeners of `listenerStacks` that have been 
added so far. Run the
+      // listeners left to this thread (see above), then deregister this 
thread as the callback
+      // invocation thread, or hand the invocation over to a thread waiting 
for it.
+      var released = false
+      while (!released) {
+        val pendingListeners: Option[() => Unit] = synchronized {
+          val pendingFailure = failureCauseOpt.filter(_ => 
!onFailureCallbacks.empty())

Review Comment:
   **[Medium] Failure listeners left to this thread run after every completion 
listener.**
   
   Suppose another thread fails the task while this thread runs the completion 
listeners, e.g. a kill whose interrupt listener throws. Its `markTaskFailed` 
finds the invocation taken and returns. This thread then first runs the 
remaining completion listeners, which close streams, release state stores and 
free the `HybridRowQueue` pages, and only after that the failure listeners, 
here. `runTaskWithListeners` always runs the failure listeners before the 
completion listeners, and listeners may rely on that. For example, 
`RocksDBStateStoreProvider` registers a failure listener `if (!hasCommitted) 
abort()` next to a completion listener that aborts the store and releases its 
stamp, so the failure listener may now call `abort()` after the stamp has been 
released.
   
   The same happens when the release below hands the invocation to a thread 
waiting in `markTaskCompleted` while failure listeners are still pending: that 
thread runs all completion listeners first.
   
   Exceptions from these failure listeners are also added to 
`listenerExceptions` (L466) and thrown from `markTaskCompleted` on the task 
thread. Before, they stayed on the thread that called `markTaskFailed`.
   
   Running them late is still better than dropping them, as before. Could 
pending failure listeners run first instead, i.e. before the handover below and 
before the next completion listener? Otherwise, could the order be documented 
here and in `addTaskFailureListener`?



##########
core/src/main/scala/org/apache/spark/TaskContextImpl.scala:
##########
@@ -320,21 +430,44 @@ private[spark] class TaskContextImpl(
           //    [[markTaskFailed]] here will have no effect and we simply 
resume running the
           //    remaining completion handlers.
           try {
-            listenerInvocationThread = None
-            markTaskFailed(e)
+            markTaskFailed(e, Nested)
           } catch {
             case t: Throwable => e.addSuppressed(t)
-          } finally {
-            synchronized {
-              if (listenerInvocationThread.isEmpty) {
-                listenerInvocationThread = Some(Thread.currentThread())
-              }
-            }
           }
           listenerExceptions += e
           logError(log"Error in ${MDC(LISTENER, name)}", e)
       }
     }
+    if (mode != Nested) {

Review Comment:
   **[Low] The release is not in a `finally`, and a stale holder now blocks 
`markTaskCompleted` forever.**
   
   If a `Throwable` escapes the loop above, this thread never gets here, and 
`listenerInvocationThread` keeps pointing at it. The catch block can throw 
itself: if a listener fails with `OutOfMemoryError`, `listenerExceptions += e` 
or `logError` (L437-438) can fail again. Before this PR, a stale holder only 
made `markTaskCompleted` return without running the completion listeners. Now 
`markTaskCompleted` waits in `awaitListenerInvocationHandover` for a handover 
that never comes, and interrupts do not end that wait.
   
   The reverse case also exists: if `awaitListenerInvocationHandover` throws, 
e.g. from the `logWarning` at L269, `nextListenerInvocationThread` stays set. 
The holder later hands the invocation to a thread that has already left, and 
every later listener is skipped.
   
   Could the main loop be wrapped in a `try`/`finally`, so that the release, or 
at least the handover, always runs?



##########
core/src/main/scala/org/apache/spark/TaskContextImpl.scala:
##########
@@ -320,21 +430,44 @@ private[spark] class TaskContextImpl(
           //    [[markTaskFailed]] here will have no effect and we simply 
resume running the
           //    remaining completion handlers.
           try {
-            listenerInvocationThread = None
-            markTaskFailed(e)
+            markTaskFailed(e, Nested)
           } catch {
             case t: Throwable => e.addSuppressed(t)
-          } finally {
-            synchronized {
-              if (listenerInvocationThread.isEmpty) {
-                listenerInvocationThread = Some(Thread.currentThread())
-              }
-            }
           }
           listenerExceptions += e
           logError(log"Error in ${MDC(LISTENER, name)}", e)
       }
     }
+    if (mode != Nested) {
+      // We have executed all listeners of `listenerStacks` that have been 
added so far. Run the
+      // listeners left to this thread (see above), then deregister this 
thread as the callback
+      // invocation thread, or hand the invocation over to a thread waiting 
for it.
+      var released = false
+      while (!released) {
+        val pendingListeners: Option[() => Unit] = synchronized {
+          val pendingFailure = failureCauseOpt.filter(_ => 
!onFailureCallbacks.empty())
+          if (nextListenerInvocationThread.isEmpty && 
pendingFailure.isDefined) {
+            Some(() => invokeTaskFailureListeners(pendingFailure.get, Nested))
+          } else if (nextListenerInvocationThread.isEmpty && completed &&
+              (!onPreCompleteCallbacks.empty() || 
!onCompleteCallbacks.empty())) {
+            Some(() => invokeTaskCompletionListeners(None, Nested))

Review Comment:
   **[Low] Leftover completion listeners can run on a kill thread under the 
global `TaskContext` lock.**
   
   This runs the pending completion listeners on whichever thread holds the 
invocation, not only on the completing thread. For example, after the task 
thread has finished `markTaskCompleted`, a late kill whose interrupt listener 
throws runs the failure listeners on the kill thread, inside `Task.kill`'s 
JVM-wide `TaskContext.synchronized` block (`Task.scala` L235). A completion 
listener that another thread adds at that point is left to the kill thread, 
which runs it here under that lock. A slow close, e.g. of an S3 or JDBC 
resource, then also blocks `Task.kill`, `interruptible()` and 
`createResourceUninterruptibly` for every task on the executor. The new test 
"completion listeners queued meanwhile run before failure invoker releases" 
asserts this behavior on purpose.
   
   This is rare, but the comment at L215, "The completion listeners always run 
on this thread", now holds only for the listeners that are pending when 
`markTaskCompleted` is called. Could it say so, and mention that the leftover 
ones run on the thread that holds the invocation?



##########
core/src/test/scala/org/apache/spark/scheduler/TaskContextSuite.scala:
##########
@@ -757,6 +760,254 @@ 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. Had P1 and
+    // P2 been added with addTaskCompletionListener, B would run before them.
+    context.addTaskCompletionListener(makeLoggingListener("B"))
+
+    context.markTaskCompleted(None)
+    assert(invocationOrder === Seq("P2", "P1", "B", "A"))
+    // Called immediately once the task has completed, like completion 
listeners.
+    context.addTaskPreCompletionListener(makeLoggingListener("P3"))
+    assert(invocationOrder === Seq("P2", "P1", "B", "A", "P3"))
+  }
+
+  test("SPARK-60113: completion listeners run even if a pre-completion 
listener fails") {
+    val context = TaskContext.empty()
+    val listener = mock(classOf[TaskCompletionListener])
+    context.addTaskCompletionListener(listener)
+    context.addTaskPreCompletionListener(new TaskCompletionListener {
+      override def onTaskCompletion(context: TaskContext): Unit = throw new 
Exception("pre")
+    })
+
+    val e = intercept[TaskCompletionListenerException] {
+      context.markTaskCompleted(None)
+    }
+    assert(e.getMessage.contains("pre"))
+    verify(listener, times(1)).onTaskCompletion(any())
+    assert(context.isFailed())
+  }
+
+  test("SPARK-60113: failures of pre-completion and completion listeners are 
both reported") {
+    val context = TaskContext.empty()
+    context.addTaskCompletionListener(new TaskCompletionListener {
+      override def onTaskCompletion(context: TaskContext): Unit = throw new 
Exception("post")
+    })
+    context.addTaskPreCompletionListener(new TaskCompletionListener {
+      override def onTaskCompletion(context: TaskContext): Unit = throw new 
Exception("pre")
+    })
+
+    val e = intercept[TaskCompletionListenerException] {
+      context.markTaskCompleted(None)
+    }
+    assert(e.getMessage.contains("pre"))
+    assert(e.getMessage.contains("post"))
+  }
+
+  test("SPARK-60113: a pre-completion listener added during completion runs 
next") {
+    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.addTaskCompletionListener(new TaskCompletionListener {
+      override def onTaskCompletion(context: TaskContext): Unit = {
+        invocationOrder += "B"
+        // Added from a second thread while this thread runs the completion 
listeners, so the
+        // call returns without running it; this thread must still run it.
+        val thread = new Thread(() => {
+          context.addTaskPreCompletionListener(makeLoggingListener("P"))
+        })
+        thread.start()
+        thread.join()

Review Comment:
   **[Nit] These tests join helper threads without a timeout, some from inside 
a listener that holds the invocation.**
   
   Here and in "failure listeners queued during completion run before it 
releases" (`failer.join()`, L970), the listener blocks in `join()` while its 
thread holds the listener invocation. "completion waits for another thread 
invoking listeners and runs them" has the same `adder.join()` (L945), and none 
of these helper threads is a daemon thread. If a later change makes the helper 
wait for the invocation, as `markTaskCompleted` now does, the test hangs until 
the core job hits the 2h30m limit, as the `StreamingShuffleSuite` test does 
now, instead of failing. Could they use `join(timeout)` with 
`assert(!thread.isAlive)` and `setDaemon(true)`, like the other new tests?



##########
core/src/main/scala/org/apache/spark/TaskContextImpl.scala:
##########
@@ -426,3 +559,21 @@ private[spark] class TaskContextImpl(
     }
   }
 }
+
+private object TaskContextImpl {
+
+  /** How an `invokeListeners()` call takes part in the listener invocation. */
+  sealed trait InvocationMode
+
+  /** Registers this thread as `listenerInvocationThread` unless another 
thread is registered. */
+  case object Claim extends InvocationMode
+
+  /** The caller has already registered this thread as 
`listenerInvocationThread`. */
+  case object Claimed extends InvocationMode
+
+  /** An outer invocation on this thread has registered it, and keeps it when 
this one is done. */
+  case object Nested extends InvocationMode
+
+  /** How often to log while `markTaskCompleted()` waits for another thread 
invoking listeners. */
+  val listenerHandoverWarnIntervalMs = 10000L

Review Comment:
   **[Nit] The handover warning has no test, and this interval cannot be 
shortened for one.**
   
   The warning is the only sign that `markTaskCompleted` is stuck behind 
another thread, but it fires only after 10 s, and none of the new tests wait 
that long. A regression there, e.g. the wrong holder named or no stack 
attached, would go unnoticed. `PipelinedWriterTask` takes `warnIntervalMs` as a 
parameter, and `BasePythonRunnerSuite` sets it to 100 ms to exercise that path. 
Could this interval be configurable in the same way, with a test that checks 
that the warning names the holder and carries its stack?



##########
core/src/main/scala/org/apache/spark/TaskContextImpl.scala:
##########
@@ -169,37 +196,113 @@ 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
+    // it may stay in this state, by killing the executor JVM, but only if the 
blocked listeners
+    // do not run within the reaper's own kill call: the reaper calls 
`Task.kill` before it starts
+    // checking the timeout, and that call runs the failure listeners on the 
reaper thread if an
+    // interrupt listener throws. Then only the periodic warning shows the 
hang.
+    val mode = synchronized {
       if (completed) return
       completed = true
+      val currentThread = Thread.currentThread()
+      if (listenerInvocationThread.isEmpty) {
+        listenerInvocationThread = Some(currentThread)
+        Claimed
+      } else if (listenerInvocationThread.contains(currentThread)) {
+        // Called from a listener on this thread: run the completion listeners 
as part of that
+        // invocation.
+        Nested
+      } else {
+        nextListenerInvocationThread = Some(currentThread)
+        awaitListenerInvocationHandover(currentThread)
+        Claimed
+      }
+    }
+    invokeTaskCompletionListeners(error, mode)
+  }
+
+  /**
+   * Waits until another thread invoking listeners hands the invocation over 
to `currentThread`.
+   * Interrupts do not stop the wait and are restored afterwards. Must hold 
the TaskContext lock.
+   */
+  private def awaitListenerInvocationHandover(currentThread: Thread): Unit = {
+    val startNs = System.nanoTime()
+    var nextWarningNs = startNs + 
TimeUnit.MILLISECONDS.toNanos(listenerHandoverWarnIntervalMs)
+    var interrupted = false
+    while (!listenerInvocationThread.contains(currentThread)) {
+      try {
+        wait(math.max(1L, TimeUnit.NANOSECONDS.toMillis(nextWarningNs - 
System.nanoTime())))
+      } catch {
+        case _: InterruptedException => interrupted = true
+      }
+      val nowNs = System.nanoTime()
+      if (nowNs >= nextWarningNs && 
!listenerInvocationThread.contains(currentThread)) {
+        listenerInvocationThread.foreach { holder =>
+          val holderStack = new Exception(s"Stack trace of thread 
${holder.getName}")
+          holderStack.setStackTrace(holder.getStackTrace)

Review Comment:
   **[Nit] The warning walks the holder's stack and logs it every 10 s while 
holding the `TaskContextImpl` lock.**
   
   The holder needs this lock to hand over (L455-458), and `isCompleted()`, 
`isFailed()` and every `add*Listener` call on other threads need it too, so 
each warning stalls them behind a stack walk and a synchronous log write. A 
task that stays stuck for an hour also logs 360 full stack traces as 
`java.lang.Exception: Stack trace of thread ...`, which log scanners tend to 
count as errors. Could the stack be attached only to the first warning, or 
could the warnings come at growing intervals?



##########
core/src/main/scala/org/apache/spark/TaskContextImpl.scala:
##########
@@ -169,37 +196,113 @@ 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

Review Comment:
   **[Discussion] The `TaskContextImpl` part could be its own `[CORE]` change.**
   
   The rework here (the handover in `markTaskCompleted`, the release loop, and 
the three invocation modes) applies to every task, not only to the opt-in 
pipelined mode:
   
   - `markTaskCompleted` can now block indefinitely on another thread.
   - Failure-listener exceptions from other threads can now fail the task on 
the task thread.
   - Failure and completion listeners that used to be dropped now run, possibly 
late and on other threads.
   - An existing core test deadlocks (see the comment on L243).
   
   The PR description answers "No" to the user-facing change question because 
the pipelined mode is unreleased, but this part is not behind that flag. A 
separate JIRA and PR for it would get its own review and tests, and a 
regression in it would not force reverting the use-after-free fix together with 
it. What do you think?



##########
core/src/main/scala/org/apache/spark/TaskContextImpl.scala:
##########
@@ -169,37 +196,113 @@ 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
+    // it may stay in this state, by killing the executor JVM, but only if the 
blocked listeners
+    // do not run within the reaper's own kill call: the reaper calls 
`Task.kill` before it starts
+    // checking the timeout, and that call runs the failure listeners on the 
reaper thread if an
+    // interrupt listener throws. Then only the periodic warning shows the 
hang.
+    val mode = synchronized {
       if (completed) return
       completed = true
+      val currentThread = Thread.currentThread()
+      if (listenerInvocationThread.isEmpty) {
+        listenerInvocationThread = Some(currentThread)
+        Claimed
+      } else if (listenerInvocationThread.contains(currentThread)) {
+        // Called from a listener on this thread: run the completion listeners 
as part of that
+        // invocation.
+        Nested
+      } else {
+        nextListenerInvocationThread = Some(currentThread)
+        awaitListenerInvocationHandover(currentThread)

Review Comment:
   **[High] This wait deadlocks an existing `StreamingShuffleSuite` test, and 
the core CI job times out on it.**
   
   `StreamingShuffleSuite`'s "background markTaskFailed races with task thread 
markTaskCompleted" (L1380-1433) has a background thread call `markTaskFailed` 
and block in a failure listener on `failureListenerProceed`. The test thread 
then calls `markTaskCompleted(Some(error))` (L1419). Before this PR, that call 
returned right away. Now it waits here for the background thread to hand over 
the invocation, but the test releases the failure listener only at L1422, on 
the same thread, after `markTaskCompleted` returns. Neither thread can make 
progress, and since this wait ignores interrupts, the test framework cannot 
break it either.
   
   The core module job shows it:
   
   - 7ab7ae2e6af 
([job](https://github.com/viirya/spark-1/actions/runs/38314820956/job/115033969680)):
 logs "Test still running after 2 hours, 14 minutes" for this test and is 
cancelled at the 2h30m limit.
   - 1053b61d411 
([job](https://github.com/viirya/spark-1/actions/runs/38285171547/job/114945456531)):
 hangs in the same test and is cancelled the same way.
   - 038c4b3420e, the last commit before the handover wait 
([job](https://github.com/viirya/spark-1/actions/runs/38194049541/job/114635793761)):
 passes in about 66 minutes.
   
   So the core-module suites scheduled after it, `BasePythonRunnerSuite` 
included, have not run in CI since 1053b61d411. `TaskContextSuite` runs earlier 
and passes.
   
   The test and its comments (L1375-1379, L1405-1406, L1416-1418, L1425-1430) 
document the skipped-listener race that this PR removes. Could the test be 
updated to the new behavior? For example, call `markTaskCompleted` on another 
thread, wait until that thread blocks in this wait, release the failure 
listener, and then assert that the completion listener ran, on that thread.



##########
core/src/main/scala/org/apache/spark/api/python/PythonRunner.scala:
##########
@@ -183,6 +183,180 @@ 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.
+   *
+   * The wait has no time bound, so a writer blocked in upstream I/O that 
ignores interrupts
+   * (e.g. a socket read without a timeout) holds task completion until that 
I/O returns. This
+   * includes I/O that only a close listener of the upstream reader would 
unblock, since that
+   * listener now runs after the wait. If such a task is killed, 
`spark.task.reaper.enabled` with
+   * a positive `spark.task.reaper.killTimeout` bounds how long it may stay in 
this state, by
+   * killing the executor JVM.
+   */
+  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)
+    })
+  }
+
+  /** The state of a [[PipelinedWriterTask]]. */
+  private sealed trait PipelinedWriterState
+  private case object PipelinedWriterNotStarted extends PipelinedWriterState
+  private case class PipelinedWriterRunning(thread: Thread) extends 
PipelinedWriterState
+  /** The writer has exited, or was stopped before it started and will never 
run. */
+  private case object PipelinedWriterFinished extends PipelinedWriterState
+
+  /**
+   * 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,
+      warnIntervalMs: Long = pipelinedWriterExitWarnIntervalMs)
+    extends Runnable {
+    // Protected by `this`. NotStarted moves to Running when run() starts the 
writer, or to
+    // Finished when stopAndAwaitExit() gets there first; Running moves to 
Finished when the
+    // writer exits. `exited` is counted down whenever the state becomes 
Finished.
+    private var state: PipelinedWriterState = PipelinedWriterNotStarted
+    private val exited = new CountDownLatch(1)
+
+    private def finish(): Unit = synchronized {
+      state = PipelinedWriterFinished
+      exited.countDown()
+    }
+
+    override def run(): Unit = {
+      val start = synchronized {
+        if (state == PipelinedWriterNotStarted) {
+          state = PipelinedWriterRunning(Thread.currentThread())
+          true
+        } else {
+          false
+        }
+      }
+      if (start) {
+        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 {
+          finish()
+        }
+      }
+    }
+
+    /** Interrupts the writer thread, but only while it still runs the writer. 
*/
+    private def interruptWriter(): Unit = synchronized {
+      state match {
+        case PipelinedWriterRunning(thread) => thread.interrupt()
+        case _ =>
+      }
+    }
+
+    /**
+     * Stops the writer and waits until it has exited, or makes sure it never 
runs. Leaves the
+     * interrupt status of the calling thread as it was on entry.
+     */
+    def stopAndAwaitExit(future: Future[_]): Unit = {
+      val wasInterrupted = Thread.interrupted()
+      try {
+        // Interrupts the writer thread if the writer is running. This 
unblocks channel.write
+        // (the JDK closes the channel and throws ClosedByInterruptException). 
Otherwise the
+        // writer sees the interrupt only between calls to 
writeNextInputToStream, each of which
+        // can pull and serialize a whole batch of input rows (e.g. for the 
Arrow runners).
+        future.cancel(true)

Review Comment:
   **[Nit] `future.cancel(true)` is no longer needed with the new state.**
   
   The state already covers both cases. `NotStarted -> Finished` keeps `run()` 
from starting the writer. For `PipelinedWriterRunning(thread)`, interrupting 
`thread` under `this`, as `interruptWriter()` does, reaches the same thread 
that `cancel(true)` interrupts. `cancel(true)` does it outside the lock, so it 
can still interrupt the pool thread after the writer has exited. On the writer 
thread itself, it interrupts the calling thread, which is one reason for the 
unconditional `Thread.interrupted()` in the `finally`.
   
   Interrupting in the state match instead, skipped when `thread eq 
Thread.currentThread()`, would remove the `future` parameter of 
`stopAndAwaitExit`, the `writerFuture` in `startPipelinedWriter`, the 
self-interrupt, and most of the `FutureTask` plumbing in the tests.



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