dongjoon-hyun commented on code in PR #59332:
URL: https://github.com/apache/spark/pull/59332#discussion_r4247343518
##########
core/src/main/scala/org/apache/spark/api/python/PythonRunner.scala:
##########
@@ -183,6 +183,97 @@ 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.
+ */
+ 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)
Review Comment:
Putting this listener first also puts it ahead of the close listeners that
the writer thread registers lazily (a DSv2/JDBC/S3 reader opened on its first
pull). Before this PR those ran first (LIFO), and closing the stream was what
unblocked a writer stuck in a socket read that ignores `Thread.interrupt`.
Now `stopAndAwaitExit` waits without bound first, logging every 10s, and the
close listener never gets to run. Since the task reaper is off by default, the
task never completes and the executor slot is lost.
The UAF fix needs this ordering, so I'm not suggesting a revert. Could we at
least document this hang mode (and the reaper as the escape hatch) in the
scaladoc and the warning message, or add a configurable bound after which the
listener fails loudly?
##########
core/src/main/scala/org/apache/spark/TaskContextImpl.scala:
##########
@@ -177,29 +197,48 @@ private[spark] class TaskContextImpl(
}
private[spark] override def markTaskCompleted(error: Option[Throwable]):
Unit = {
- synchronized {
+ val claimedInvocation = synchronized {
if (completed) return
completed = true
+ // Claim the listener invocation together with setting `completed`,
unless another thread is
+ // already invoking listeners. Otherwise a thread that adds a completion
listener in between
+ // would see `completed` and run the listeners itself. That thread can
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.
+ if (listenerInvocationThread.isEmpty) {
Review Comment:
When another thread already holds the invocation, `claimedInvocation` is
`false`, so `invokeListeners` returns immediately. The holder only drains
`onFailureCallbacks`, which means neither the pre-completion stop listener nor
any completion listener runs.
For example, a kill arrives and an interrupt listener fails.
`markInterrupted` then calls `markTaskFailed` on the kill thread, which runs
the failure listeners. If the task thread reaches `markTaskCompleted(None)`
meanwhile, it returns without running anything. `Task.run` finishes, and the
executor's `cleanUpAllAllocatedMemory` frees the pages while the pipelined
writer may still be copying rows (SPARK-33277). If the writer adds a listener
later, it takes over and waits for itself.
This predates the PR, but the PR now depends on the stop listener always
running on the completing thread. Should `markTaskCompleted` wait for the
current holder and then run the completion stacks, or should the holder also
drain the completion stacks once `completed` is set?
##########
core/src/main/scala/org/apache/spark/TaskContextImpl.scala:
##########
@@ -267,13 +313,14 @@ 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.
+ listenerInvocationThread = None
Review Comment:
The claim in `markTaskCompleted` closes the takeover window at completion
start, but the failure path still opens one. When a listener throws, the catch
block (L370) sets `listenerInvocationThread = None` before `markTaskFailed(e)`
and only reclaims it in the `finally`.
Suppose a task has two pipelined writers, W1 and W2. W2's stop listener (or
any pre-completion listener registered after W1's) throws while W1 is still
running. If W1 then lazily adds a listener in that window, it sees `completed`
and an empty slot, becomes the invocation thread, and pops its own stop
listener. `stopAndAwaitExit` then waits on W1's own `exited` latch forever. The
task thread also keeps popping listeners at the same time, which breaks the
sequential guarantee.
Could the catch block keep this thread registered while it calls
`markTaskFailed` (e.g. let the failure invocation reuse the already-claimed
slot) instead of clearing it?
##########
core/src/main/scala/org/apache/spark/TaskContext.scala:
##########
@@ -147,6 +147,21 @@ abstract class TaskContext extends Serializable {
})
}
+ /**
+ * Adds a listener to be executed on task completion before every listener
added with
+ * `addTaskCompletionListener`, regardless of when or from which thread that
listener was
Review Comment:
"regardless of when or from which thread that listener was added" claims
more than the implementation does. A pre-completion listener added after
completion has started runs only before the *remaining* completion listeners.
The new test `a pre-completion listener added during completion runs next`
asserts `Seq("B", "P", "A")`, so `B` runs before `P`.
Maybe: "before every pending listener added with
`addTaskCompletionListener`. Completion listeners that have already run are not
affected." That makes it clear a pre-completion listener must be registered
before completion starts to protect everything.
##########
core/src/main/scala/org/apache/spark/TaskContextImpl.scala:
##########
@@ -128,6 +134,20 @@ private[spark] class TaskContextImpl(
this
}
+ override private[spark] def addTaskPreCompletionListener(
Review Comment:
nit: this copies `addTaskCompletionListener` with only the stack changed
(the comment even says "Same as `addTaskCompletionListener`"). A private helper
such as `addCompletionListenerTo(stack, listener)` used by both would keep the
add/invoke protocol in one place.
##########
core/src/test/scala/org/apache/spark/scheduler/TaskContextSuite.scala:
##########
@@ -757,6 +757,113 @@ 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. An ordinary
+ // completion listener added here 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()
+ assert(invocationOrder === Seq("B"))
+ }
+ })
+
+ context.markTaskCompleted(None)
+ assert(invocationOrder === Seq("B", "P", "A"))
+ }
+
+ test("SPARK-60113: a listener added as completion starts does not take over
the listeners") {
Review Comment:
This depends on the busy-spin landing in the few-instruction window between
`completed = true` and the claim, without forcing that interleaving. If the
`markTaskCompleted` claim is reverted, the adder almost always arrives after
the task thread has registered itself, so I expect all 500 iterations to still
pass.
Could the test force the interleaving deterministically, for example with a
`TaskContextImpl` test subclass or hook that blocks between setting `completed`
and invoking the listeners? A case for the failure-path window (inline comment
on L322) would also help.
##########
core/src/main/scala/org/apache/spark/api/python/PythonRunner.scala:
##########
@@ -1336,9 +1409,9 @@ private[spark] abstract class BasePythonRunner[IN, OUT](
// automatically closed by the JVM, which will cause Python to
receive
// EOF and the reader thread to get IOException.
Thread.currentThread().interrupt()
- case NonFatal(t) =>
+ case t: Throwable =>
Review Comment:
With `NonFatal(t)` widened to `Throwable`, a fatal error such as an OOM now
goes through `writer.setException` and `shutdownOutput` (both may allocate near
OOM) before it is rethrown. `PipelinedWriterTask` then logs it at ERROR, while
`ReaderIterator` rethrows the same error on the task thread, so one fatal error
is reported from two threads.
Is the `setException` path needed for fatal errors, or would rethrowing them
directly (as before) and letting `PipelinedWriterTask` log them be enough? If
the goal is to unblock the reader, a short note on the expected double report
would help.
##########
core/src/main/scala/org/apache/spark/api/python/PythonRunner.scala:
##########
@@ -183,6 +183,97 @@ 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.
+ */
+ 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)
+ 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)
+
+ override def run(): Unit = {
+ if (claimed.compareAndSet(false, true)) {
+ 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 {
+ exited.countDown()
+ }
+ }
+ }
+
+ 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
+ }
+ // Wait without a time bound. The completion listeners that run next
free memory that
+ // the writer may still access: even a writer blocked in upstream I/O
that ignores
+ // interrupts adds the row it gets next to the HybridRowQueue. Giving up
here would allow
+ // that use-after-free, and failing this listener would not stop those
listeners either.
+ // The price is that task completion is held for as long as such
upstream I/O blocks.
+ //
+ // An interrupt of this thread (e.g. a task kill) is not restored after
the wait: the kill
+ // is already recorded in the TaskContext, and the flag would make the
blocking channel
+ // and lock operations of the completion listeners that run next fail
and skip cleanup.
+ val startNs = System.nanoTime()
+ var done = false
+ while (!done) {
+ try {
+ done = exited.await(pipelinedWriterExitWarnIntervalMs,
TimeUnit.MILLISECONDS)
Review Comment:
`CountDownLatch.await` checks `Thread.interrupted()` before it checks the
count, so when the writer was claimed, a kill interrupt that is already set is
consumed here even if the writer has already exited. If the writer was never
claimed, the early `return` at L247 leaves the flag set.
As a result, the listeners that run next see the interrupt flag set or not
depending on a race. The comment above says the flag would make their blocking
channel and lock operations fail and skip cleanup, which is the case this is
trying to avoid.
If the intent is to never pass the interrupt on, `Thread.interrupted()`
could be called on the early-return path as well. Otherwise, could the comment
describe the actual behavior?
--
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]