dongjoon-hyun commented on code in PR #59663:
URL: https://github.com/apache/spark/pull/59663#discussion_r4419200062
##########
core/src/main/scala/org/apache/spark/TaskContextImpl.scala:
##########
@@ -266,7 +352,10 @@ private[spark] class TaskContextImpl(
try {
markTaskFailed(e)
} catch {
- case t: Throwable => e.addSuppressed(t)
+ // Failures of the failure listeners. Any other error comes from the
invocation itself
+ // (e.g. an OutOfMemoryError) and propagates, so that the executor
still sees a fatal
+ // one.
+ case t: TaskCompletionListenerException => e.addSuppressed(t)
Review Comment:
**1. [Low] Catching only `TaskCompletionListenerException` lets a non-fatal
error end the listener loops**
Round 2 narrowed this catch, the one in `runListener` (L631) and the one in
`runLeftFailureListeners` (L652), as [round-2 item
3](https://github.com/apache/spark/pull/59663#discussion_r4371913169) asked, so
that a fatal error from the nested invocation reaches the executor. They now
also let through non-fatal errors other than `TaskCompletionListenerException`,
and the nested invocation can throw one without any injected error: it builds
its exception with `listenerExceptions.map(_.getMessage)` (L690-691), and
`getMessage` is listener code. Logging such an exception is best effort, but
this call is not covered.
So when a failure listener throws an exception whose `getMessage` throws a
`RuntimeException`:
- Here, after a pre-completion listener failed, the error escapes before
L360 records `e` and before it is logged. It ends the loop at L299-301 before
`nextPreCompletionListener()` sets `completed` (L279), so no completion
listener runs, and one added later is only queued.
- In `runListener`, after a completion listener failed, the error escapes
the loop: the remaining completion listeners are skipped, and `e` is neither
logged nor reported.
- In `runLeftFailureListeners`, the same happens to the completion listeners
after it.
On master, `case t: Throwable => e.addSuppressed(t)` kept these loops going.
Since the comments say the point is to let fatal errors through, could these
catch `NonFatal` errors instead? That is `case NonFatal(t) =>
e.addSuppressed(t)` here and at L631, and `case NonFatal(t) =>
listenerExceptions += t` after the case at L652. An `OutOfMemoryError` would
still propagate.
##########
core/src/main/scala/org/apache/spark/TaskContextImpl.scala:
##########
@@ -449,6 +641,51 @@ 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. Any other error comes from that
invocation itself (e.g. an
+ // OutOfMemoryError) and propagates, through the release below.
+ case e: TaskCompletionListenerException => listenerExceptions ++=
e.getSuppressed.toSeq
+ }
+ }
+
+ // Chooses the next thing to do under the TaskContext lock, to be done
outside of it.
+ def nextStep(): Option[() => Unit] = synchronized {
+ if (completesTask && failureCauseOpt.isDefined &&
!onFailureCallbacks.empty()) {
Review Comment:
**3. [Nit] A failure listener left to the completing thread can run after
the completion listener that released its resource**
L536-538 and the description say that this lets a failure listener that
aborts a state store update run before the completion listener that releases
the state store. That holds only if that completion listener has not run yet:
this branch runs the left failure listeners before the next completion
listener, after the ones already popped. On master they were dropped. Now they
can run on a resource that is already released, and their failures go into what
`markTaskCompleted` throws.
For example, `RocksDBStateStore` registers both listeners when it opens a
store (`RocksDBStateStoreProvider.scala:175-195`). For a read-only store, the
completion listener calls `release()`
(`RocksDBStateStoreProvider.scala:179-180`), which sets `RELEASED`. If another
thread marks the task failed after that, the failure listener left here calls
`abort()` (`RocksDBStateStoreProvider.scala:194`), and `validateState(UPDATING,
ABORTED)` (`RocksDBStateStoreProvider.scala:740`) throws
`stateStoreOperationOutOfOrder` (`RocksDBStateStoreProvider.scala:119-122`).
After a kill, the task is reported as killed anyway. After a background
`markTaskFailed`, a task whose body succeeded now fails with that error as its
reason. The failure that caused it is not in the exception, which carries
`markTaskCompleted`'s `error` (L690-691), `None` here.
Could L536-538 and the description say "before the completion listeners that
have not run yet"? It may also be worth a sentence whether such late failures
should fail the completion, or carry `failureCauseOpt` as the previous error.
##########
core/src/main/scala/org/apache/spark/TaskContextImpl.scala:
##########
@@ -288,27 +377,120 @@ 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.
+ * set by a task kill) as the completion listeners would see it without the
wait.
+ *
+ * A failure to warn (e.g. an OutOfMemoryError while logging) does not end
the wait either, like
+ * in `PipelinedWriterTask.awaitExit()`: the other thread does not run the
completion listeners,
+ * and the task would release its memory with them pending. The first such
failure is returned,
+ * for `markTaskCompleted()` to throw once it has run the completion
listeners. The wait itself
+ * is not covered, so that a lasting failure does not spin.
+ */
+ private def awaitListenerInvocationHandover(currentThread: Thread):
Option[Throwable] = {
+ val startNs = System.nanoTime()
+ var interrupted = false
+ var warned = false
+ var handedOver = false
+ var failure: Option[Throwable] = None
+ try {
+ while (!handedOver) {
+ val holder = synchronized {
+ val deadlineNs = System.nanoTime() +
+ TimeUnit.MILLISECONDS.toNanos(math.max(1L,
listenerHandoverWarnIntervalMs))
+ var remainingNs = deadlineNs - System.nanoTime()
+ while (!listenerInvocationThread.contains(currentThread) &&
remainingNs > 0) {
+ try {
+ wait(math.max(1L, TimeUnit.NANOSECONDS.toMillis(remainingNs)))
+ } catch {
+ case _: InterruptedException => interrupted = true
+ }
+ remainingNs = deadlineNs - System.nanoTime()
+ }
+ handedOver = listenerInvocationThread.contains(currentThread)
+ if (handedOver) None else listenerInvocationThread
+ }
+ // Outside the lock: walking the stack of another thread stops it at a
safepoint, and
+ // logging may block, which would otherwise hold up every thread using
this TaskContext.
+ // The holder does not change during the wait, since it hands the
invocation to this
+ // thread, so its stack trace is attached to the first warning only.
+ holder match {
+ case Some(holder) =>
+ try {
+ warnStillWaiting(holder, startNs, withStackTrace = !warned)
+ warned = true
+ } catch {
+ case t: Throwable => failure = failure.orElse(Some(t))
Review Comment:
**4. [Nit] An `InterruptedException` from the warning is kept as the
failure, and the interrupt is lost**
This catch also takes an `InterruptedException`, e.g. from a custom appender
that blocks interruptibly. The wait then keeps it as the failure to throw at
the end, `interrupted` stays false, and L435-437 do not restore the interrupt
status, which that throw has cleared. The scaladoc says that this wait passes
interrupts on (L384), and that it handles a failure to warn like
`PipelinedWriterTask.awaitExit()` (L390-391). That method leaves
`InterruptedException` out of this catch (`PythonRunner.scala:401`) and handles
it as an interrupt (`PythonRunner.scala:419`). Maybe:
```scala
} catch {
case _: InterruptedException => interrupted = true
case t: Throwable => failure = failure.orElse(Some(t))
}
```
##########
core/src/main/scala/org/apache/spark/TaskContextImpl.scala:
##########
@@ -218,30 +243,91 @@ 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 does not run it (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 first, so it is the one thrown.
+ case e: Throwable if warningFailure.exists(_ ne e) =>
warningFailure.get.addSuppressed(e)
+ }
+ warningFailure.foreach(throw _)
Review Comment:
**2. [Low] A failure of the handover warning drops the pre-completion
listener failures**
Since round 2, the first failure to warn is thrown here once the completion
listeners have run, as [round-2 item
2](https://github.com/apache/spark/pull/59663#discussion_r4371913164) asked. It
is not a `TaskCompletionListenerException`, so it skips the catch at L313-318,
and the `throw` at L332-334 never runs. If a pre-completion listener failed,
`markTaskCompleted` then throws only the logging failure. The pre-completion
failures are in no exception, and the completion listeners' failures are only a
suppressed exception of the logging failure (L309). `runTaskWithListeners` then
fails the task with the appender's exception as its reason, although its
listeners failed first. That also contradicts L308: a pre-completion failure
comes before the failure to warn.
Could L311 add the failure to `preCompletionExceptions` when that is not
empty, so that L332-334 reports it together with the listener failures?
##########
core/src/main/scala/org/apache/spark/TaskContextImpl.scala:
##########
@@ -125,17 +140,19 @@ private[spark] class TaskContextImpl(
override def addTaskCompletionListener(listener: TaskCompletionListener):
this.type = {
val needToCallListener = synchronized {
- // If there is already a thread invoking listeners, adding the new
listener to
- // `onCompleteCallbacks` will cause that thread to execute the new
listener, and the call to
- // `invokeTaskCompletionListeners()` below will be a no-op.
+ // If there is already a thread invoking listeners, the call to
+ // `invokeTaskCompletionListeners()` below will be a no-op. If that
thread runs the completion
+ // listeners in `markTaskCompleted()`, or hands the invocation over to a
thread waiting to do
+ // so, the new listener runs there. Otherwise it is not run until a
listener is added while no
Review Comment:
**5. [Nit] Three comments say that a holder does not run the listeners added
meanwhile, but it runs those of its own kind**
The holder keeps popping its own stack, so it also runs the listeners of
that kind that other threads add while it runs:
- L146-147: if the holder is a thread that added a completion listener after
`markTaskCompleted()` returned, it runs the new listener too. "until a listener
is added" also needs to say "a completion listener": adding a failure listener
runs only the failure listeners.
- L254-256: likewise, "that thread does not run it" holds only if that
thread is not running completion listeners.
- L534-541: a failure listener invocation on a kill thread runs the failure
listeners added meanwhile, on the kill thread. "Any other invocation does not
run the listeners left to it" holds only for the other kinds.
The test name "listeners added after completion are not run by another
thread invoking listeners" (`TaskContextSuite.scala:1581`) is just as broad,
while its invoker runs post-status-update listeners. Maybe "... are not run by
a thread invoking other listeners".
##########
core/src/main/scala/org/apache/spark/TaskContextImpl.scala:
##########
@@ -199,11 +216,19 @@ private[spark] class TaskContextImpl(
}
private[spark] override def markTaskFailed(error: Throwable): Unit = {
+ markTaskFailed(error, Claim)
Review Comment:
**6. [Nit] `markTaskFailed` called on the holder's thread defers the failure
listeners, while a throwing listener runs them right away**
A listener that fails by throwing gets the failure listeners run nested,
before the next listener (L627). A listener that calls `markTaskFailed` itself
takes this `Claim` path instead, finds its own thread registered and returns
(L573-576). `Utils.tryWithSafeFinallyAndFailureCallbacks` does that right
before its `catchBlock` (`Utils.scala:1331-1334`). The failure listeners then
run only after that listener has returned, and only in the completion listener
invocation of `markTaskCompleted()`. In any other invocation, e.g. of
post-status-update listeners or of a completion listener added after
completion, they never run.
Could `markTaskFailed(error)` use `Nested` when this thread is the
registered one? A failure listener that calls it finds the task failed already,
so that changes only the cases above.
```scala
private[spark] override def markTaskFailed(error: Throwable): Unit = {
val mode = synchronized {
if (listenerInvocationThread.contains(Thread.currentThread())) Nested
else Claim
}
markTaskFailed(error, mode)
}
```
##########
core/src/test/scala/org/apache/spark/shuffle/streaming/StreamingShuffleSuite.scala:
##########
@@ -1372,68 +1372,85 @@ 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)
- // Latches to force the exact interleaving that triggers the race.
+ // Latches to force the interleaving.
val failureListenerEntered = new CountDownLatch(1)
val failureListenerProceed = new CountDownLatch(1)
- @volatile var completionListenerRan = false
+ @volatile var completionListenerThread: Thread = null
- // Add a failure listener that blocks, holding the
listenerInvocationThread guard.
+ // Add a failure listener that blocks, holding the listener invocation.
readerContext.addTaskFailureListener(new
org.apache.spark.util.TaskFailureListener {
override def onTaskFailure(context: TaskContext, error: Throwable): Unit
= {
failureListenerEntered.countDown()
- failureListenerProceed.await()
+ failureListenerProceed.await(1, TimeUnit.MINUTES)
}
})
// Add a completion listener -- stands in for cleanupResources() in
production.
readerContext.addTaskCompletionListener[Unit] { _ =>
- completionListenerRan = true
+ completionListenerThread = Thread.currentThread()
}
val error = new RuntimeException("background discovery failure")
- // Background thread calls markTaskFailed -> enters invokeListeners ->
acquires
- // listenerInvocationThread guard -> blocks in the failure listener above.
+ // Background thread calls markTaskFailed -> runs the failure listeners ->
holds the listener
+ // invocation while it blocks in the failure listener above.
val bgThread = new Thread(() => {
TaskContext.setTaskContext(readerContext)
readerContext.markTaskFailed(error)
})
+ bgThread.setDaemon(true)
Review Comment:
**8. [Nit] `bgThread` does not record what it throws**
Since [round-2 item
8](https://github.com/apache/spark/pull/59663#discussion_r4371913186),
`taskThread` records its failure (L1425, L1447), but the holder does not.
Suppose its release broke after the handover, e.g. with `notifyAll()` outside
the lock. `taskThread` would still get the invocation once its `wait` times
out, and the test would pass, while the exception of `bgThread` only reaches
the default handler. Could it record its failure like `taskThread`?
##########
core/src/main/scala/org/apache/spark/TaskContextImpl.scala:
##########
@@ -288,27 +377,120 @@ 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.
+ * set by a task kill) as the completion listeners would see it without the
wait.
+ *
+ * A failure to warn (e.g. an OutOfMemoryError while logging) does not end
the wait either, like
+ * in `PipelinedWriterTask.awaitExit()`: the other thread does not run the
completion listeners,
+ * and the task would release its memory with them pending. The first such
failure is returned,
+ * for `markTaskCompleted()` to throw once it has run the completion
listeners. The wait itself
+ * is not covered, so that a lasting failure does not spin.
+ */
+ private def awaitListenerInvocationHandover(currentThread: Thread):
Option[Throwable] = {
+ val startNs = System.nanoTime()
+ var interrupted = false
+ var warned = false
+ var handedOver = false
+ var failure: Option[Throwable] = None
+ try {
+ while (!handedOver) {
+ val holder = synchronized {
+ val deadlineNs = System.nanoTime() +
+ TimeUnit.MILLISECONDS.toNanos(math.max(1L,
listenerHandoverWarnIntervalMs))
+ var remainingNs = deadlineNs - System.nanoTime()
+ while (!listenerInvocationThread.contains(currentThread) &&
remainingNs > 0) {
+ try {
+ wait(math.max(1L, TimeUnit.NANOSECONDS.toMillis(remainingNs)))
+ } catch {
+ case _: InterruptedException => interrupted = true
+ }
+ remainingNs = deadlineNs - System.nanoTime()
+ }
+ handedOver = listenerInvocationThread.contains(currentThread)
+ if (handedOver) None else listenerInvocationThread
+ }
+ // Outside the lock: walking the stack of another thread stops it at a
safepoint, and
+ // logging may block, which would otherwise hold up every thread using
this TaskContext.
+ // The holder does not change during the wait, since it hands the
invocation to this
+ // thread, so its stack trace is attached to the first warning only.
+ holder match {
+ case Some(holder) =>
+ try {
+ warnStillWaiting(holder, startNs, withStackTrace = !warned)
Review Comment:
**9. [Nit] While the warning keeps failing, each interval walks the holder's
stack again**
`warned` is set only when `warnStillWaiting` returns. With an appender that
keeps failing, every interval takes the holder's stack again, which stops the
holder at a safepoint, and tries to log it again, unlike what L421-422 say.
`PipelinedWriterTask.awaitExit` does the same (`PythonRunner.scala:398-399`).
Setting `warned` before the call would keep it to one stack walk:
```scala
val withStackTrace = !warned
warned = true
warnStillWaiting(holder, startNs, withStackTrace)
```
##########
core/src/test/scala/org/apache/spark/scheduler/TaskContextSuite.scala:
##########
@@ -1195,19 +1192,428 @@ 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")
+ assert(thread.error == null, s"${thread.getName} failed: ${thread.error}")
Review Comment:
**11. [Nit] `joinThread` reports a thread's failure without its stack trace**
The clue formats `thread.error` with `toString`, so a failure on one of
these threads, e.g. an assertion inside a listener, shows only its message, not
where it was thrown. Maybe:
```scala
Option(thread.error).foreach { e =>
throw new AssertionError(s"${thread.getName} failed", e)
}
```
##########
core/src/main/scala/org/apache/spark/TaskContextImpl.scala:
##########
@@ -288,27 +377,120 @@ 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.
+ * set by a task kill) as the completion listeners would see it without the
wait.
+ *
+ * A failure to warn (e.g. an OutOfMemoryError while logging) does not end
the wait either, like
+ * in `PipelinedWriterTask.awaitExit()`: the other thread does not run the
completion listeners,
+ * and the task would release its memory with them pending. The first such
failure is returned,
+ * for `markTaskCompleted()` to throw once it has run the completion
listeners. The wait itself
+ * is not covered, so that a lasting failure does not spin.
+ */
+ private def awaitListenerInvocationHandover(currentThread: Thread):
Option[Throwable] = {
+ val startNs = System.nanoTime()
+ var interrupted = false
+ var warned = false
+ var handedOver = false
+ var failure: Option[Throwable] = None
+ try {
+ while (!handedOver) {
+ val holder = synchronized {
+ val deadlineNs = System.nanoTime() +
+ TimeUnit.MILLISECONDS.toNanos(math.max(1L,
listenerHandoverWarnIntervalMs))
+ var remainingNs = deadlineNs - System.nanoTime()
+ while (!listenerInvocationThread.contains(currentThread) &&
remainingNs > 0) {
+ try {
+ wait(math.max(1L, TimeUnit.NANOSECONDS.toMillis(remainingNs)))
+ } catch {
+ case _: InterruptedException => interrupted = true
+ }
+ remainingNs = deadlineNs - System.nanoTime()
+ }
+ handedOver = listenerInvocationThread.contains(currentThread)
+ if (handedOver) None else listenerInvocationThread
+ }
+ // Outside the lock: walking the stack of another thread stops it at a
safepoint, and
+ // logging may block, which would otherwise hold up every thread using
this TaskContext.
+ // The holder does not change during the wait, since it hands the
invocation to this
+ // thread, so its stack trace is attached to the first warning only.
+ holder match {
+ case Some(holder) =>
+ try {
+ warnStillWaiting(holder, startNs, withStackTrace = !warned)
+ warned = true
+ } catch {
+ case t: Throwable => failure = failure.orElse(Some(t))
+ }
+ case None =>
+ }
+ }
+ } finally {
+ if (interrupted) {
+ currentThread.interrupt()
+ }
+ }
+ failure
+ }
+
+ private def warnStillWaiting(holder: Thread, startNs: Long, withStackTrace:
Boolean): Unit = {
Review Comment:
**10. [Nit] `warnStillWaiting` repeats
`PipelinedWriterTask.warnStillWaiting`**
Both take the other thread's stack outside the lock, check that it is still
the thread waited for, and attach the stack to the first warning as a `new
Exception` with `setStackTrace` (`PythonRunner.scala:430-448`). The wait loops
around them follow the same pattern too. They already differ in how they handle
an `InterruptedException` (item 4). A shared helper, e.g. one in `Utils` that
turns a thread's stack into a throwable for logging, would keep them in step.
##########
core/src/main/scala/org/apache/spark/TaskContextImpl.scala:
##########
@@ -449,6 +641,51 @@ 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. Any other error comes from that
invocation itself (e.g. an
+ // OutOfMemoryError) and propagates, through the release below.
+ case e: TaskCompletionListenerException => listenerExceptions ++=
e.getSuppressed.toSeq
+ }
+ }
+
+ // Chooses the next thing to do under the TaskContext lock, to be done
outside of it.
+ def nextStep(): Option[() => Unit] = synchronized {
+ if (completesTask && failureCauseOpt.isDefined &&
!onFailureCallbacks.empty()) {
+ val cause = failureCauseOpt.get
+ Some(() => runLeftFailureListeners(cause))
+ } else if (!listeners.empty()) {
+ val listener = listeners.pop()
+ Some(() => runListener(listener))
+ } else {
+ // We have executed all listeners that have been added so far.
Deregister this thread as
+ // the callback invocation thread, or hand the invocation over to a
waiting thread.
+ if (mode != Nested) {
+ releaseListenerInvocation()
+ }
+ None
+ }
+ }
+
+ try {
+ var step: Option[() => Unit] = None
+ while ({step = nextStep(); step.nonEmpty}) {
+ step.get()
+ }
+ released = true
+ } finally {
+ if (!released) {
+ // Something escaped the loop above. Do not leave this thread
registered, which would
Review Comment:
**7. [Nit] Two of the removed escape-path tests can be kept without
injecting an error**
Your [latest
comment](https://github.com/apache/spark/pull/59663#issuecomment-6510238497)
and the description say that only an error from the invocation itself, e.g. an
`OutOfMemoryError`, reaches this release now. A listener exception whose
`getMessage` throws reaches it too (item 1). A completion listener fails first,
a failure listener in the nested invocation throws such an exception, and
L690-691 throw from the nested invocation. That brings back "an error that
escapes the listener loop still releases the invocation" and "an error of a
failure listener invocation is not hidden by the failure of the listener that
started it". With `NonFatal` (item 1), a `getMessage` that throws a
`LinkageError` still does. "an error that escapes the holder's listener loop
hands the invocation over" stays out of reach, since L627 is a no-op in a
failure listener invocation.
--
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]