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


##########
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:
   Thanks, verified in d05f9547fea. The `markTaskCompleted` comment now says 
that the reaper bounds the handover wait only when the blocked listeners do not 
run within its own kill call, and that otherwise only the periodic warning 
shows the hang. Item 3 of the PR description now describes the single 
`PipelinedWriterState` as well.
   
   <!-- SPARK_DEV_REVIEW_REPLY 
{"feedback_id":"inline:4255354335","thread_id":"inline:4255354335","verdict_sha256":"ef0b8c6e5798ce23390828b19412cb78862f22a318a0e4cc0fb82794e99a9180"}
 -->



##########
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))
+          } else {
+            listenerInvocationThread = nextListenerInvocationThread
+            nextListenerInvocationThread = None
+            notifyAll()
+            released = true
+            None
+          }
+        }
+        pendingListeners.foreach { invoke =>
+          try {
+            invoke()
+          } catch {
+            case e: Throwable => listenerExceptions += e

Review Comment:
   **Nit (P3):** This catch keeps the release loop reaching `released = true` 
and `notifyAll()` when a deferred listener throws, and it puts that failure 
into the reported exception. Both new release-loop tests ("failure listeners 
queued during completion run before it releases" and "completion listeners 
queued meanwhile run before failure invoker releases") use listeners that do 
not throw, so nothing exercises this branch. If a later change dropped the 
catch, the nested invocation's `TaskCompletionListenerException` would leave 
the loop early with this thread still registered as `listenerInvocationThread`. 
Later invocations, including `invokePostStatusUpdateListeners`, would return 
without running anything, and a `markTaskCompleted` waiting for the handover 
would never be woken. If it swallowed the exception instead, the failure would 
never reach the caller. Neither change would fail a current test.
   
   **Verification:**
   - **Regression:** The test fails when the release loop lets a deferred 
listener's exception escape before releasing the invocation, or swallows it, 
and passes with the current implementation.



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

Review Comment:
   **Nit (P3):** This catch keeps the handover wait going on an interrupt, and 
the restore below hands the interrupt back once the invocation arrives. Both 
matter on the kill path: `Task.kill` interrupts the task thread, which can land 
while it waits here for a kill thread that is running failure listeners. If a 
later change let the `InterruptedException` escape, `markTaskCompleted` would 
throw with `completed` already set and `nextListenerInvocationThread` still 
naming this thread. The holder would then hand the invocation to a thread that 
has left, `listenerInvocationThread` would stay set, and none of the completion 
listeners (including the pipelined writer stop and the memory cleanup) would 
run. If the restore were dropped, the kill's interrupt would be silently lost. 
"completion waits for another thread invoking listeners and runs them" never 
interrupts the completion thread, so no current assertion fails in either case.
   
   **Verification:**
   - **Regression:** The test fails when the handover wait propagates 
InterruptedException or does not restore the interrupt flag, and passes with 
the current implementation.
   - **Behavior:** After the interrupted wait, every pending completion 
listener runs on the completing thread.



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