HyukjinKwon commented on code in PR #59332:
URL: https://github.com/apache/spark/pull/59332#discussion_r4242065501
##########
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:
**Non-blocking (P2):** This wait can block forever when `stopAndAwaitExit`
runs on the writer thread itself, which can happen through the existing
`TaskContextImpl` handoff. `markTaskCompleted` sets `completed = true` in one
`synchronized` block, and `invokeListeners` claims `listenerInvocationThread`
in a later one. The writer thread has the `TaskContext` set
(`PipelinedWriterRunnable.run`) and registers completion listeners lazily while
it pulls input, e.g. `ParquetPartitionReaderFactory` L391 or
`UnsafeExternalSorter` L206. If one of those registrations lands between the
two blocks, `addTaskCompletionListener` sees `completed = true` and the writer
thread becomes the invoker. Pre-completion listeners are popped first, so the
writer then runs its own stop listener:
- `future.cancel(true)` interrupts the writer thread itself, because it is
the `FutureTask` runner.
- `claimed.compareAndSet(false, true)` fails, because `run()` already
claimed the writer.
- This loop swallows the `InterruptedException` and waits on `exited`, which
only the `finally` of `run()`, further up the same stack, can count down.
Meanwhile the task thread's `invokeListeners` sees another invoker and
returns. The task reports success, but none of the remaining completion
listeners run (`worker.stop()`, the `HybridRowQueue` close, the reader closes),
and `MonitorThread` does not step in because `isCompleted()` is already true.
Each occurrence permanently leaks a `python-udf-pipelined-writer` thread and a
Python worker, and this warning is logged every 10 seconds forever. The old
`cancel(true)` + `get()` listener did not hang on the same interleaving,
because `get()` throws `CancellationException` right away. The window is
narrow, but it sits on exactly the path this PR targets (lazy listener
registration on the writer thread), and none of the new tests registers a
listener from the writer after `completed` is set.
**Recommended change:** In TaskContextImpl, have markTaskCompleted claim the
completion-listener invocation slot for the calling thread in the same
synchronized block that sets completed, when no thread already holds it, and
run the listeners under that pre-claimed slot. Add TaskContextSuite coverage
for a listener added from another thread at the moment completion starts, and a
BasePythonRunnerSuite case in which the pipelined writer thread registers a
completion listener after markTaskCompleted has begun.
**Why this works:** Once completed is visible to other threads, the task
thread already owns listenerInvocationThread. A listener added by the writer
thread at that point is only pushed, so the task thread pops and runs the
writer's stop listener, waits for the real writer to exit, and then runs the
newly queued listener together with the rest.
**Scope:** Make the task thread the guaranteed owner of completion-listener
invocation from the moment the task is marked completed, and cover the late
writer-thread registration.
**Compatibility:** A listener added after the task's listeners have all run
is still called immediately, and pre-completion listeners still run before
every pending completion listener.
**Risks:** invokeListeners also serves failure and post-status-update
listeners. Pre-claiming must apply only to the markTaskCompleted entry point,
or those invocations could stop running. If another thread already holds the
slot when markTaskCompleted runs, the existing return-early behavior must be
kept.
**Constraints:** Listeners must still run one at a time and never while
holding the TaskContext lock. Pre-completion listeners must still run before
every pending completion listener.
**Success:** When the task thread calls markTaskCompleted and no other
thread is invoking listeners, the task thread holds the listener-invocation
slot as soon as completed becomes visible, so a listener added from another
thread at that point is only queued. The pipelined writer's stop listener never
runs on the writer thread during markTaskCompleted. markTaskCompleted returns
only after the writer has exited and every queued pre-completion and completion
listener has run. A listener added after all listeners have run still runs
immediately on the calling thread.
**Verification:**
- **Regression:** A completion listener registered from the running
pipelined writer thread after task completion has started is queued, not
executed on the writer thread. Task completion then finishes after the writer
exits, and the listener observes that the writer has exited.
- **Behavior:** A listener added from another thread while markTaskCompleted
is starting runs on the task thread, after the pending pre-completion listeners.
- **Compatibility:** Existing listener ordering, add-during-completion,
add-after-completion, and failure-aggregation behavior of task completion,
failure, and post-status-update listeners is unchanged.
--
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]