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


##########
core/src/main/scala/org/apache/spark/TaskContextImpl.scala:
##########
@@ -449,6 +654,52 @@ private[spark] class TaskContextImpl(
           }
       }
     }
+
+    def runLeftFailureListeners(cause: Throwable): Unit = {
+      try {
+        invokeTaskFailureListeners(cause, Nested)
+      } catch {
+        // Merge the listeners' own failures, which the nested invocation has 
logged, not the
+        // exception that wraps them. A fatal error (e.g. an OutOfMemoryError) 
propagates, through
+        // the release below.
+        case e: TaskCompletionListenerException => listenerExceptions ++= 
e.getSuppressed.toSeq
+        case NonFatal(t) => listenerExceptions += t

Review Comment:
   **3. [Nit] A failing `getMessage` drops the other listeners' failures from 
the report**
   
   Round 3 handles a failing `getMessage` where the error is caught (L322, 
L364, L644 and this line), but the error comes from one place. 
`listenerExceptions.map(_.getMessage)` (L381, L534, L705) throws at the first 
such exception, and then none of the collected failures is reported.
   
   For example, two failure listeners are left to the completing thread. One 
throws `new Exception("a")`, and the other an exception whose `getMessage` 
throws. L705 throws, and this line adds only that error, so "a" is in the log 
but not in what `markTaskCompleted` throws. "an exception whose getMessage 
throws does not end the listener loops" pins this result for a single listener 
(`TaskContextSuite.scala:1500`). Likewise, one pre-completion listener with 
such an exception replaces all the failures that L339 was about to report.
   
   Could the helper at L378-384 keep them, with L533-536 and L704-707 calling 
it too?
   
   ```scala
     private def newTaskCompletionListenerException(
         listenerExceptions: Seq[Throwable],
         error: Option[Throwable]): TaskCompletionListenerException = {
       // `getMessage` is listener code. Report its failure in place of the 
exception it fails for:
       // `printStackTrace`, e.g. in `Utils.exceptionString`, would fail on 
that exception as well.
       val reported = listenerExceptions.map { e =>
         try { e.getMessage; e } catch { case t if !Utils.isFatalError(t) => t }
       }
       val exception = new 
TaskCompletionListenerException(reported.map(_.getMessage), error)
       reported.foreach(exception.addSuppressed)
       exception
     }
   ```
   
   A failing `getMessage` then no longer leaves an invocation, which also 
settles item 1 for that path.



##########
core/src/main/scala/org/apache/spark/TaskContextImpl.scala:
##########
@@ -218,30 +244,96 @@ 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 not run them, and the 
task would go on to
+    // release its memory with them pending. 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 that another thread leaves to this 
invocation run before the
+    // next completion listener (see `invokeListeners()`). A completion 
listener added after this
+    // method has run the others runs on the thread adding it, unless another 
thread is invoking
+    // listeners at that moment: that thread runs it only if it is running 
completion listeners
+    // (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. Nothing bounds a wait for a kill thread: with 
`spark.task.reaper.enabled`, every
+    // kill runs on a reaper thread, which checks 
`spark.task.reaper.killTimeout` only after its
+    // kill call returns. If another thread holds the invocation and the task 
is killed, the reaper
+    // with a positive kill timeout bounds the wait by killing the executor 
JVM, except in local
+    // mode, where it only logs an error.
+    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 {
+        val warningFailure =
+          if (awaitHandover) awaitListenerInvocationHandover(currentThread) 
else None
+        try {
+          invokeTaskCompletionListeners(error, mode)
+        } catch {
+          // The failure to warn came after the pre-completion listeners' 
failures, but before
+          // those of the completion listeners.
+          case e: Throwable if warningFailure.exists(_ ne e) => 
warningFailure.get.addSuppressed(e)

Review Comment:
   **2. [Low] A failure to warn hides a fatal error from the completion 
listener invocation**
   
   This catch takes any `Throwable`. Suppose the warning failed with a 
non-fatal error, e.g. an `AppenderLoggingException`, and the invocation at L308 
then throws an `OutOfMemoryError`. The `OutOfMemoryError` becomes a suppressed 
exception of the failure to warn, and L314 throws the non-fatal one. 
`Executor.isFatalError` follows causes, not suppressed exceptions 
(`Executor.scala:1654-1665`), so the executor keeps running. L318-319 say that 
a fatal error propagates.
   
   Could this take only what is not fatal, with the same test as in item 1?
   
   ```scala
             case e if !Utils.isFatalError(e) && warningFailure.exists(_ ne e) 
=>
               warningFailure.get.addSuppressed(e)
   ```
   
   The fatal error then propagates instead of the failure to warn, which 
matters less.



##########
core/src/main/scala/org/apache/spark/TaskContextImpl.scala:
##########
@@ -288,27 +383,123 @@ private[spark] class TaskContextImpl(
     exception
   }
 
-  private def invokeTaskCompletionListeners(error: Option[Throwable]): Unit = {
+  /**
+   * Waits until the thread invoking listeners hands the invocation over to 
`currentThread`, which
+   * has registered itself as `nextListenerInvocationThread`.
+   *
+   * Interrupts do not stop the wait, and are passed on once it is done. The 
wait of
+   * `PipelinedWriterTask.awaitExit()` in `BasePythonRunner` drops them 
instead, because it is a
+   * listener itself and the listeners right after it must still be able to 
clean up. This wait
+   * is not a listener but part of `markTaskCompleted()`, so it leaves the 
interrupt status (e.g.

Review Comment:
   **6. [Nit] With a kill thread as holder, the interrupt comes right after the 
handover**
   
   L393-394 say that this wait leaves the interrupt status as the completion 
listeners would see it without the wait. When the holder is a kill thread, that 
kill's interrupt does not arrive during this wait. `Task.kill` calls 
`taskThread.interrupt()` only after `markInterrupted` has returned 
(`Task.scala:237-239`), and by then L475 has handed the invocation over. So 
with `interruptThread = true`, the completion listeners on this thread start at 
about the same moment as the interrupt, and the flag can stay set for the ones 
after it. `PipelinedWriterTask.awaitExit` drops interrupts to protect these 
listeners, because the flag makes their blocking channel and lock operations 
fail and skip cleanup (`PythonRunner.scala:378-381`).
   
   This is not a regression, since master skips these listeners in this case, 
and I don't see a simple way to avoid it here. But it is the main path of this 
PR, so it may be worth a sentence here and in the description's note on the 
wait, so that nobody assumes these listeners run undisturbed.



##########
core/src/test/scala/org/apache/spark/scheduler/TaskContextSuite.scala:
##########
@@ -1195,19 +1192,570 @@ class TaskContextSuite extends SparkFunSuite with 
BeforeAndAfter with LocalSpark
     completion
   }
 
+  /** A daemon thread that records what `body` throws, for `joinThread` to 
check. */
+  private class DaemonThread(name: String, body: => Unit) extends Thread(name) 
{
+    setDaemon(true)
+    @volatile var error: Throwable = _
+    override def run(): Unit = {
+      try {
+        body
+      } catch {
+        case t: Throwable => error = t
+      }
+    }
+  }
+
+  private def startDaemonThread(name: String)(body: => Unit): DaemonThread = {
+    val thread = new DaemonThread(name, body)
+    thread.start()
+    thread
+  }
+
+  private def joinThread(thread: DaemonThread): Unit = {
+    thread.join(TimeUnit.MINUTES.toMillis(1))
+    assert(!thread.isAlive, s"${thread.getName} is still running")
+    Option(thread.error).foreach(e => fail(s"${thread.getName} failed", e))
+  }
+
+  /**
+   * Runs `takeInvocation` on a new thread named `name`, by default failing 
the task, and waits
+   * until a failure listener on it holds the listener invocation, which it 
does until the
+   * returned latch is counted down. Returns that latch and the thread.
+   */
+  private def holdListenerInvocation(context: TaskContext, name: String = 
"listener-holder")(
+      takeInvocation: => Unit = context.markTaskFailed(new Exception("failed 
on another thread")))
+      : (CountDownLatch, DaemonThread) = {
+    val holding = new CountDownLatch(1)
+    val release = new CountDownLatch(1)
+    context.addTaskFailureListener(new TaskFailureListener {
+      override def onTaskFailure(context: TaskContext, error: Throwable): Unit 
= {
+        holding.countDown()
+        release.await(1, TimeUnit.MINUTES)
+      }
+    })
+    val holder = startDaemonThread(name)(takeInvocation)
+    assert(holding.await(1, TimeUnit.MINUTES))
+    (release, holder)
+  }
+
   /**
-   * An appender that throws `failure` for every event whose message contains 
`trigger`. Unlike
-   * `LogAppender`, it does not ignore exceptions, so log4j throws them from 
the logging call.
+   * Fails every logging call whose message contains `trigger` with `failure`. 
Unlike
+   * `LogAppender`, it does not ignore its exceptions, so log4j passes them to 
the caller.
    */
   private class FailingAppender(trigger: String, failure: RuntimeException)
     extends AbstractAppender("failingAppender", null, null, false, 
Property.EMPTY_ARRAY) {
+    val failures = new AtomicInteger()
+    // Of these, the failures of events that carry a throwable.
+    val failuresWithThrown = new AtomicInteger()
     override def append(event: LogEvent): Unit = {
       if (event.getMessage.getFormattedMessage.contains(trigger)) {
+        failures.incrementAndGet()
+        if (event.getThrown != null) failuresWithThrown.incrementAndGet()
         throw failure
       }
     }
   }
 
+  private def isCausedBy(error: Throwable, cause: Throwable): Boolean =
+    Iterator.iterate(error)(_.getCause).takeWhile(_ != null).exists(_ eq cause)
+
+  /** An exception whose `getMessage`, which is listener code too, throws 
`failure`. */
+  private class MessageFailingException(failure: Throwable) extends Exception {
+    override def getMessage: String = throw failure
+  }
+
+  private def addFailureListenerThrowing(context: TaskContext)(failure: => 
Throwable): Unit =
+    context.addTaskFailureListener(new TaskFailureListener {
+      override def onTaskFailure(context: TaskContext, error: Throwable): Unit 
= throw failure
+    })
+
+  /** Waits until `thread` waits in `markTaskCompleted()` for the listener 
invocation. */
+  private def awaitHandoverWait(thread: Thread): Unit = {
+    eventually(timeout(1.minute)) {
+      assert(!thread.isAlive || thread.getState == Thread.State.TIMED_WAITING)
+    }
+    assert(thread.isAlive, s"${thread.getName} returned instead of waiting")
+  }
+
+  test("SPARK-60222: a listener added as completion starts does not take over 
the listeners") {
+    // A thread that sees the task completed and adds a listener at that 
moment must not become
+    // the thread that runs the pending listeners: the completing thread 
claims the listener
+    // invocation together with setting `completed`.
+    (1 to 500).foreach { _ =>
+      val context = TaskContext.empty()
+      val pendingListenerThread = new AtomicReference[Thread]()
+      context.addTaskCompletionListener[Unit] { _ =>
+        pendingListenerThread.set(Thread.currentThread())
+      }
+      val ready = new CountDownLatch(1)
+      val adder = startDaemonThread("adder") {
+        ready.countDown()
+        while (!context.isCompleted()) {}
+        context.addTaskCompletionListener[Unit] { _ => }
+      }
+      ready.await()
+      context.markTaskCompleted(None)
+      joinThread(adder)
+      assert(pendingListenerThread.get eq Thread.currentThread())
+    }
+  }
+
+  test("SPARK-60222: a failing listener does not let another thread take over 
the listeners") {
+    // While a failing listener's failure listeners run, the thread invoking 
the listeners must
+    // stay registered, so that another thread adding listeners at that moment 
does not start
+    // running the pending ones, possibly concurrently with this thread.
+    (1 to 200).foreach { _ =>
+      val context = TaskContext.empty()
+      val pendingListenerThread = new AtomicReference[Thread]()
+      context.addTaskCompletionListener[Unit] { _ =>
+        pendingListenerThread.set(Thread.currentThread())
+      }
+      // Two failing listeners, which run first: the second failure finds the 
task already failed.
+      (1 to 2).foreach { i =>
+        context.addTaskCompletionListener(new TaskCompletionListener {
+          override def onTaskCompletion(context: TaskContext): Unit = throw 
new Exception(s"$i")
+        })
+      }
+      context.addTaskFailureListener(new TaskFailureListener {
+        override def onTaskFailure(context: TaskContext, error: Throwable): 
Unit = {}
+      })
+      val ready = new CountDownLatch(1)
+      val adder = startDaemonThread("adder") {
+        ready.countDown()
+        while (!context.isCompleted()) {}
+        (1 to 200).foreach { _ => context.addTaskCompletionListener[Unit] { _ 
=> } }
+      }
+      ready.await()
+      intercept[TaskCompletionListenerException] {
+        context.markTaskCompleted(None)
+      }
+      joinThread(adder)
+      assert(pendingListenerThread.get eq Thread.currentThread())
+    }
+  }
+
+  test("SPARK-60222: completion waits for another thread invoking listeners 
and runs them") {
+    val context = TaskContext.empty()
+    val listenerThreads = new ConcurrentLinkedQueue[(String, Thread)]()
+    context.addTaskCompletionListener(loggingListener(listenerThreads, 
"pending"))
+    context.addTaskInterruptListener(new TaskInterruptListener {
+      override def onTaskInterrupted(context: TaskContext, reason: String): 
Unit =
+        throw new Exception("interrupt listener")
+    })
+    // Like a kill: an interrupt listener fails, so the kill thread runs the 
failure listeners.
+    val (release, killer) = holdListenerInvocation(context, "killer") {
+      context.markInterrupted("killed")
+    }
+
+    val completion = 
startDaemonThread("completion")(context.markTaskCompleted(None))
+    try {
+      awaitHandoverWait(completion)
+      // Neither returns early nor runs the completion listeners while the 
kill thread holds the
+      // listener invocation, and a third thread adding a listener does not 
take them over.
+      joinThread(startDaemonThread("adder") {
+        context.addTaskCompletionListener(loggingListener(listenerThreads, 
"added"))
+      })
+      assert(completion.isAlive)
+      assert(listenerThreads.isEmpty)
+    } finally {
+      release.countDown()
+    }
+    joinThread(completion)
+    joinThread(killer)
+    assert(listenerThreads.asScala.toSeq === Seq(("added", completion), 
("pending", completion)))
+  }
+
+  test("SPARK-60222: a listener on the invoking thread completing the task 
runs them nested") {
+    val context = TaskContext.empty()
+    val completionListenerThread = new AtomicReference[Thread]()
+    context.addTaskCompletionListener[Unit] { _ =>
+      completionListenerThread.set(Thread.currentThread())
+    }
+    val ranBeforeReturn = new AtomicReference[Option[Boolean]](None)
+    val laterListenerThread = new AtomicReference[Thread]()
+    context.addTaskFailureListener(new TaskFailureListener {
+      override def onTaskFailure(context: TaskContext, error: Throwable): Unit 
= {
+        // This thread holds the listener invocation, so it must not wait for 
itself.
+        context.markTaskCompleted(Some(error))
+        ranBeforeReturn.set(Some(completionListenerThread.get eq 
Thread.currentThread()))
+        // And it still holds the invocation, so another thread adding a 
listener does not run it.
+        joinThread(startDaemonThread("adder") {
+          context.addTaskCompletionListener[Unit] { _ =>
+            laterListenerThread.set(Thread.currentThread())
+          }
+        })
+      }
+    })
+    joinThread(startDaemonThread("failer")(context.markTaskFailed(new 
Exception("failed"))))
+    assert(ranBeforeReturn.get === Some(true))
+    assert(laterListenerThread.get == null)
+  }
+
+  test("SPARK-60222: a failure listener left to the completing thread runs 
before the next " +
+      "completion listener") {
+    val context = TaskContext.empty()
+    val invocations = new ConcurrentLinkedQueue[(String, Thread)]()
+    context.addTaskFailureListener(new TaskFailureListener {
+      override def onTaskFailure(context: TaskContext, error: Throwable): Unit 
= {
+        invocations.add(("failure", Thread.currentThread()))
+        throw new Exception("failure listener")
+      }
+    })
+    // E.g. a completion listener that releases a state store, which a failure 
listener aborts.
+    context.addTaskCompletionListener(loggingListener(invocations, "second"))
+    context.addTaskCompletionListener[Unit] { _ =>
+      invocations.add(("first", Thread.currentThread()))
+      // Another thread fails the task while this thread runs the completion 
listeners, e.g. a
+      // kill whose interrupt listener failed. It finds the listener 
invocation taken and leaves
+      // the failure listeners to this thread.
+      joinThread(startDaemonThread("failer") {
+        context.markTaskFailed(new Exception("failed elsewhere"))
+      })
+      assert(invocations.size == 1)
+    }
+
+    val error = intercept[TaskCompletionListenerException] {
+      context.markTaskCompleted(None)
+    }
+    val current = Thread.currentThread()
+    assert(invocations.asScala.toSeq ===
+      Seq(("first", current), ("failure", current), ("second", current)))
+    // The failure listener's failure is reported as its own, not wrapped in 
the exception of the
+    // failure listener invocation.
+    assert(error.getSuppressed.map(_.getMessage).toSeq === Seq("failure 
listener"))
+  }
+
+  test("SPARK-60222: a failure listener left to the thread the invocation was 
handed over to " +
+      "runs before its next completion listener") {
+    val context = TaskContext.empty()
+    val invocations = new ConcurrentLinkedQueue[(String, Thread)]()
+    context.addTaskCompletionListener(loggingListener(invocations, "second"))
+    context.addTaskCompletionListener[Unit] { _ =>
+      invocations.add(("first", Thread.currentThread()))
+      // The task has failed, so the added failure listener is to run right 
away. This thread
+      // holds the listener invocation, so the adding thread leaves it to this 
one.
+      joinThread(startDaemonThread("adder") {
+        context.addTaskFailureListener(new TaskFailureListener {
+          override def onTaskFailure(context: TaskContext, error: Throwable): 
Unit =
+            invocations.add(("failure", Thread.currentThread()))
+        })
+      })
+      assert(invocations.size == 1)
+    }
+    val (release, holder) = holdListenerInvocation(context)()
+    val completion = 
startDaemonThread("completion")(context.markTaskCompleted(None))
+    try {
+      awaitHandoverWait(completion)
+    } finally {
+      release.countDown()
+    }
+    joinThread(completion)
+    joinThread(holder)
+    assert(invocations.asScala.toSeq ===
+      Seq(("first", completion), ("failure", completion), ("second", 
completion)))
+  }
+
+  test("SPARK-60222: an exception whose getMessage throws does not end the 
listener loops") {
+    val messageFailure = new IllegalStateException("no message")
+    val current = Thread.currentThread()
+    // The failure listener invocation that a failing pre-completion or 
completion listener
+    // starts throws the failure of `getMessage`, while building its exception.
+    Seq(true, false).foreach { preCompletion =>
+      val context = TaskContext.empty()
+      val invocations = new ConcurrentLinkedQueue[(String, Thread)]()
+      addFailureListenerThrowing(context)(new 
MessageFailingException(messageFailure))
+      context.addTaskCompletionListener(loggingListener(invocations, "next"))
+      val failing = new TaskCompletionListener {
+        override def onTaskCompletion(context: TaskContext): Unit = throw new 
Exception("listener")
+      }
+      if (preCompletion) {
+        context.addTaskPreCompletionListener(failing)
+      } else {
+        context.addTaskCompletionListener(failing)
+      }
+      val error = intercept[TaskCompletionListenerException] {
+        context.markTaskCompleted(None)
+      }
+      assert(invocations.asScala.toSeq === Seq(("next", current)), 
s"preCompletion=$preCompletion")
+      assert(context.isCompleted())
+      val listenerFailure = error.getSuppressed.toSeq.head
+      assert(listenerFailure.getMessage === "listener")
+      assert(listenerFailure.getSuppressed.exists(_ eq messageFailure))
+    }
+
+    // Likewise for the failure listeners left to the completing thread.
+    val context = TaskContext.empty()
+    val invocations = new ConcurrentLinkedQueue[(String, Thread)]()
+    addFailureListenerThrowing(context)(new 
MessageFailingException(messageFailure))
+    context.addTaskCompletionListener(loggingListener(invocations, "second"))
+    context.addTaskCompletionListener[Unit] { _ =>
+      joinThread(startDaemonThread("failer") {
+        context.markTaskFailed(new Exception("failed elsewhere"))
+      })
+    }
+    val error = intercept[TaskCompletionListenerException] {
+      context.markTaskCompleted(None)
+    }
+    assert(invocations.asScala.toSeq === Seq(("second", current)))
+    assert(error.getSuppressed.toSeq === Seq(messageFailure))
+  }
+
+  test("SPARK-60222: an error that escapes the listener loop still releases 
the invocation") {
+    val context = TaskContext.empty()
+    context.markTaskCompleted(None)
+    val fatal = new LinkageError("no message")
+    addFailureListenerThrowing(context)(new MessageFailingException(fatal))
+    val error = intercept[LinkageError] {
+      // Runs right away on this thread and fails, and the failure listener 
invocation that this
+      // starts throws `fatal` while building its exception.
+      context.addTaskCompletionListener[Unit] { _ => throw new 
Exception("listener") }
+    }
+    assert(error eq fatal)
+    // Released: a listener added now runs right away on this thread.
+    val laterListenerThread = new AtomicReference[Thread]()
+    context.addTaskCompletionListener[Unit] { _ =>
+      laterListenerThread.set(Thread.currentThread())
+    }
+    assert(laterListenerThread.get eq Thread.currentThread())
+  }
+
+  test("SPARK-60222: a fatal error of a failure listener invocation is not 
hidden by the failure " +
+      "of the listener that started it") {
+    val fatal = new LinkageError("no message")
+    // Both for a failing completion listener and for a failing pre-completion 
listener.
+    Seq(true, false).foreach { preCompletion =>
+      val context = TaskContext.empty()
+      addFailureListenerThrowing(context)(new MessageFailingException(fatal))
+      val failing = new TaskCompletionListener {
+        override def onTaskCompletion(context: TaskContext): Unit = throw new 
Exception("listener")
+      }
+      if (preCompletion) {
+        context.addTaskPreCompletionListener(failing)
+      } else {
+        context.addTaskCompletionListener(failing)
+      }
+      // It propagates, so that the executor sees it, instead of being a 
suppressed exception of
+      // the listener's failure.
+      val error = intercept[LinkageError] {
+        context.markTaskCompleted(None)
+      }
+      assert(error eq fatal, s"preCompletion=$preCompletion")
+    }
+  }
+
+  test("SPARK-60222: an interrupt does not stop the handover wait and is 
kept") {
+    val context = TaskContext.empty()
+    val listenerThreads = new ConcurrentLinkedQueue[(String, Thread)]()
+    (1 to 2).foreach { i =>
+      context.addTaskCompletionListener(loggingListener(listenerThreads, 
s"completion $i"))
+    }
+    val (release, holder) = holdListenerInvocation(context)()
+    val interruptedAfterCompletion = new AtomicReference[Option[Boolean]](None)
+    val completion = startDaemonThread("completion") {
+      context.markTaskCompleted(None)
+      
interruptedAfterCompletion.set(Some(Thread.currentThread().isInterrupted))
+    }
+    try {
+      awaitHandoverWait(completion)
+      completion.interrupt()
+      // Still waiting for the holder, without having run any completion 
listener.
+      eventually(timeout(1.minute)) {
+        assert(!completion.isAlive ||
+          !completion.isInterrupted && completion.getState == 
Thread.State.TIMED_WAITING)
+      }
+      assert(completion.isAlive, "completion returned instead of waiting")
+      assert(listenerThreads.isEmpty)
+    } finally {
+      release.countDown()
+    }
+    joinThread(completion)
+    joinThread(holder)
+    assert(interruptedAfterCompletion.get === Some(true))
+    assert(listenerThreads.asScala.toSeq ===
+      Seq(("completion 2", completion), ("completion 1", completion)))
+  }
+
+  test("SPARK-60222: the handover wait warns with the holder's name and stack 
trace") {
+    val context = TaskContext.empty()
+    context.listenerHandoverWarnIntervalMs = 100
+    val (release, holder) = holdListenerInvocation(context)()
+    val logAppender = new LogAppender("listener handover wait")
+    var completion: DaemonThread = null
+    try {
+      withLogAppender(logAppender, Seq(classOf[TaskContextImpl].getName), 
Some(Level.WARN)) {
+        completion = 
startDaemonThread("completion")(context.markTaskCompleted(None))
+        eventually(timeout(1.minute)) {
+          assert(!completion.isAlive ||
+            logAppender.loggingEvents.count(_.getLevel == Level.WARN) >= 2)
+        }
+        assert(completion.isAlive, "completion returned instead of waiting")
+      }
+    } finally {
+      release.countDown()
+    }
+    joinThread(completion)
+    joinThread(holder)
+    val warnings = logAppender.loggingEvents.filter(_.getLevel == 
Level.WARN).toSeq
+    warnings.foreach { event =>
+      assert(event.getMessage.getFormattedMessage.contains("until thread 
listener-holder"))
+    }
+    // The first warning carries the holder's stack trace, blocked in its 
failure listener.
+    val holderStack = warnings.head.getThrown
+    assert(holderStack.getMessage.contains("listener-holder"))
+    assert(holderStack.getStackTrace.exists(_.getMethodName == 
"onTaskFailure"))
+    // The later ones do not walk the stack again.
+    warnings.tail.foreach { event => assert(event.getThrown == null) }
+  }
+
+  test("SPARK-60222: a failing handover warning does not end the wait") {

Review Comment:
   **4. [Nit] No test covers a failure to warn together with a failing 
completion listener**
   
   `TaskContextImpl.scala:312` adds the completion listeners' failure to the 
failure to warn. Neither test reaches it: the `pending` listener here does not 
fail, and "a failing handover warning is reported with the failures of the 
pre-completion listeners" has no completion listener. Without that catch both 
still pass, although the `TaskCompletionListenerException` would then skip 
`TaskContextImpl.scala:314` and the failure to warn would be lost. The 
description's list of what is not covered does not mention this.
   
   Could this test get a failing completion listener and check that 
`completionError` carries both failures? If item 2 changes that catch, an 
`OutOfMemoryError` variant would cover that too.



##########
core/src/main/scala/org/apache/spark/TaskContextImpl.scala:
##########
@@ -266,7 +358,10 @@ private[spark] class TaskContextImpl(
         try {
           markTaskFailed(e)
         } catch {
-          case t: Throwable => e.addSuppressed(t)
+          // Failures of the failure listeners, or e.g. of the `getMessage` of 
one of their
+          // exceptions, which is listener code too. A fatal error (e.g. an 
OutOfMemoryError)
+          // propagates, so that the executor still sees it.
+          case NonFatal(t) => e.addSuppressed(t)

Review Comment:
   **1. [Low] `NonFatal` lets a `LinkageError` end the listener loops, while 
the executor keeps running**
   
   This corrects my own suggestion. In [round-3 item 
1](https://github.com/apache/spark/pull/59663#discussion_r4419200062) I 
proposed `NonFatal`, and in [round-3 item 
7](https://github.com/apache/spark/pull/59663#discussion_r4419200095) I named a 
`LinkageError` as an error that still propagates. `NonFatal` does not match a 
`LinkageError`, but the executor does not count it as fatal either 
(`Utils.scala:1976-1987`, `Executor.scala:1654-1665`). So propagating it does 
not stop the executor, and it costs the listeners. On master, `case t: 
Throwable => e.addSuppressed(t)` kept these loops going.
   
   Take a task whose body succeeded. A pre-completion listener fails, and a 
failure listener throws an exception whose `getMessage` throws a 
`LinkageError`, e.g. a `NoClassDefFoundError`. "a fatal error of a failure 
listener invocation is not hidden by the failure of the listener that started 
it" does exactly this (`TaskContextSuite.scala:1522-1544`). Then:
   
   - The error passes this catch and ends the loop at L301-303 before L281 sets 
`completed`. `e` is neither recorded nor logged.
   - No completion listener runs, so their cleanup is skipped, e.g. the release 
of the state store stamp at `RocksDBStateStoreProvider.scala:189`.
   - `completed` stays false, so a listener added later is only queued.
   - The executor reports the failure and keeps running.
   
   The catches at L644 and L666 skip the remaining completion listeners in the 
same way. The test checks only which error comes out, not `isCompleted()` or 
the listeners.
   
   Could this catch use the executor's own test, and L322, L644 and L666 too? 
Then only an error that stops the executor ends the loops:
   
   ```scala
             case t if !Utils.isFatalError(t) => e.addSuppressed(t)
   ```
   
   The two tests that use a `LinkageError` would then need an error such as an 
`OutOfMemoryError`.
   
   Also, when the task body failed, `runTaskWithListeners` turns whatever 
`markTaskCompleted(Some(e))` throws into a suppressed exception of `e` 
(`TaskContext.scala:250-255`). So "so that the executor still sees it" at 
L318-319 holds only when the body succeeded.



##########
core/src/main/scala/org/apache/spark/TaskContextImpl.scala:
##########
@@ -346,10 +537,31 @@ 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 
leave the listeners
+   * they add, or whose invocation they start, meanwhile. Those in `listeners` 
run here like the
+   * others. If this is the completion listener invocation of 
`markTaskCompleted()`, the failure
+   * listeners left to it (the task has failed) run before the completion 
listeners that have
+   * not run yet, e.g. so that a failure listener that aborts a state store 
update runs before
+   * the completion listener that releases the state store, if that one has 
not run yet. This is
+   * the order of a failure listener invocation that a failing completion 
listener starts, and,
+   * likewise, the failures of these failure listeners are reported with those 
of the completion
+   * listeners. Any other invocation does not run the listeners of another 
kind left to it, since

Review Comment:
   **5. [Nit] "must not run" does not hold for the failure listeners that a 
failing listener starts**
   
   L551-553 say that any other invocation does not run the listeners left to 
it, since its thread may be one on which they must not run, e.g. a kill thread, 
or the task thread after `Task.run`. But when a listener in such an invocation 
throws, L639 runs the failure listeners on that very thread. A 
post-status-update listener that throws does this after the executor has 
released the task's block locks and memory, and so does a completion listener 
that fails on a kill thread that added it after completion.
   
   Master does the same, and I am not asking to change that. This is only about 
the wording, which [your reply on round-3 item 
6](https://github.com/apache/spark/pull/59663#discussion_r4421488978) relies 
on. Could the sentence say that it is about listeners that other threads leave, 
and that a failing listener on this thread still starts the failure listeners 
here (see `runListener`)?



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