viirya commented on code in PR #59332:
URL: https://github.com/apache/spark/pull/59332#discussion_r4262581044
##########
core/src/main/scala/org/apache/spark/TaskContextImpl.scala:
##########
@@ -320,21 +430,44 @@ private[spark] class TaskContextImpl(
// [[markTaskFailed]] here will have no effect and we simply
resume running the
// remaining completion handlers.
try {
- listenerInvocationThread = None
- markTaskFailed(e)
+ markTaskFailed(e, Nested)
} catch {
case t: Throwable => e.addSuppressed(t)
- } finally {
- synchronized {
- if (listenerInvocationThread.isEmpty) {
- listenerInvocationThread = Some(Thread.currentThread())
- }
- }
}
listenerExceptions += e
logError(log"Error in ${MDC(LISTENER, name)}", e)
}
}
+ if (mode != Nested) {
+ // We have executed all listeners of `listenerStacks` that have been
added so far. Run the
+ // listeners left to this thread (see above), then deregister this
thread as the callback
+ // invocation thread, or hand the invocation over to a thread waiting
for it.
+ var released = false
+ while (!released) {
+ val pendingListeners: Option[() => Unit] = synchronized {
+ val pendingFailure = failureCauseOpt.filter(_ =>
!onFailureCallbacks.empty())
+ if (nextListenerInvocationThread.isEmpty &&
pendingFailure.isDefined) {
+ Some(() => invokeTaskFailureListeners(pendingFailure.get, Nested))
+ } else if (nextListenerInvocationThread.isEmpty && completed &&
+ (!onPreCompleteCallbacks.empty() ||
!onCompleteCallbacks.empty())) {
+ Some(() => invokeTaskCompletionListeners(None, Nested))
Review Comment:
Moved to SPARK-60222. I'll document there which listeners run on which
thread, and look at keeping leftover completion listeners off a thread that
holds the global `TaskContext` lock.
##########
core/src/main/scala/org/apache/spark/api/python/PythonRunner.scala:
##########
@@ -183,6 +183,180 @@ private[spark] object BasePythonRunner extends Logging {
ThreadUtils.newDaemonCachedThreadPool("python-udf-pipelined-writer")
}
+ /** How often to log while task completion waits for a pipelined writer to
exit. */
+ private val pipelinedWriterExitWarnIntervalMs = 10000L
+
+ /**
+ * Runs `writer` on [[pipelinedWriterThreadPool]] and registers a task
pre-completion listener
+ * that interrupts it and blocks until it has exited.
+ *
+ * Task completion listeners free memory that the writer may still read or
write, e.g. the
+ * pages of the HybridRowQueue that buffers the input rows, or the off-heap
column vectors of a
+ * vectorized reader. If the writer were still running at that point, it
would be a
+ * use-after-free that can crash the executor (SPARK-33277). The listener is
added with
+ * `addTaskPreCompletionListener` so that it runs before all of them,
including the ones that
+ * the writer thread adds lazily while it pulls the upstream iterator, which
would run first if
+ * this were an ordinary completion listener. `Future.get()` cannot be used
for this wait:
+ * once `cancel(true)` succeeds it throws `CancellationException`
immediately, without waiting
+ * for the interrupted runnable to return.
+ *
+ * The wait has no time bound, so a writer blocked in upstream I/O that
ignores interrupts
+ * (e.g. a socket read without a timeout) holds task completion until that
I/O returns. This
+ * includes I/O that only a close listener of the upstream reader would
unblock, since that
+ * listener now runs after the wait. If such a task is killed,
`spark.task.reaper.enabled` with
+ * a positive `spark.task.reaper.killTimeout` bounds how long it may stay in
this state, by
+ * killing the executor JVM.
+ */
+ private[python] def startPipelinedWriter(writer: Runnable, context:
TaskContext): Unit = {
+ val writerTask = new PipelinedWriterTask(writer, taskIdentifier(context))
+ val writerFuture = pipelinedWriterThreadPool.submit(writerTask)
+ context.addTaskPreCompletionListener(new TaskCompletionListener {
+ override def onTaskCompletion(context: TaskContext): Unit =
+ writerTask.stopAndAwaitExit(writerFuture)
+ })
+ }
+
+ /** The state of a [[PipelinedWriterTask]]. */
+ private sealed trait PipelinedWriterState
+ private case object PipelinedWriterNotStarted extends PipelinedWriterState
+ private case class PipelinedWriterRunning(thread: Thread) extends
PipelinedWriterState
+ /** The writer has exited, or was stopped before it started and will never
run. */
+ private case object PipelinedWriterFinished extends PipelinedWriterState
+
+ /**
+ * Wraps a pipelined writer so that task completion can wait until the
writer has either
+ * exited or is guaranteed never to run.
+ */
+ private[python] class PipelinedWriterTask(
+ writer: Runnable,
+ taskName: String,
+ warnIntervalMs: Long = pipelinedWriterExitWarnIntervalMs)
+ extends Runnable {
+ // Protected by `this`. NotStarted moves to Running when run() starts the
writer, or to
+ // Finished when stopAndAwaitExit() gets there first; Running moves to
Finished when the
+ // writer exits. `exited` is counted down whenever the state becomes
Finished.
+ private var state: PipelinedWriterState = PipelinedWriterNotStarted
+ private val exited = new CountDownLatch(1)
+
+ private def finish(): Unit = synchronized {
+ state = PipelinedWriterFinished
+ exited.countDown()
+ }
+
+ override def run(): Unit = {
+ val start = synchronized {
+ if (state == PipelinedWriterNotStarted) {
+ state = PipelinedWriterRunning(Thread.currentThread())
+ true
+ } else {
+ false
+ }
+ }
+ if (start) {
+ try {
+ writer.run()
+ } catch {
+ case t: Throwable =>
+ // The thread pool's FutureTask would keep this to itself, and the
result of a
+ // cancelled future cannot be retrieved, so log it here.
+ logError(log"Pipelined Python writer of ${MDC(TASK_NAME,
taskName)} failed", t)
+ throw t
+ } finally {
+ finish()
+ }
+ }
+ }
+
+ /** Interrupts the writer thread, but only while it still runs the writer.
*/
+ private def interruptWriter(): Unit = synchronized {
+ state match {
+ case PipelinedWriterRunning(thread) => thread.interrupt()
+ case _ =>
+ }
+ }
+
+ /**
+ * Stops the writer and waits until it has exited, or makes sure it never
runs. Leaves the
+ * interrupt status of the calling thread as it was on entry.
+ */
+ def stopAndAwaitExit(future: Future[_]): Unit = {
+ val wasInterrupted = Thread.interrupted()
+ try {
+ // Interrupts the writer thread if the writer is running. This
unblocks channel.write
+ // (the JDK closes the channel and throws ClosedByInterruptException).
Otherwise the
+ // writer sees the interrupt only between calls to
writeNextInputToStream, each of which
+ // can pull and serialize a whole batch of input rows (e.g. for the
Arrow runners).
+ future.cancel(true)
Review Comment:
Done in 00da9fb69d4. `stopAndAwaitExit` no longer takes the future: it
interrupts the `PipelinedWriterRunning` thread under the lock, and skips that
when it runs on the writer thread itself. `startPipelinedWriter` no longer
keeps the future, and the tests no longer need `FutureTask`.
##########
core/src/main/scala/org/apache/spark/TaskContextImpl.scala:
##########
@@ -169,37 +196,113 @@ private[spark] class TaskContextImpl(
}
private[spark] override def markTaskFailed(error: Throwable): Unit = {
+ markTaskFailed(error, Claim)
+ }
+
+ /**
+ * @param mode `Nested` if this thread is already invoking listeners, i.e. a
listener failed. The
+ * failure listeners then run as part of that invocation, which
this thread keeps.
+ */
+ private def markTaskFailed(error: Throwable, mode: InvocationMode): Unit = {
synchronized {
if (failureCauseOpt.isDefined) return
failureCauseOpt = Some(error)
}
- invokeTaskFailureListeners(error)
+ invokeTaskFailureListeners(error, mode)
}
private[spark] override def markTaskCompleted(error: Option[Throwable]):
Unit = {
- synchronized {
+ // The completion listeners always run on this thread, and this method
returns only after they
+ // have run. If any other thread could run them, it could be one the
listeners are meant to
+ // stop and wait for, e.g. a pipelined Python UDF writer that adds
listeners lazily while it
+ // pulls its input, which would then wait for itself. And if this method
returned before they
+ // ran, the task could release its memory while that writer still uses it.
So the listener
+ // invocation is claimed together with setting `completed`; if another
thread holds it (e.g. a
+ // kill thread running failure listeners), that thread hands it over to
this one when done.
+ //
+ // That wait has no time bound: if the other thread's listeners block, so
does this task. It
+ // logs a warning with that thread's stack trace periodically. If such a
task is killed,
+ // `spark.task.reaper.enabled` with a positive
`spark.task.reaper.killTimeout` bounds how long
+ // it may stay in this state, by killing the executor JVM, but only if the
blocked listeners
+ // do not run within the reaper's own kill call: the reaper calls
`Task.kill` before it starts
+ // checking the timeout, and that call runs the failure listeners on the
reaper thread if an
+ // interrupt listener throws. Then only the periodic warning shows the
hang.
+ val mode = synchronized {
if (completed) return
completed = true
+ val currentThread = Thread.currentThread()
+ if (listenerInvocationThread.isEmpty) {
+ listenerInvocationThread = Some(currentThread)
+ Claimed
+ } else if (listenerInvocationThread.contains(currentThread)) {
+ // Called from a listener on this thread: run the completion listeners
as part of that
+ // invocation.
+ Nested
+ } else {
+ nextListenerInvocationThread = Some(currentThread)
+ awaitListenerInvocationHandover(currentThread)
+ Claimed
+ }
+ }
+ invokeTaskCompletionListeners(error, mode)
+ }
+
+ /**
+ * Waits until another thread invoking listeners hands the invocation over
to `currentThread`.
+ * Interrupts do not stop the wait and are restored afterwards. Must hold
the TaskContext lock.
+ */
+ private def awaitListenerInvocationHandover(currentThread: Thread): Unit = {
+ val startNs = System.nanoTime()
+ var nextWarningNs = startNs +
TimeUnit.MILLISECONDS.toNanos(listenerHandoverWarnIntervalMs)
+ var interrupted = false
+ while (!listenerInvocationThread.contains(currentThread)) {
+ try {
+ wait(math.max(1L, TimeUnit.NANOSECONDS.toMillis(nextWarningNs -
System.nanoTime())))
+ } catch {
+ case _: InterruptedException => interrupted = true
+ }
+ val nowNs = System.nanoTime()
+ if (nowNs >= nextWarningNs &&
!listenerInvocationThread.contains(currentThread)) {
+ listenerInvocationThread.foreach { holder =>
+ val holderStack = new Exception(s"Stack trace of thread
${holder.getName}")
+ holderStack.setStackTrace(holder.getStackTrace)
Review Comment:
Moved to SPARK-60222 with the handover warning. I'll take the stack outside
the lock there, and attach it only to the first warning.
##########
core/src/test/scala/org/apache/spark/scheduler/TaskContextSuite.scala:
##########
@@ -757,6 +760,254 @@ class TaskContextSuite extends SparkFunSuite with
BeforeAndAfter with LocalSpark
assert(invocationOrder === Seq("C", "B", "A", "D"))
}
+ test("SPARK-60113: pre-completion listeners run before all completion
listeners") {
+ val context = TaskContext.empty()
+ val invocationOrder = ArrayBuffer.empty[String]
+
+ def makeLoggingListener(id: String): TaskCompletionListener = new
TaskCompletionListener {
+ override def onTaskCompletion(context: TaskContext): Unit = {
+ invocationOrder += id
+ }
+ }
+ context.addTaskCompletionListener(makeLoggingListener("A"))
+ context.addTaskPreCompletionListener(makeLoggingListener("P1"))
+ context.addTaskPreCompletionListener(makeLoggingListener("P2"))
+ // Added after the pre-completion listeners, e.g. lazily by the thread
they stop. Had P1 and
+ // P2 been added with addTaskCompletionListener, B would run before them.
+ context.addTaskCompletionListener(makeLoggingListener("B"))
+
+ context.markTaskCompleted(None)
+ assert(invocationOrder === Seq("P2", "P1", "B", "A"))
+ // Called immediately once the task has completed, like completion
listeners.
+ context.addTaskPreCompletionListener(makeLoggingListener("P3"))
+ assert(invocationOrder === Seq("P2", "P1", "B", "A", "P3"))
+ }
+
+ test("SPARK-60113: completion listeners run even if a pre-completion
listener fails") {
+ val context = TaskContext.empty()
+ val listener = mock(classOf[TaskCompletionListener])
+ context.addTaskCompletionListener(listener)
+ context.addTaskPreCompletionListener(new TaskCompletionListener {
+ override def onTaskCompletion(context: TaskContext): Unit = throw new
Exception("pre")
+ })
+
+ val e = intercept[TaskCompletionListenerException] {
+ context.markTaskCompleted(None)
+ }
+ assert(e.getMessage.contains("pre"))
+ verify(listener, times(1)).onTaskCompletion(any())
+ assert(context.isFailed())
+ }
+
+ test("SPARK-60113: failures of pre-completion and completion listeners are
both reported") {
+ val context = TaskContext.empty()
+ context.addTaskCompletionListener(new TaskCompletionListener {
+ override def onTaskCompletion(context: TaskContext): Unit = throw new
Exception("post")
+ })
+ context.addTaskPreCompletionListener(new TaskCompletionListener {
+ override def onTaskCompletion(context: TaskContext): Unit = throw new
Exception("pre")
+ })
+
+ val e = intercept[TaskCompletionListenerException] {
+ context.markTaskCompleted(None)
+ }
+ assert(e.getMessage.contains("pre"))
+ assert(e.getMessage.contains("post"))
+ }
+
+ test("SPARK-60113: a pre-completion listener added during completion runs
next") {
+ val context = TaskContext.empty()
+ val invocationOrder = ArrayBuffer.empty[String]
+
+ def makeLoggingListener(id: String): TaskCompletionListener = new
TaskCompletionListener {
+ override def onTaskCompletion(context: TaskContext): Unit = {
+ invocationOrder += id
+ }
+ }
+ context.addTaskCompletionListener(makeLoggingListener("A"))
+ context.addTaskCompletionListener(new TaskCompletionListener {
+ override def onTaskCompletion(context: TaskContext): Unit = {
+ invocationOrder += "B"
+ // Added from a second thread while this thread runs the completion
listeners, so the
+ // call returns without running it; this thread must still run it.
+ val thread = new Thread(() => {
+ context.addTaskPreCompletionListener(makeLoggingListener("P"))
+ })
+ thread.start()
+ thread.join()
Review Comment:
These tests moved to SPARK-60222 with the code they cover. I'll use daemon
threads and `join(timeout)` with `assert(!thread.isAlive)` there.
##########
core/src/main/scala/org/apache/spark/TaskContextImpl.scala:
##########
@@ -426,3 +559,21 @@ private[spark] class TaskContextImpl(
}
}
}
+
+private object TaskContextImpl {
+
+ /** How an `invokeListeners()` call takes part in the listener invocation. */
+ sealed trait InvocationMode
+
+ /** Registers this thread as `listenerInvocationThread` unless another
thread is registered. */
+ case object Claim extends InvocationMode
+
+ /** The caller has already registered this thread as
`listenerInvocationThread`. */
+ case object Claimed extends InvocationMode
+
+ /** An outer invocation on this thread has registered it, and keeps it when
this one is done. */
+ case object Nested extends InvocationMode
+
+ /** How often to log while `markTaskCompleted()` waits for another thread
invoking listeners. */
+ val listenerHandoverWarnIntervalMs = 10000L
Review Comment:
Moved to SPARK-60222. I'll make the interval configurable there and add a
test that checks the holder's name and stack.
##########
core/src/main/scala/org/apache/spark/TaskContextImpl.scala:
##########
@@ -169,37 +196,113 @@ private[spark] class TaskContextImpl(
}
private[spark] override def markTaskFailed(error: Throwable): Unit = {
+ markTaskFailed(error, Claim)
+ }
+
+ /**
+ * @param mode `Nested` if this thread is already invoking listeners, i.e. a
listener failed. The
+ * failure listeners then run as part of that invocation, which
this thread keeps.
+ */
+ private def markTaskFailed(error: Throwable, mode: InvocationMode): Unit = {
synchronized {
if (failureCauseOpt.isDefined) return
failureCauseOpt = Some(error)
}
- invokeTaskFailureListeners(error)
+ invokeTaskFailureListeners(error, mode)
}
private[spark] override def markTaskCompleted(error: Option[Throwable]):
Unit = {
- synchronized {
+ // The completion listeners always run on this thread, and this method
returns only after they
+ // have run. If any other thread could run them, it could be one the
listeners are meant to
+ // stop and wait for, e.g. a pipelined Python UDF writer that adds
listeners lazily while it
+ // pulls its input, which would then wait for itself. And if this method
returned before they
+ // ran, the task could release its memory while that writer still uses it.
So the listener
+ // invocation is claimed together with setting `completed`; if another
thread holds it (e.g. a
+ // kill thread running failure listeners), that thread hands it over to
this one when done.
+ //
+ // That wait has no time bound: if the other thread's listeners block, so
does this task. It
+ // logs a warning with that thread's stack trace periodically. If such a
task is killed,
+ // `spark.task.reaper.enabled` with a positive
`spark.task.reaper.killTimeout` bounds how long
+ // it may stay in this state, by killing the executor JVM, but only if the
blocked listeners
+ // do not run within the reaper's own kill call: the reaper calls
`Task.kill` before it starts
+ // checking the timeout, and that call runs the failure listeners on the
reaper thread if an
+ // interrupt listener throws. Then only the periodic warning shows the
hang.
+ val mode = synchronized {
if (completed) return
completed = true
+ val currentThread = Thread.currentThread()
+ if (listenerInvocationThread.isEmpty) {
+ listenerInvocationThread = Some(currentThread)
+ Claimed
+ } else if (listenerInvocationThread.contains(currentThread)) {
+ // Called from a listener on this thread: run the completion listeners
as part of that
+ // invocation.
+ Nested
+ } else {
+ nextListenerInvocationThread = Some(currentThread)
+ awaitListenerInvocationHandover(currentThread)
+ Claimed
+ }
+ }
+ invokeTaskCompletionListeners(error, mode)
+ }
+
+ /**
+ * Waits until another thread invoking listeners hands the invocation over
to `currentThread`.
+ * Interrupts do not stop the wait and are restored afterwards. Must hold
the TaskContext lock.
+ */
+ private def awaitListenerInvocationHandover(currentThread: Thread): Unit = {
+ val startNs = System.nanoTime()
+ var nextWarningNs = startNs +
TimeUnit.MILLISECONDS.toNanos(listenerHandoverWarnIntervalMs)
+ var interrupted = false
+ while (!listenerInvocationThread.contains(currentThread)) {
+ try {
+ wait(math.max(1L, TimeUnit.NANOSECONDS.toMillis(nextWarningNs -
System.nanoTime())))
+ } catch {
+ case _: InterruptedException => interrupted = true
Review Comment:
Thanks. The handover wait moved to SPARK-60222, and I'll add that test
there: interrupt the completing thread while it waits, then check that the wait
continues, that the interrupt is kept, and that all completion listeners run on
the completing thread.
##########
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:
Thanks. The release loop moved to SPARK-60222, and I'll add a test there
with a deferred listener that throws. It will check that the invocation is
still released (or handed over) and that the failure reaches the caller.
--
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]