viirya commented on code in PR #59332:
URL: https://github.com/apache/spark/pull/59332#discussion_r4252748737
##########
core/src/main/scala/org/apache/spark/TaskContextImpl.scala:
##########
@@ -169,37 +193,86 @@ private[spark] class TaskContextImpl(
}
private[spark] override def markTaskFailed(error: Throwable): Unit = {
+ markTaskFailed(error, nested = false)
+ }
+
+ /**
+ * @param nested whether 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, nested: Boolean): Unit = {
synchronized {
if (failureCauseOpt.isDefined) return
failureCauseOpt = Some(error)
}
- invokeTaskFailureListeners(error)
+ invokeTaskFailureListeners(error, nested)
}
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.
+ val nested = synchronized {
if (completed) return
completed = true
+ val currentThread = Thread.currentThread()
+ if (listenerInvocationThread.isEmpty) {
+ listenerInvocationThread = Some(currentThread)
+ false
+ } else if (listenerInvocationThread.contains(currentThread)) {
+ // Called from a listener on this thread: run the completion listeners
as part of that
+ // invocation.
+ true
+ } else {
+ nextListenerInvocationThread = Some(currentThread)
+ var interrupted = false
+ while (!listenerInvocationThread.contains(currentThread)) {
Review Comment:
Done in 7ab7ae2e6af. The handover wait now uses `wait(timeout)` and logs a
warning every 10 seconds while it lasts. The warning names the thread holding
the invocation and includes that thread's current stack trace, so a blocked
failure listener shows up in the log. As before, it has no time bound, and an
interrupt that arrives during it is restored afterwards. The comment and the
scaladoc now point at `spark.task.reaper.enabled` with a positive
`spark.task.reaper.killTimeout` as what bounds it for a killed task.
##########
core/src/main/scala/org/apache/spark/api/python/PythonRunner.scala:
##########
@@ -183,6 +183,148 @@ 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`
+ * bounds how long it may stay in this state.
+ */
+ 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)
+ })
+ }
+
+ /**
+ * 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 {
+ // Set by whichever of run() and stopAndAwaitExit() gets here first. If
run() wins, the
+ // writer runs and stopAndAwaitExit() waits for it; otherwise run() skips
the writer.
+ private val claimed = new AtomicBoolean(false)
+ private val exited = new CountDownLatch(1)
+ // The thread running the writer, while it does. Protected by `this`, so
that it is only
+ // interrupted while it still runs the writer, not after it has moved on
to other work.
+ private var writerThread: Thread = _
+
+ override def run(): Unit = {
+ if (claimed.compareAndSet(false, true)) {
+ synchronized {
+ writerThread = Thread.currentThread()
+ }
+ 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 {
+ synchronized {
+ writerThread = null
+ }
+ exited.countDown()
+ }
+ }
+ }
+
+ private def isWriterThread: Boolean = synchronized {
+ writerThread eq Thread.currentThread()
+ }
+
+ private def interruptWriter(): Unit = synchronized {
+ if (writerThread != null) {
+ writerThread.interrupt()
+ }
+ }
+
+ def stopAndAwaitExit(future: Future[_]): Unit = {
+ // 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)
+ if (claimed.compareAndSet(false, true)) {
+ // The writer never started and now never will.
+ return
+ }
+ if (isWriterThread) {
Review Comment:
You're right: on that thread `cancel(true)` only interrupts itself, and the
writer keeps going once the remaining listeners return. Changed as you
suggested. That path now logs at ERROR, saying that it may lead to a
use-after-free. `stopAndAwaitExit` clears the interrupt that `cancel(true)` set
on the thread, so the remaining listeners can clean up. The comment states that
reaching this path means the `TaskContextImpl` invariant is broken, and that
the writer is not stopped before the remaining listeners run. The test now runs
the writer through a `FutureTask`, so that `cancel(true)` really interrupts the
writer thread, and it checks that the flag is cleared when the call returns.
##########
core/src/test/scala/org/apache/spark/api/python/BasePythonRunnerSuite.scala:
##########
@@ -50,6 +62,355 @@ class BasePythonRunnerSuite extends SparkFunSuite {
}
}
+ /**
+ * A writer that, once started, runs `onStart` and then keeps running until
`release` is
+ * counted down, ignoring interrupts the way a writer in the middle of
copying a row into a
+ * HybridRowQueue page does.
+ */
+ private class UninterruptibleWriter(
+ onStart: UninterruptibleWriter => Unit = _ => ()) extends Runnable {
+ val started = new CountDownLatch(1)
+ val release = new CountDownLatch(1)
+ @volatile var exited = false
+
+ override def run(): Unit = {
+ try {
+ onStart(this)
+ started.countDown()
+ awaitIgnoringInterrupts(release)
+ } finally {
+ exited = true
+ }
+ }
+ }
+
+ /** Waits for `latch`, ignoring interrupts like a writer in the middle of a
row copy. */
+ private def awaitIgnoringInterrupts(latch: CountDownLatch): Unit = {
+ var done = false
+ while (!done) {
+ try {
+ done = latch.await(1, TimeUnit.MINUTES)
+ } catch {
+ case _: InterruptedException => // keep going
+ }
+ }
+ }
+
+ /** Adds a completion listener that records whether `writer` had exited when
it ran. */
+ private def addCleanupListener(
+ context: TaskContext,
+ writer: UninterruptibleWriter): AtomicReference[Option[Boolean]] = {
+ val exitedBeforeCleanup = new AtomicReference[Option[Boolean]](None)
+ context.addTaskCompletionListener[Unit] { _ =>
exitedBeforeCleanup.set(Some(writer.exited)) }
+ exitedBeforeCleanup
+ }
+
+ /**
+ * Adds a pre-completion listener that runs right before the one added by
+ * `startPipelinedWriter`, so that the test knows task completion has
reached the writer.
+ */
+ private def addReachedListener(context: TaskContext): CountDownLatch = {
+ val reached = new CountDownLatch(1)
+ context.addTaskPreCompletionListener(new TaskCompletionListener {
+ override def onTaskCompletion(context: TaskContext): Unit =
reached.countDown()
+ })
+ reached
+ }
+
+ private def startThread(body: => Unit): Thread = {
+ val thread = new Thread(() => body)
+ thread.setDaemon(true)
+ thread.start()
+ thread
+ }
+
+ test("SPARK-60113: task completion waits for the pipelined writer to exit") {
+ val context = TaskContext.empty()
+ @volatile var cleanupAddedByWriter: AtomicReference[Option[Boolean]] = null
+ val writer = new UninterruptibleWriter(self => {
+ // Like an upstream reader that adds its close listener when the writer
first pulls it.
+ cleanupAddedByWriter = addCleanupListener(context, self)
+ })
+ // Like the listener that closes the HybridRowQueue, added on the task
thread before the
+ // writer starts.
+ val cleanupAddedBefore = addCleanupListener(context, writer)
+ BasePythonRunner.startPipelinedWriter(writer, context)
+ assert(writer.started.await(1, TimeUnit.MINUTES))
+ val reached = addReachedListener(context)
+
+ val completion = startThread(context.markTaskCompleted(None))
+ try {
+ assert(reached.await(1, TimeUnit.MINUTES))
+ // Task completion must stay blocked while the writer is still running.
+ completion.join(500)
+ assert(completion.isAlive)
+ assert(cleanupAddedBefore.get.isEmpty)
+ assert(cleanupAddedByWriter.get.isEmpty)
+ } finally {
+ writer.release.countDown()
+ }
+ completion.join(TimeUnit.MINUTES.toMillis(1))
+ assert(!completion.isAlive)
+ assert(cleanupAddedBefore.get === Some(true))
+ assert(cleanupAddedByWriter.get === Some(true))
+ }
+
+ test("SPARK-60113: a listener the writer adds once completion has started
waits for it") {
+ val context = TaskContext.empty()
+ val addNow = new CountDownLatch(1)
+ val added = new CountDownLatch(1)
+ // Whether the writer had exited, and the thread, when the listener added
by the writer ran.
+ val lateCleanup = new AtomicReference[Option[(Boolean, Thread)]](None)
+ val writerStarted = new CountDownLatch(1)
+ val release = new CountDownLatch(1)
+ // Like a writer that adds a listener when it pulls more input after
completion has started.
+ val lateWriter: Runnable = new Runnable {
+ @volatile var exited = false
+ override def run(): Unit = {
+ try {
+ writerStarted.countDown()
+ awaitIgnoringInterrupts(addNow)
+ context.addTaskCompletionListener[Unit] { _ =>
+ lateCleanup.set(Some((exited, Thread.currentThread())))
+ }
+ added.countDown()
+ awaitIgnoringInterrupts(release)
+ } finally {
+ exited = true
+ }
+ }
+ }
+ BasePythonRunner.startPipelinedWriter(lateWriter, context)
+ assert(writerStarted.await(1, TimeUnit.MINUTES))
+ val reached = addReachedListener(context)
+
+ val completion = startThread(context.markTaskCompleted(None))
+ try {
+ assert(reached.await(1, TimeUnit.MINUTES))
+ addNow.countDown()
+ assert(added.await(1, TimeUnit.MINUTES))
+ // The writer thread only queued the listener: task completion still
waits for the writer.
+ completion.join(500)
+ assert(completion.isAlive)
+ assert(lateCleanup.get.isEmpty)
+ } finally {
+ release.countDown()
+ }
+ completion.join(TimeUnit.MINUTES.toMillis(1))
+ assert(!completion.isAlive)
+ assert(lateCleanup.get === Some((true, completion)))
+ }
+
+ test("SPARK-60113: an interrupt does not stop the wait and is not passed to
later listeners") {
+ val context = TaskContext.empty()
+ val writer = new UninterruptibleWriter
+ val cleanup = addCleanupListener(context, writer)
+ val interruptedInCleanup = new AtomicReference[Option[Boolean]](None)
+ context.addTaskCompletionListener[Unit] { _ =>
+ interruptedInCleanup.set(Some(Thread.currentThread().isInterrupted))
+ }
+ BasePythonRunner.startPipelinedWriter(writer, context)
+ assert(writer.started.await(1, TimeUnit.MINUTES))
+ val reached = addReachedListener(context)
+
+ val completion = startThread(context.markTaskCompleted(None))
+ try {
+ assert(reached.await(1, TimeUnit.MINUTES))
+ // Give the completion thread time to block in the wait, then interrupt
it, e.g. a task
+ // kill that lands while the listeners run.
+ completion.join(200)
Review Comment:
Thanks, changed to wait with `eventually` until the completion thread is in
`TIMED_WAITING` before interrupting it.
##########
core/src/main/scala/org/apache/spark/TaskContextImpl.scala:
##########
@@ -267,13 +352,19 @@ private[spark] class TaskContextImpl(
}
def getNextListenerOrDeregisterThread(): Option[T] = synchronized {
- if (listeners.empty()) {
- // We have executed all listeners that have been added so far.
Deregister this thread as the
- // callback invocation thread.
- listenerInvocationThread = None
- None
- } else {
- Some(listeners.pop())
+ listenerStacks.find(!_.empty()) match {
+ case Some(listeners) =>
+ Some(listeners.pop())
+ case None =>
+ // We have executed all listeners that have been added so far.
Deregister this thread as
+ // the callback invocation thread, unless an outer invocation on
this thread continues. A
+ // thread waiting in `markTaskCompleted()` takes over directly.
+ if (!nested) {
Review Comment:
Fixed. Before a thread releases the listener invocation, it now also runs
the listeners that other threads queued in the meantime and left to it. Those
are the failure listeners if the task has failed, and the completion listeners
if the task has completed. They run in the nested mode, so the invocation stays
with this thread until nothing is pending. If a thread is waiting in
`markTaskCompleted`, the invocation is handed over to it instead, and it runs
whatever is pending when it releases. This also covers the reverse case:
completion listeners queued while a failure-listener thread holds the
invocation after the task has completed. New tests cover both directions.
##########
core/src/main/scala/org/apache/spark/api/python/PythonRunner.scala:
##########
@@ -183,6 +183,148 @@ 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`
Review Comment:
Thanks, corrected in both places (`startPipelinedWriter` and
`markTaskCompleted`). They now say that `spark.task.reaper.enabled` with a
positive `spark.task.reaper.killTimeout` bounds how long a killed task may stay
in this state, by killing the executor JVM.
##########
core/src/main/scala/org/apache/spark/TaskContextImpl.scala:
##########
@@ -169,37 +193,86 @@ private[spark] class TaskContextImpl(
}
private[spark] override def markTaskFailed(error: Throwable): Unit = {
+ markTaskFailed(error, nested = false)
+ }
+
+ /**
+ * @param nested whether 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, nested: Boolean): Unit = {
synchronized {
if (failureCauseOpt.isDefined) return
failureCauseOpt = Some(error)
}
- invokeTaskFailureListeners(error)
+ invokeTaskFailureListeners(error, nested)
}
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.
Review Comment:
Updated both. The `ErrorNotifier` doc now says that calling `markTaskFailed`
from a background thread runs the failure listeners on that thread, and that a
concurrent `markTaskCompleted` on the task thread then blocks until they finish
before it runs the completion listeners. The
`StreamingShuffleReader.checkTaskFailure` comment now says that surfacing the
error on the task thread lets the failure and completion listeners run there
without waiting for another thread. I left out the dropped-failure-listener
hazard, since the change for L362 removes it.
##########
core/src/main/scala/org/apache/spark/TaskContextImpl.scala:
##########
@@ -239,10 +312,22 @@ private[spark] class TaskContextImpl(
}
}
+ /**
+ * Invokes and removes the listeners in `listenerStacks`, each in reverse
order of registration.
+ * A listener is always taken from the first non-empty stack, so all
listeners of a stack run
+ * before those of the stacks after it that are still pending.
+ *
+ * @param claimed whether the caller has already registered this thread as
+ * `listenerInvocationThread`.
+ * @param nested whether that registration belongs to an outer invocation on
this thread, which
+ * keeps it when this one is done. Implies `claimed`.
+ */
private def invokeListeners[T](
- listeners: Stack[T],
+ listenerStacks: Seq[Stack[T]],
name: String,
- error: Option[Throwable])(
+ error: Option[Throwable],
+ claimed: Boolean = false,
Review Comment:
Done: replaced them with a single `InvocationMode` (`Claim`, `Claimed`,
`Nested`) in a private `TaskContextImpl` companion object.
##########
core/src/main/scala/org/apache/spark/api/python/PythonRunner.scala:
##########
@@ -183,6 +183,148 @@ 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`
+ * bounds how long it may stay in this state.
+ */
+ 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)
+ })
+ }
+
+ /**
+ * 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 {
+ // Set by whichever of run() and stopAndAwaitExit() gets here first. If
run() wins, the
+ // writer runs and stopAndAwaitExit() waits for it; otherwise run() skips
the writer.
+ private val claimed = new AtomicBoolean(false)
Review Comment:
Done. The `AtomicBoolean`, the latch-only completion and the separate
`writerThread` are replaced by a single state guarded by `this`: `NotStarted`,
`Running(thread)`, or `Finished`. The latch is counted down on every transition
to `Finished`, including when the stop listener gets there before the writer
starts. A second `stopAndAwaitExit` therefore returns at once, and the test now
calls it twice.
--
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]