HyukjinKwon commented on code in PR #59332:
URL: https://github.com/apache/spark/pull/59332#discussion_r4246906867
##########
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 = {
Review Comment:
**Non-blocking (P2):** The new claim in `markTaskCompleted` closes the
window from the last round when the slot is empty. When another thread already
holds `listenerInvocationThread`, though, the task thread still returns without
running any completion listener, and whichever thread adds a listener next
inherits them. That can happen when `Task.kill` runs `markInterrupted` on the
kill thread and a `TaskInterruptListener` throws (`addTaskInterruptListener` is
public), so `markTaskFailed` runs the failure listeners on that thread. It can
also happen when a failure listener is added from another thread after the task
failed. If the pipelined writer, which nobody has stopped yet, then lazily adds
a completion listener (e.g. `ParquetReaderCallback.initIfNotAlready`), it
claims the slot and pops this pre-completion listener first. `cancel(true)`
interrupts the writer itself, the `claimed` CAS fails, and the loop waits on
`exited`, which only the `finally` of `run()` further up the same
stack can count down. The pool thread and the Python worker leak permanently,
and `worker.stop()`, the `HybridRowQueue` close, and the reader closes never
run. The old `cancel(true)` + `get()` returned at once on this path. The
listener-failure handoff you mentioned ends in the same wait if it is ever hit.
**Recommended change:** In PipelinedWriterTask, remember the thread that
claimed and is running the writer. In stopAndAwaitExit, after cancel(true), if
the current thread is that writer thread, log that task completion cleanup is
running on the writer thread and return without waiting, because the writer can
only exit after this listener returns. Add Python runner unit-test coverage
that invokes the stop listener on the writer's own thread while the writer is
still running.
**Why this works:** On the writer thread, the latch can only be counted down
by run()'s finally block further up the same stack, so waiting there can never
succeed. Skipping the wait on that one thread restores the merge-target
behavior for this handoff: the listener returns, the remaining cleanup runs,
and the writer exits on its self-interrupt. Every other thread keeps the
unbounded wait.
**Scope:** Add a same-thread guard to the pipelined writer stop listener and
a regression test for it.
**Compatibility:** The unbounded wait from the task thread and the
run()/stopAndAwaitExit claim protocol stay as they are.
**Risks:** On the self-invocation path, the remaining completion listeners
still run on the writer thread while it is in the middle of pulling input, as
in the merge target. The guard prevents the hang; it does not add a
use-after-free safety net for that narrow path.
**Constraints:** Keep the unbounded, interrupt-ignoring wait unchanged for
every thread other than the writer. Keep the claim protocol between run() and
stopAndAwaitExit, including the never-started case.
**Success:** stopAndAwaitExit never blocks when it is invoked on the thread
that is running this writer. Invoked from any other thread, it still interrupts
the writer and returns only after the writer has exited or is guaranteed never
to run.
**Verification:**
- **Regression:** Invoking the stop listener on the writer's own thread
while that writer is still running returns instead of hanging, and the writer
then finishes and counts down its exit latch.
- **Behavior:** Task completion from another thread still waits for a
running writer, skips a writer that never started, and waits for a writer
claimed after the interrupt.
##########
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:
**Nit (P3):** With this change, a fatal error goes through `setException`,
`shutdownOutput()`, and the rethrow below, but no test runs this branch. `a
fatal error of the pipelined writer is logged` wraps a bare `Runnable` in
`PipelinedWriterTask`, so narrowing this back to `NonFatal(t)`, or dropping the
rethrow, would keep every test green. The reader would then again wait on
Python with no cause recorded, which is the hang raised earlier in review.
**Verification:**
- **Regression:** With the fatal-error branch narrowed back to NonFatal, or
with the rethrow removed, the new coverage fails.
##########
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
Review Comment:
**Nit (P3):** `B` is an ordinary completion listener added at this exact
point, and the assertion below expects it to run after `P2` and `P1`. Read
literally, this sentence says the opposite of what the test checks. Something
like: "Had P1 and P2 been added with addTaskCompletionListener, B would run
before them."
**Verification:**
- **Inspection:** The comment names the condition (P1 and P2 being ordinary
completion listeners) under which B would run before them.
##########
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:
Thanks, I checked 038c4b3420e. `markTaskCompleted` now takes
`listenerInvocationThread` in the same `synchronized` block that sets
`completed`, only that entry point pre-claims, and the new `TaskContextSuite`
and `BasePythonRunnerSuite` tests cover a listener added just as completion
starts and one added by the writer after completion has reached its stop
listener. I agree the listener-failure handoff you describe is fine to leave:
it needs a pre-completion listener that throws, and the stop listener does not
throw in practice.
<!-- SPARK_DEV_REVIEW_REPLY
{"feedback_id":"inline:4242065501","thread_id":"inline:4242065501","verdict_sha256":"6704d95e924fc29e87f8cb36a03662f4adde76842bc4b448ca5b970a0c19ca82"}
-->
--
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]