viirya commented on code in PR #59663:
URL: https://github.com/apache/spark/pull/59663#discussion_r4404165554


##########
core/src/test/scala/org/apache/spark/shuffle/streaming/StreamingShuffleSuite.scala:
##########
@@ -1372,68 +1372,81 @@ class StreamingShuffleSuite
     }
   }
 
-  // Demonstrates why background threads must not call 
context.markTaskFailed() directly:
-  // doing so can cause the task thread's markTaskCompleted() to silently skip 
completion
-  // listeners. In production, cleanupResources() is a completion listener, so 
skipping it
-  // leaks threads. The reader avoids this by routing background errors 
through a
-  // CompletableFuture so markTaskFailed/markTaskCompleted are both called 
from the task thread.
+  // Shows why background threads should not call context.markTaskFailed() 
directly: the
+  // failure listeners then run on that thread, and the task thread's 
markTaskCompleted() blocks
+  // until they finish before it runs the completion listeners. In production, 
cleanupResources()
+  // is a completion listener, so it waits for whatever the background 
thread's failure listeners
+  // wait for. The reader avoids this by routing background errors through an 
ErrorNotifier so
+  // that markTaskFailed/markTaskCompleted are both called from the task 
thread.
   test("background markTaskFailed races with task thread markTaskCompleted") {
     val readerContext = createTaskContext(sparkConf, partitionId = 0)
     TaskContext.setTaskContext(readerContext)

Review Comment:
   Removed both.
   



##########
core/src/main/scala/org/apache/spark/TaskContextImpl.scala:
##########
@@ -217,30 +240,85 @@ private[spark] class TaskContextImpl(
     // writer that adds listeners lazily while it pulls its input. And since 
the completion
     // listeners only run after `completed` is set, they run after every 
pre-completion listener,
     // even if another thread is invoking listeners at this point.
+    //
+    // The completion listeners then run on this thread too, and this method 
returns only after
+    // they have run. If it returned while another thread is invoking 
listeners (e.g. a kill thread
+    // running failure listeners), that thread would run them only after its 
own listeners, after
+    // the task has released its memory and reported its result. So the 
listener invocation is
+    // claimed together with setting `completed`, which also keeps a thread 
that adds a completion
+    // listener at that moment from running them; if another thread holds it, 
that thread hands it
+    // over to this one when done. Failure listeners pending at that point run 
first (see
+    // `invokeListeners()`). A completion listener added after this method has 
run the others runs
+    // on the thread adding it, or, if another thread is invoking listeners at 
that moment, on that
+    // thread before it stops (see `invokeListeners()`).
+    //
+    // The wait for the other thread has no time bound: if its listeners 
block, so does this task.
+    // It logs a warning periodically, the first one with that thread's stack 
trace. The task waits
+    // here before it releases its block locks and memory (the executor does 
that once `Task.run`
+    // returns), so a failure listener on another thread, e.g. a kill thread, 
must not wait for
+    // either. If such a task is killed, `spark.task.reaper.enabled` with a 
positive
+    // `spark.task.reaper.killTimeout` bounds the wait by killing the executor 
JVM, except in
+    // local mode, where the reaper only logs an error, and except when the 
blocked listeners run
+    // on the reaper thread itself, within its kill call.
+    val currentThread = Thread.currentThread()
+    var mode: InvocationMode = Claimed
+    var awaitHandover = false
+    // Whether this call registered this thread as the listener invocation 
thread or as the next
+    // one, so that it must make sure not to leave it registered.
+    var registered = false
     val preCompletionExceptions = new ArrayBuffer[Throwable](2)
     def nextPreCompletionListener(): Option[TaskCompletionListener] = 
synchronized {
       if (onPreCompleteCallbacks.empty()) {
         // Set together with finding no more pre-completion listeners, so that 
one added later
-        // is handed to `addTaskCompletionListener` instead of being left in 
the stack.
+        // is handed to `addTaskCompletionListener` instead of being left in 
the stack. The
+        // listener invocation is claimed in the same step (see above).
         completed = true
+        if (listenerInvocationThread.isEmpty) {
+          listenerInvocationThread = Some(currentThread)
+          registered = true
+        } else if (listenerInvocationThread.contains(currentThread)) {
+          // Called from a listener on this thread: run the completion 
listeners as part of that
+          // invocation.
+          mode = Nested
+        } else {
+          nextListenerInvocationThread = Some(currentThread)
+          awaitHandover = true
+          registered = true
+        }
         None
       } else {
         Some(onPreCompleteCallbacks.pop())
       }
     }
     var listener: Option[TaskCompletionListener] = None
-    while ({listener = nextPreCompletionListener(); listener.nonEmpty}) {
-      invokeTaskPreCompletionListener(listener.get, preCompletionExceptions)
-    }
-
     try {
-      invokeTaskCompletionListeners(error)
-    } catch {
-      case e: TaskCompletionListenerException if 
preCompletionExceptions.nonEmpty =>
-        // Merge the completion listeners' own failures, not the exception 
that wraps them. Any
-        // other error comes from invokeListeners itself (e.g. an 
OutOfMemoryError) and is left
-        // to propagate, so that the executor still sees a fatal one.
-        preCompletionExceptions ++= e.getSuppressed.toSeq
+      while ({listener = nextPreCompletionListener(); listener.nonEmpty}) {
+        invokeTaskPreCompletionListener(listener.get, preCompletionExceptions)
+      }
+      try {
+        if (awaitHandover) {
+          awaitListenerInvocationHandover(currentThread)
+        }
+        invokeTaskCompletionListeners(error, mode)
+      } catch {
+        case e: TaskCompletionListenerException if 
preCompletionExceptions.nonEmpty =>
+          // Merge the completion listeners' own failures, not the exception 
that wraps them. Any
+          // other error comes from invokeListeners itself (e.g. an 
OutOfMemoryError) and is left
+          // to propagate, so that the executor still sees a fatal one.
+          preCompletionExceptions ++= e.getSuppressed.toSeq
+      }
+    } finally {
+      if (registered) synchronized {
+        // `invokeListeners()` releases the invocation itself once it has 
started; this covers an
+        // error before that, or in the wait. A stale waiter would get the 
invocation handed to
+        // it, and a stale holder would keep every other thread from running 
listeners.
+        if (nextListenerInvocationThread.contains(currentThread)) {
+          nextListenerInvocationThread = None
+        }
+        if (listenerInvocationThread.contains(currentThread)) {

Review Comment:
   The release at the end of the loop with a waiter is now covered by "an error 
that escapes the holder's listener loop hands the invocation over": the 
holder's failure listener throws, logging that fails, and the thread waiting in 
`markTaskCompleted()` must get the invocation and run the pending listener. 
Replacing `releaseListenerInvocation()` there with `listenerInvocationThread = 
None` fails it.
   
   For L315-320: with item 2, the wait no longer ends early, and the thread it 
hands over to goes straight into `invokeListeners()`, which releases the 
invocation itself. So an error can reach this `finally` with this thread still 
registered, as waiter or as holder, only before `invokeListeners()` has 
started, e.g. an `OutOfMemoryError`. I kept it as a defensive release, and I 
don't see a way to test it without injecting an error there; the description 
says it is not covered.
   



##########
core/src/main/scala/org/apache/spark/TaskContextImpl.scala:
##########
@@ -217,30 +240,85 @@ private[spark] class TaskContextImpl(
     // writer that adds listeners lazily while it pulls its input. And since 
the completion
     // listeners only run after `completed` is set, they run after every 
pre-completion listener,
     // even if another thread is invoking listeners at this point.
+    //
+    // The completion listeners then run on this thread too, and this method 
returns only after
+    // they have run. If it returned while another thread is invoking 
listeners (e.g. a kill thread
+    // running failure listeners), that thread would run them only after its 
own listeners, after
+    // the task has released its memory and reported its result. So the 
listener invocation is

Review Comment:
   Thanks for checking. With items 1 and 2 the holder no longer runs the 
completion listeners, so the sentence is now about what happens if 
`markTaskCompleted()` returned early: the other thread would not run them, and 
the task would release its memory with them pending. The sentence about the 
executor exiting is gone with the early end of the wait. The description now 
says that on master the task thread could release its block locks and memory, 
but not report its result, before it blocked on the `TaskRunner` lock.
   



##########
core/src/test/scala/org/apache/spark/scheduler/TaskContextSuite.scala:
##########
@@ -216,23 +222,22 @@ class TaskContextSuite extends SparkFunSuite with 
BeforeAndAfter with LocalSpark
     })
 
     // Interrupt the task from a separate thread and wait until the interrupt 
listener starts.
-    val interruptThread = new Thread(() => context.markInterrupted("test 
interrupt"))
-    interruptThread.start()
-    interruptListenerStarted.acquire()
-
-    // While the interrupt listener is running on the interrupt thread, mark 
the task completed
-    // on this thread. With the dedicated interrupt listener loop, this must 
NOT be blocked.
-    context.markTaskCompleted(None)
-
-    // The completion listener should have been called even though the 
interrupt listener is still
-    // running. If `invokeListeners()` were shared between interrupt and 
completion listeners,
-    // the completion listener would be silently skipped because 
`listenerInvocationThread` would
-    // be held by the interrupt thread.
-    verify(completionListener, times(1)).onTaskCompletion(any())
-
-    // Release the interrupt listener and join the interrupt thread.
-    interruptListenerRelease.release()
-    interruptThread.join()
+    val interruptThread = 
startDaemonThread("interrupt")(context.markInterrupted("test interrupt"))
+    try {
+      interruptListenerStarted.acquire()
+
+      // While the interrupt listener is running on the interrupt thread, mark 
the task completed
+      // on another thread. With the dedicated interrupt listener loop, this 
must NOT be blocked.
+      // If `invokeListeners()` were shared between interrupt and completion 
listeners, the
+      // interrupt thread would hold `listenerInvocationThread`, and the 
completion would wait
+      // for it, so the join below would time out.
+      
joinThread(startDaemonThread("completion")(context.markTaskCompleted(None)))

Review Comment:
   Done: `startDaemonThread` now returns a thread that records what its body 
throws, and `joinThread` asserts that it threw nothing. The `taskThread` in 
`StreamingShuffleSuite` records it too.
   



##########
core/src/main/scala/org/apache/spark/TaskContextImpl.scala:
##########
@@ -331,10 +497,34 @@ private[spark] class TaskContextImpl(
     }
   }
 
+  /**
+   * Invokes and removes the listeners in `listeners`, in reverse order of 
registration.
+   *
+   * Other threads cannot invoke listeners while this thread does, so they 
queue completion and
+   * failure listeners and leave them to this thread. (Post-status-update 
listeners that another
+   * thread invokes meanwhile are not left to it; they are dropped.) Failure 
listeners left to it
+   * (the task has failed) run before the next listener of `listeners`, e.g. 
so that a failure
+   * listener that aborts a state store update runs before the completion 
listener that releases
+   * the state store. Unless `mode` is `Nested`, this thread releases the 
invocation when it is
+   * done, but first runs the completion listeners left to it if the task has 
completed, unless a
+   * thread waiting in `markTaskCompleted()` takes the invocation over and 
runs them itself. Such
+   * leftover completion listeners were added after `markTaskCompleted()` had 
run the others.

Review Comment:
   The leftover branch is gone (item 1), and so is the sentence.
   



##########
core/src/main/scala/org/apache/spark/util/ErrorNotifier.scala:
##########
@@ -28,9 +28,10 @@ import org.apache.spark.internal.Logging
  *
  * A background thread records the first error via [[markError]] rather than 
failing the task
  * directly; the owning thread polls [[throwErrorIfExists]] (or [[getError]]) 
at a safe point and
- * re-throws on its own thread. This avoids a race where calling 
`TaskContext.markTaskFailed` from
- * a background thread can run concurrently with the task thread's 
`markTaskCompleted` and silently
- * skip completion listeners (including cleanup), leading to thread leaks.
+ * re-throws on its own thread. Calling `TaskContext.markTaskFailed` from a 
background thread

Review Comment:
   Done, with your wording.
   



##########
core/src/main/scala/org/apache/spark/TaskContextImpl.scala:
##########
@@ -134,7 +149,7 @@ private[spark] class TaskContextImpl(
       completed
     }
     if (needToCallListener) {
-      invokeTaskCompletionListeners(None)
+      invokeTaskCompletionListeners(None, Claim)

Review Comment:
   Updated: with another thread invoking listeners, the call is a no-op, and 
the new listener runs only if that thread runs the completion listeners in 
`markTaskCompleted()`, or hands the invocation over to a thread waiting to do 
so; otherwise it is not run until a listener is added while no thread is 
invoking listeners.
   



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