dongjoon-hyun commented on code in PR #59332:
URL: https://github.com/apache/spark/pull/59332#discussion_r4264575990
##########
core/src/main/scala/org/apache/spark/api/python/PythonRunner.scala:
##########
@@ -183,6 +183,182 @@ 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 `Future.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))
+ pipelinedWriterThreadPool.submit(writerTask)
+ context.addTaskPreCompletionListener(new TaskCompletionListener {
+ override def onTaskCompletion(context: TaskContext): Unit =
writerTask.stopAndAwaitExit()
+ })
+ }
+
+ /** 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, 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(): Unit = {
+ val wasInterrupted = Thread.interrupted()
+ try {
+ val runningThread = synchronized {
+ state match {
+ case PipelinedWriterNotStarted =>
+ // The writer never started and now never will.
+ finish()
+ None
+ case PipelinedWriterRunning(thread) =>
+ // Interrupts the writer thread. 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).
+ if (thread ne Thread.currentThread()) {
+ thread.interrupt()
Review Comment:
**1. [High] The stop interrupt can free the Arrow writer's output buffer
while the writer is copying into it**
When this interrupt lands while an Arrow writer is serializing a batch, the
JDK closes the writer's `DataOutputStream`, and with it the
`DirectByteBufferOutputStream`, on this thread, while the writer thread is
still copying into that buffer:
- The Arrow inputs write `dataOut` through `Channels.newChannel(dataOut)`:
`new ArrowStreamWriter(root, null, dataOut)` (`PythonArrowInput.scala` L103)
and `new WriteChannel(Channels.newChannel(dataOut))` (L144, L238, and
`ColumnarArrowPythonInput.scala` L125, L148). In pipelined mode, `dataOut` is
`new DataOutputStream(new DirectByteBufferOutputStream(bufferSize))`
(L1470-L1471 in this file).
- `Channels.newChannel(OutputStream)` returns `WritableByteChannelImpl`, an
`AbstractInterruptibleChannel`. Its `write` wraps each chunk of up to 8 KiB in
`begin(); out.write(...); end()`, and its `implCloseChannel()` is `out.close()`.
- `Thread.interrupt()` on a thread that is between `begin()` and `end()`
runs the channel's interruptor on the calling thread. Here that is
`dataOut.close()` -> `DirectByteBufferOutputStream.close()` ->
`StorageUtils.dispose(buffer)`, which frees the native memory right away.
`FilterOutputStream.close()` does not take the `DataOutputStream` monitor, so
the synchronized `write` does not keep it out.
- Meanwhile the writer is in `buffer.put(b, off, len)`, or in `grow()`
copying the old buffer, and writes to freed memory. Only after that does
`end()` throw `ClosedByInterruptException`.
This happens before `awaitExit()` starts, so the invariant this PR
establishes does not hold for the writer's own buffer: the executor can crash,
or memory that another allocation reused can be corrupted silently. A single
stop has a small chance, since the interrupt has to land during a chunk copy,
but any early completion of an Arrow UDF while its writer is busy serializing
can hit it, and the repeated interrupt at L271 adds more chances. I checked
each step against the JDK 17/21 sources and the Arrow 19.0.0 bytecode.
It predates this PR: the old `cancel(true)` interrupted the same thread. The
sync path has the same exposure when `Task.kill` interrupts a task thread that
is serializing in `ReaderInputStream`. Could the writer get a stream whose
`close()` does not free the buffer? For example, wrap `bufferStream` in a
`FilterOutputStream` that forwards `write(b, off, len)` and ignores `close()`,
and free the buffer only in the writer's own `finally`, which already calls
`bufferStream.close()`. The writer still stops right after the current chunk,
since `end()` throws `ClosedByInterruptException` either way.
##########
core/src/main/scala/org/apache/spark/api/python/PythonRunner.scala:
##########
@@ -1329,16 +1487,16 @@ private[spark] abstract class BasePythonRunner[IN, OUT](
}
} catch {
case _: InterruptedException =>
- // Task cancelled via Future.cancel(true)
+ // Interrupted by the task completion listener that stops the writer.
Thread.currentThread().interrupt()
case _: java.nio.channels.ClosedByInterruptException =>
// Task cancelled while blocked in channel.write(). The channel is
// 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:
**3. [Low] A fatal error before this `try` still leaves the reader waiting
for Python**
This catch does not cover the four statements before the `try`
(L1468-L1471): `TaskContext.setTaskContext`,
`InputFileBlockHolder.setThreadLocalValue`, `new
DirectByteBufferOutputStream(bufferSize)` and `new DataOutputStream(...)`. If
the direct buffer allocation fails (`Platform.allocateDirectBuffer` ->
`Unsafe.allocateMemory`), the `OutOfMemoryError` skips `writer.setException`,
`shutdownOutput()` and the `finally`. `PipelinedWriterTask` logs it, and the
discarded `FutureTask` swallows it. The Python worker then waits for its init
message, and the reader finds `writer.exception` empty and blocks in
`readInt()` (the idle timeout is 0 by default) until the task is killed. The
thread-locals also stay set on the pool thread.
It is rare (a 64 KiB allocation), and the order predates this PR, but item 6
of the description says that fatal writer errors are now passed to the reader.
Could the four statements move into the `try`, with a nullable `bufferStream`
that the `finally` closes only if it was created?
##########
core/src/main/scala/org/apache/spark/api/python/PythonRunner.scala:
##########
@@ -183,6 +183,182 @@ 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 `Future.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))
+ pipelinedWriterThreadPool.submit(writerTask)
+ context.addTaskPreCompletionListener(new TaskCompletionListener {
+ override def onTaskCompletion(context: TaskContext): Unit =
writerTask.stopAndAwaitExit()
+ })
+ }
+
+ /** 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, 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(): Unit = {
+ val wasInterrupted = Thread.interrupted()
+ try {
+ val runningThread = synchronized {
+ state match {
+ case PipelinedWriterNotStarted =>
+ // The writer never started and now never will.
+ finish()
+ None
+ case PipelinedWriterRunning(thread) =>
+ // Interrupts the writer thread. 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).
+ if (thread ne Thread.currentThread()) {
Review Comment:
**6. [Low] No test fails if this interrupt, or the `Thread.interrupted()` in
the `finally`, is removed**
Deleting L294-L296, or inverting the guard, keeps every test green:
- "the writer is interrupted again if it consumed the interrupt" uses
`warnIntervalMs = 100`, so two warning-tick interrupts at about 100 ms and 200
ms satisfy `interrupts.get >= 2`.
- The `running` writer in "an interrupt pending before stopping the writer
is kept" exits at the first 10 s tick, within its 1-minute join.
In production, every task that completes early while its writer is blocked
in `channel.write` would then hold completion for 10 s and log a warning. The
same goes for the unconditional `Thread.interrupted()` at L318: round 4's
same-thread test pinned it through the `FutureTask` self-interrupt, and nothing
replaced that test.
"stopping waits for a writer that started after the interrupt" (L252) sets
the flag before `run()` claims the writer, which production no longer does: the
pool clears the flag before each task, and nothing cancels the future. It could
pin this interrupt instead, with a writer that exits on interrupt, a long
`warnIntervalMs` (e.g. 10 minutes), and an assertion that stopping returns
within the 1-minute join. For L318, a deterministic test could hold the monitor
of a `PipelinedWriterTask` that never started, start a stopper, wait until it
is `BLOCKED`, interrupt it, and release the monitor: the stopper must return
with the flag cleared.
##########
core/src/main/scala/org/apache/spark/TaskContextImpl.scala:
##########
@@ -177,29 +194,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:
**8. [Discussion] Running the pre-completion listeners before `completed` is
set would close the deferred writer paths here**
Instead of claiming the invocation here and popping the pre-completion stack
first in `invokeListeners`, `markTaskCompleted` could run the pre-completion
listeners on this thread *before* it sets `completed`, outside
`listenerInvocationThread`, as `invokeTaskInterruptListeners` already does.
Then it would set `completed` and invoke the completion listeners as it does
now.
While the stop listener waits, the writer's lazy `add*Listener` calls see
`completed == false` and only push, so the writer can never become the invoker.
And the writer has exited before any completion listener runs, and before
`Task.run` reaches `cleanUpAllAllocatedMemory`, even when another thread holds
the invocation. That closes all three writer paths that the description defers
to SPARK-60222: another holder at completion, the catch-block window, and the
same-thread branch. A task without pre-completion listeners behaves exactly as
on master, so this touches fewer tasks than the claim, which applies to every
task.
It is not less code. Since `completed` is set later, the method needs its
own "completion started" flag, and failures of pre-completion listeners have to
be merged into the `TaskCompletionListenerException`. `isCompleted()` is false
during the wait; I checked `Writer.open`, `MonitorThread`,
`BlockManager.releaseLock` and `StreamingShuffleWriter`, and none of them is
harmed. The 500-iteration claim test would also go. What do you think?
##########
core/src/main/scala/org/apache/spark/BarrierTaskContext.scala:
##########
@@ -208,6 +208,12 @@ class BarrierTaskContext private[spark] (
this
}
+ override private[spark] def addTaskPreCompletionListener(
Review Comment:
**9. [Low] No test covers this delegation, and dropping it would silently
restore the LIFO order in barrier stages**
If this override were removed, `TaskContext`'s default (`TaskContext.scala`
L164-L165) would forward to `addTaskCompletionListener`, so everything would
compile and all tests would pass, while barrier stages get the use-after-free
back. Pipelined mode does run with a `BarrierTaskContext`:
`mapInPandas`/`mapInArrow(..., barrier=True)` goes through `MapInBatchExec`
(`child.execute().barrier()`) with
`SQL_MAP_PANDAS_ITER_UDF`/`SQL_MAP_ARROW_ITER_UDF`, which `usePipelined` does
not exclude.
Could `BarrierTaskContextSuite` get a `local[2]` barrier job that adds a
pre-completion listener and then a completion listener and checks their order?
Alternatively, declaring `addTaskPreCompletionListener` abstract in
`TaskContext`, like `addPostStatusUpdateListener` (SPARK-57491), would make it
impossible for a wrapper to miss it.
##########
core/src/main/scala/org/apache/spark/api/python/PythonRunner.scala:
##########
@@ -183,6 +183,182 @@ 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:
Review Comment:
**12. [Nit] This sentence still justifies the latch with
`Future.cancel(true)`, which the code no longer calls**
Since `00da9fb69d4`, the future from `submit` is discarded (L212) and
nothing cancels it, and an uncancelled `FutureTask.get()` does wait for `run()`
to return. So "`Future.get()` cannot be used for this wait" no longer holds as
written, and it could even read as a reason to go back to the future. The
actual reasons are that a `Future` exposes neither the thread to interrupt
(directly, and again on every tick) nor the thread to compare with the current
one, and that a stop before the pool starts the task should not wait for a pool
thread.
Maybe: "The listener does not use the `Future` returned by the pool:
interrupting the writer through it takes `Future.cancel(true)`, after which
`Future.get()` throws `CancellationException` immediately instead of waiting
for the runnable to return. `PipelinedWriterTask` records the thread running
the writer to interrupt it directly, and counts down a latch when the writer
exits or is stopped before it starts."
##########
core/src/test/scala/org/apache/spark/api/python/BasePythonRunnerSuite.scala:
##########
@@ -50,6 +62,356 @@ 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))
+ // Interrupt the completion thread once it blocks in the wait, e.g. a
task kill that lands
+ // while the listeners run.
+ eventually(timeout(1.minute)) {
+ assert(completion.getState == Thread.State.TIMED_WAITING)
+ }
+ completion.interrupt()
+ completion.join(500)
+ assert(completion.isAlive)
+ assert(cleanup.get.isEmpty)
+ } finally {
+ writer.release.countDown()
+ }
+ completion.join(TimeUnit.MINUTES.toMillis(1))
+ assert(!completion.isAlive)
+ assert(cleanup.get === Some(true))
+ assert(interruptedInCleanup.get === Some(false))
+ }
+
+ test("SPARK-60113: a pipelined writer stopped before it starts never runs") {
+ val writer = new UninterruptibleWriter
+ val writerTask = new BasePythonRunner.PipelinedWriterTask(writer, "test
task")
+ // The pool has not started the writer yet: stopping must not wait for it.
+ writerTask.stopAndAwaitExit()
+ // Stopping again does not wait either.
+ writerTask.stopAndAwaitExit()
+ // Even if the pool thread reaches the task afterwards, the writer must
not run. Release it
+ // up front so that a writer that wrongly runs fails the test instead of
hanging it.
+ writer.release.countDown()
+ writerTask.run()
+ assert(writer.started.getCount === 1)
+ assert(!writer.exited)
+ }
+
+ test("SPARK-60113: stopping waits for a writer that started after the
interrupt") {
+ // The writer starts with the interrupt flag of its thread already set,
and ignores it.
+ val writer = new UninterruptibleWriter
+ val writerTask = new BasePythonRunner.PipelinedWriterTask(writer, "test
task")
+ val writerThread = startThread {
+ Thread.currentThread().interrupt()
+ writerTask.run()
+ }
+ assert(writer.started.await(1, TimeUnit.MINUTES))
+
+ val stopper = startThread(writerTask.stopAndAwaitExit())
+ try {
+ stopper.join(500)
+ assert(stopper.isAlive)
+ assert(!writer.exited)
+ } finally {
+ writer.release.countDown()
+ }
+ stopper.join(TimeUnit.MINUTES.toMillis(1))
+ assert(!stopper.isAlive)
+ assert(writer.exited)
+ writerThread.join(TimeUnit.MINUTES.toMillis(1))
+ }
+
+ test("SPARK-60113: a fatal error of the pipelined writer is logged") {
+ val writerTask = new BasePythonRunner.PipelinedWriterTask(
+ // scalastyle:off throwerror
+ () => throw new LinkageError("fatal writer error"), "test task")
+ // scalastyle:on throwerror
+ val logAppender = new LogAppender("pipelined writer failure")
+ withLogAppender(logAppender) {
+ intercept[LinkageError] {
+ writerTask.run()
+ }
+ }
+ assert(logAppender.loggingEvents.exists { event =>
+ event.getLevel == Level.ERROR &&
+ event.getMessage.getFormattedMessage.contains("Pipelined Python writer
of test task") &&
+ event.getThrown.getMessage == "fatal writer error"
+ })
+ // The writer has exited, so stopping does not wait.
+ writerTask.stopAndAwaitExit()
+ }
+
+ test("SPARK-60113: stopping on the writer thread itself does not wait for
the writer") {
+ val taskRef = new AtomicReference[BasePythonRunner.PipelinedWriterTask]()
+ val stopReturned = new CountDownLatch(1)
+ val interruptedAfterStop = new AtomicReference[Option[Boolean]](None)
+ val writerTask = new BasePythonRunner.PipelinedWriterTask(() => {
+ // Like task completion listeners handed to the writer thread while it
pulls its input.
+ taskRef.get.stopAndAwaitExit()
+ interruptedAfterStop.set(Some(Thread.currentThread().isInterrupted))
+ stopReturned.countDown()
+ }, "test task")
+ taskRef.set(writerTask)
+ val writerThread = startThread(writerTask.run())
+ assert(stopReturned.await(1, TimeUnit.MINUTES))
+ // Stopping leaves the interrupt status of the thread it runs on as it
was, so that the
+ // listeners that run next on it can clean up.
+ assert(interruptedAfterStop.get === Some(false))
+ writerThread.join(TimeUnit.MINUTES.toMillis(1))
+ assert(!writerThread.isAlive)
+ // The writer has exited, so stopping from another thread does not wait
either.
+ writerTask.stopAndAwaitExit()
+ }
+
+ test("SPARK-60113: the writer is interrupted again if it consumed the
interrupt") {
+ val interrupts = new AtomicInteger(0)
+ val writerTask = new BasePythonRunner.PipelinedWriterTask(() => {
+ // Like upstream code that catches the first InterruptedException and
carries on, and then
+ // blocks again (e.g. in channel.write), where the next interrupt stops
it.
+ val blocker = new CountDownLatch(1)
+ var stopped = false
+ while (!stopped) {
+ try {
+ blocker.await()
+ } catch {
+ case _: InterruptedException =>
+ stopped = interrupts.incrementAndGet() >= 2
+ }
+ }
+ }, "test task", warnIntervalMs = 100)
+ val writerThread = startThread(writerTask.run())
+ eventually(timeout(1.minute)) {
+ assert(writerThread.getState == Thread.State.WAITING)
+ }
+
+ val stopper = startThread(writerTask.stopAndAwaitExit())
+ stopper.join(TimeUnit.MINUTES.toMillis(1))
+ assert(!stopper.isAlive)
+ assert(interrupts.get >= 2)
+ writerThread.join(TimeUnit.MINUTES.toMillis(1))
+ }
+
+ test("SPARK-60113: an interrupt pending before stopping the writer is kept")
{
+ // The writer never started.
+ val notStarted = new BasePythonRunner.PipelinedWriterTask(() => (), "test
task")
+ // The writer runs and exits once interrupted.
+ val running = new BasePythonRunner.PipelinedWriterTask(() => {
+ try {
+ new CountDownLatch(1).await()
+ } catch {
+ case _: InterruptedException =>
+ }
+ }, "test task")
+ val writerThread = startThread(running.run())
+ eventually(timeout(1.minute)) {
+ assert(writerThread.getState == Thread.State.WAITING)
+ }
+
+ Seq(notStarted, running).foreach { writerTask =>
+ val interruptedAfterStop = new AtomicReference[Option[Boolean]](None)
+ val stopper = startThread {
+ Thread.currentThread().interrupt()
+ writerTask.stopAndAwaitExit()
+ interruptedAfterStop.set(Some(Thread.currentThread().isInterrupted))
+ }
+ stopper.join(TimeUnit.MINUTES.toMillis(1))
+ assert(interruptedAfterStop.get === Some(true))
+ }
+ writerThread.join(TimeUnit.MINUTES.toMillis(1))
+ }
+
+ test("SPARK-60113: a fatal error of the pipelined writer is passed to the
reader") {
+ LocalSparkContext.withSpark(new SparkContext("local", "test")) { _ =>
+ val func = SimplePythonFunction(
+ Seq.empty, new JHashMap[String, String](), new JArrayList[String](),
"python3", "3",
+ new JArrayList[Broadcast[PythonBroadcast]](), null)
+ val runner = new PythonRunner(Seq(ChainedPythonFunctions(Seq(func))),
None)
+ val server = ServerSocketChannel.open()
+ try {
+ server.bind(new InetSocketAddress(InetAddress.getLoopbackAddress, 0))
+ val channel = SocketChannel.open(server.getLocalAddress)
+ val pythonSide = server.accept()
+ try {
+ val context = TaskContext.empty()
+ val worker = PythonWorker(channel)
+ val writer = new runner.Writer(SparkEnv.get, worker, Iterator.empty,
0, context) {
+ override def open(dataOut: DataOutputStream): Unit = {}
+ override protected def writeCommand(dataOut: DataOutputStream):
Unit = {}
+ override def writeNextInputToStream(dataOut: DataOutputStream):
Boolean = {
+ // scalastyle:off throwerror
+ throw new LinkageError("fatal writer error")
+ // scalastyle:on throwerror
+ }
+ }
+ intercept[LinkageError] {
+ new runner.PipelinedWriterRunnable(worker, writer, 1024,
context).run()
+ }
+ // The reader rethrows the error instead of waiting for the Python
worker, which sees
+ // the end of its input.
+ assert(writer.exception.map(_.getMessage) === Some("fatal writer
error"))
+ assert(pythonSide.read(ByteBuffer.allocate(1)) === -1)
Review Comment:
**10. [Low] This read blocks forever if `shutdownOutput()` regresses,
instead of failing**
`pythonSide` is a blocking `SocketChannel`, and `SocketChannel.read` honors
no timeout. If a change keeps `writer.setException` but skips
`shutdownOutput()`, e.g. by moving the rethrow above it, the assertions above
still pass and this read never returns, since `channel` is closed only in the
`finally` after it. `SparkFunSuite`'s 20-minute `failAfter` uses the default
`DoNotSignal`, so the core job would run into its 2h30m limit, as it did with
`StreamingShuffleSuite`.
Reading through the socket adaptor, which honors `SO_TIMEOUT`, would make it
fail instead:
```scala
pythonSide.socket().setSoTimeout(TimeUnit.MINUTES.toMillis(1).toInt)
assert(pythonSide.socket().getInputStream.read() === -1)
```
##########
core/src/main/scala/org/apache/spark/TaskContextImpl.scala:
##########
@@ -267,13 +310,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:
**4. [Low] Failure listeners that another thread triggers during the writer
wait are dropped again, and nothing says so**
With the release loop moved to SPARK-60222, the gap from [round
3](https://github.com/apache/spark/pull/59332#discussion_r4252547639) is back.
While this thread holds the invocation, which now includes the wait for the
writer, a `markTaskFailed` or `addTaskFailureListener` from another thread
returns at L300-L303. This loop drains only the pre-completion and completion
stacks before it deregisters here, so those failure listeners never run,
although `isFailed()` is true.
For example, a task is killed while its writer is blocked in upstream I/O,
and a `TaskInterruptListener` throws: `markInterrupted` calls `markTaskFailed`
on the kill thread, which only records the failure. Master has the same gap,
but only while the completion listeners run; this PR extends it to the whole
writer wait. The in-tree interrupt listeners catch `NonFatal`, so in practice
it takes a third-party listener.
I'm not asking to bring the release loop back here. But the description's
SPARK-60222 paragraph lists only the completion-listener cases, and the round-3
thread still ends at "Fixed.". Could the paragraph, or the
`startPipelinedWriter` scaladoc, mention this case, so that SPARK-60222 covers
it too?
##########
core/src/main/scala/org/apache/spark/api/python/PythonRunner.scala:
##########
@@ -183,6 +183,182 @@ 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 `Future.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))
+ pipelinedWriterThreadPool.submit(writerTask)
+ context.addTaskPreCompletionListener(new TaskCompletionListener {
+ override def onTaskCompletion(context: TaskContext): Unit =
writerTask.stopAndAwaitExit()
+ })
+ }
+
+ /** 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, 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(): Unit = {
+ val wasInterrupted = Thread.interrupted()
+ try {
+ val runningThread = synchronized {
+ state match {
+ case PipelinedWriterNotStarted =>
+ // The writer never started and now never will.
+ finish()
+ None
+ case PipelinedWriterRunning(thread) =>
+ // Interrupts the writer thread. 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).
+ if (thread ne Thread.currentThread()) {
+ thread.interrupt()
+ }
+ Some(thread)
+ case PipelinedWriterFinished => None
+ }
+ }
+ runningThread.foreach { thread =>
+ if (thread eq Thread.currentThread()) {
+ // TaskContextImpl can still let a thread that adds a listener run
the pending
+ // completion listeners: when another thread is invoking listeners
as the task
+ // completes, or while a failing listener's failure listeners run.
If that thread is
+ // this writer, in the middle of pulling its input, the writer can
only exit after
+ // this returns, so waiting would never end. It is not stopped
either: the remaining
+ // listeners run on this thread right after this one and free
memory that the writer
+ // uses again when they return, as before this listener waited at
all.
Review Comment:
**2. [Medium] On the writer-thread path nothing stops the writer any more,
so it keeps writing after the cleanup**
Since `00da9fb69d4` this branch is reachable again (the paths the
description defers to SPARK-60222), and "as before this listener waited at all"
is not quite what happens.
Before this PR, `cancel(true)` on this path interrupted this thread itself,
since it is the `FutureTask` runner. Once the remaining listeners returned, the
writer stopped at the `isInterrupted` check at L1479 after the current
`writeNextInputToStream` call, or at the next `channel.write`. Now nothing sets
that flag: L294 skips this thread, and the `finally` restores the state on
entry. Clearing the self-interrupt was my round-3 suggestion, back when this
path was unreachable.
So after `queue.close()` and `worker.stop()` run here, the writer goes back
to the upstream code and keeps adding rows to the freed `HybridRowQueue` page:
`HybridQueue.close()` does not reset `writing`, so `add` keeps writing through
it. It stops only when `bufferStream.size() >= bufferSize` (64 KiB by default)
and the flush hits the closed channel, or when upstream throws. On the row path
that is up to 64 KiB of rows, against at most the current pickled batch (100
rows) before.
Could the loop also check `!context.isCompleted()`?
```scala
while (hasInput && !Thread.currentThread().isInterrupted &&
!context.isCompleted()) {
```
It is always true on this path, since the takeover requires `completed`. On
the normal path the stop listener interrupts the writer right after anyway. And
it leaves the interrupt status of the listeners alone, which was the reason to
clear the flag. Otherwise, could the comment say that the writer keeps going?
Also on this comment: the invocation is released around a failing listener's
failure listeners, not while they run. The windows are between
`listenerInvocationThread = None` (`TaskContextImpl.scala` L367) and the claim
in `markTaskFailed`, and between their deregistration and the reclaim in the
`finally`. And [your reply on the
split](https://github.com/apache/spark/pull/59332#discussion_r4262579640) says
the comment points to SPARK-60222, but no code comment mentions it yet.
##########
core/src/test/scala/org/apache/spark/api/python/BasePythonRunnerSuite.scala:
##########
@@ -50,6 +62,356 @@ 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)
Review Comment:
**7. [Low] The only test that catches reverting
`addTaskPreCompletionListener` in `startPipelinedWriter` depends on a race**
If `startPipelinedWriter` went back to `addTaskCompletionListener` (the LIFO
bug this PR fixes), this is the only test that would fail, and only when this
push comes after the stop listener is registered. `startPipelinedWriter`
submits the writer first (L212) and registers the stop listener next (L213).
When the pool thread pushes `cleanupAddedByWriter` first, the reverted stop
listener lands on top, runs first, and L146-L155 all pass. That order is likely
enough: an idle thread left by the SPARK-58192 test picks the task up quickly,
while the test thread may still be loading the listener class.
Could `onStart` wait for a latch that the test counts down after
`startPipelinedWriter` returns? Then the writer's listener is always added
after the stop listener, and the test fails deterministically on that
regression.
##########
core/src/main/scala/org/apache/spark/api/python/PythonRunner.scala:
##########
@@ -183,6 +183,182 @@ 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 `Future.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))
+ pipelinedWriterThreadPool.submit(writerTask)
+ context.addTaskPreCompletionListener(new TaskCompletionListener {
+ override def onTaskCompletion(context: TaskContext): Unit =
writerTask.stopAndAwaitExit()
+ })
+ }
+
+ /** 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, 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()
Review Comment:
**5. [Low] The writer interrupts ignore the uninterruptible resource
creation that `Task.kill` respects**
The writer thread has the `TaskContext` set (L1468), so
`Utils.createResourceUninterruptiblyIfInTaskThread` applies on it when an
upstream scan opens a file: CSV, JSON, Text, XML, `NewHadoopRDD`, and the DSv2
Parquet and Text readers. `Task.kill` defers its interrupt while
`context.interruptible()` is false, which SPARK-50768 added to avoid leaking
half-built streams. This interrupt and the one at L295 do not check it.
If the interrupt lands in the builder, or the flag is already set when the
writer enters it, the builder can fail after it opened a stream that nothing
closes. For example, an S3A open through `FutureIO.awaitFuture` turns the
interrupt into `InterruptedIOException` and leaves the stream that the pool
thread opened unclosed. And `HadoopCodecStreams.createInputStream` does not
close `fs.open(file)` when `codec.createInputStream` fails.
The first interrupt behaves like master's `cancel(true)`, and `PipedRDD`
interrupts its stdin writer the same way, so this may deserve its own JIRA.
What this PR adds is the repeated interrupt here. Could it at least skip a
writer that is inside `createResourceUninterruptibly`, e.g. by checking
`context.interruptible()` under `TaskContext.synchronized` and retrying at the
next tick?
##########
core/src/test/scala/org/apache/spark/api/python/BasePythonRunnerSuite.scala:
##########
@@ -50,6 +62,356 @@ 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()
Review Comment:
**14. [Nit] `addNow` is not counted down in this `finally`**
If `reached.await` (L189) fails, the writer stays in
`awaitIgnoringInterrupts(addNow)` for the rest of the core test JVM and keeps a
pool thread busy. If completion already reached the stop listener, that thread
also logs a warning and re-interrupts the writer every 10 s. `finally {
addNow.countDown(); release.countDown() }` would release both. The `started`
assertions before the `try` blocks (L138, L184) leave a writer behind in the
same way.
##########
core/src/main/scala/org/apache/spark/TaskContextImpl.scala:
##########
@@ -177,29 +194,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.
Review Comment:
**11. [Nit] "which would then wait for itself" no longer matches what
happens**
This was true in `038c4b3420e`, before the same-thread guard. With the guard
in `stopAndAwaitExit`, such a writer now skips the wait, and the listeners
after it free memory it still uses, as the comment in `PythonRunner.scala`
(L303-L312) and the description say. So the two comments describe different
outcomes for the same path. This comment also doesn't say that the `else`
branch below still allows the takeover when another thread holds the invocation.
Maybe: "..., e.g. a pipelined Python UDF writer that adds listeners lazily
while it pulls its input. That thread cannot stop itself, so the listeners
after the stop listener would free memory it still uses. If another thread
already holds the invocation, this does not help (SPARK-60222)."
The scaladoc of `TaskContext.addTaskPreCompletionListener` could also say
which thread runs these listeners: normally the one that marks the task
completed, otherwise the next thread that adds a listener, which can be the
helper thread itself.
##########
core/src/main/scala/org/apache/spark/api/python/PythonRunner.scala:
##########
@@ -183,6 +183,182 @@ 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 `Future.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))
+ pipelinedWriterThreadPool.submit(writerTask)
+ context.addTaskPreCompletionListener(new TaskCompletionListener {
+ override def onTaskCompletion(context: TaskContext): Unit =
writerTask.stopAndAwaitExit()
+ })
+ }
+
+ /** 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, 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(): Unit = {
+ val wasInterrupted = Thread.interrupted()
+ try {
+ val runningThread = synchronized {
+ state match {
+ case PipelinedWriterNotStarted =>
+ // The writer never started and now never will.
+ finish()
+ None
+ case PipelinedWriterRunning(thread) =>
+ // Interrupts the writer thread. 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).
+ if (thread ne Thread.currentThread()) {
+ thread.interrupt()
+ }
+ Some(thread)
+ case PipelinedWriterFinished => None
+ }
+ }
+ runningThread.foreach { thread =>
+ if (thread eq Thread.currentThread()) {
+ // TaskContextImpl can still let a thread that adds a listener run
the pending
+ // completion listeners: when another thread is invoking listeners
as the task
+ // completes, or while a failing listener's failure listeners run.
If that thread is
+ // this writer, in the middle of pulling its input, the writer can
only exit after
+ // this returns, so waiting would never end. It is not stopped
either: the remaining
+ // listeners run on this thread right after this one and free
memory that the writer
+ // uses again when they return, as before this listener waited at
all.
+ logError(log"Task completion listeners of ${MDC(TASK_NAME,
taskName)} run on its " +
+ log"pipelined Python writer thread while the writer is running;
not waiting " +
+ log"for the writer to exit, which may lead to a use-after-free.")
+ } else {
+ awaitExit()
+ }
+ }
+ } finally {
+ Thread.interrupted()
+ if (wasInterrupted) {
+ Thread.currentThread().interrupt()
+ }
+ }
+ }
+
+ private def awaitExit(): Unit = {
+ // 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.
+ //
+ // The writer is interrupted again on every warning, in case code it
called consumed the
+ // interrupt without stopping the writer, e.g. an upstream iterator that
caught an
+ // InterruptedException. The writer would then go on and block in
channel.write, since
+ // nothing reads the Python worker's output anymore.
+ //
+ // An interrupt of this thread that arrives during the wait (e.g. a task
kill) does not stop
+ // it and is not passed on (see stopAndAwaitExit): 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(warnIntervalMs, TimeUnit.MILLISECONDS)
+ if (!done) {
+ val waitedMs = TimeUnit.NANOSECONDS.toMillis(System.nanoTime() -
startNs)
+ logWarning(log"Still waiting for the pipelined Python writer of " +
Review Comment:
**13. [Nit] The warning doesn't identify the writer thread or where it is
blocked**
The writer threads are named `python-udf-pipelined-writer-N` and are reused
across tasks, the default log layout has no thread name, and the task reaper
dumps only the task thread, which shows `CountDownLatch.await`. So when this
warning repeats, the logs say neither which thread to look at nor where it is
stuck.
The thread is at hand in `PipelinedWriterRunning(thread)`. Could each
warning include its name, and the first one also its stack? The stack can be
taken outside the monitor and dropped if `exited` reached zero meanwhile, since
the pool thread may already run another task. The `logError` in the same-thread
branch (L310) could likewise carry a throwable with the current stack, to show
which lazy `add*Listener` call handed the listeners to the writer.
##########
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:
**15. [Nit] This test stayed in the PR**
This comment is anchored on "a pre-completion listener added during
completion runs next", which is still in this PR: its listener joins a
non-daemon helper without a timeout (`thread.join()` at L834) while it holds
the invocation. It cannot hang today, since the helper's add returns at once,
but it would if SPARK-60222 makes an add wait for the holder. Could it use
`setDaemon(true)`, `join(timeout)` and `assert(!thread.isAlive)`, like the test
below (L858, L862)? The older "listener registers another listener using a
second thread" test (L685) has the same pattern and could be changed together
with SPARK-60222.
--
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]