You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@spark.apache.org by we...@apache.org on 2026/07/22 11:43:13 UTC

(spark) branch master updated: [SPARK-58245][CORE] Fix AsyncEventQueue Shutdown Hang When Poison Pill Enqueue Is Interrupted

This is an automated email from the ASF dual-hosted git repository.

cloud-fan pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/spark.git


The following commit(s) were added to refs/heads/master by this push:
     new ce39ba8e7e02 [SPARK-58245][CORE] Fix AsyncEventQueue Shutdown Hang When Poison Pill Enqueue Is Interrupted
ce39ba8e7e02 is described below

commit ce39ba8e7e023123dcb62548443e268c946459f3
Author: Zerui Bao <ze...@databricks.com>
AuthorDate: Wed Jul 22 19:42:56 2026 +0800

    [SPARK-58245][CORE] Fix AsyncEventQueue Shutdown Hang When Poison Pill Enqueue Is Interrupted
    
    ### What changes were proposed in this pull request?
    This PR makes `AsyncEventQueue` shutdown robust to thread interruption. In `AsyncEventQueue.stop()`, two blocking operations are made uninterruptible:
    
    - Enqueuing the POISON_PILL now uses `Uninterruptibles.putUninterruptibly(eventQueue, POISON_PILL)` instead of eventQueue.put(POISON_PILL).
    - Joining the dispatch thread now uses Uninterruptibles.joinUninterruptibly(...), preserving the existing `spark.scheduler.listenerbus.exitTimeout` semantics (wait indefinitely when the timeout is 0, otherwise wait up to the configured timeout).
    Both operations restore the caller's interrupt status before returning, so interruption is not swallowed.
    
    ### Why are the changes needed?
    `AsyncEventQueue.stop()` can be invoked from a thread that has already been interrupted. For example, a Structured Streaming execution thread being torn down. In that case `eventQueue.put(POISON_PILL)` throws InterruptedException before the poison pill is enqueued. Because stopped has already been flipped to true, no further poison pill is ever enqueued, and the dispatch thread (spark-listener-group-*) blocks forever on `eventQueue.take()`, leaking the thread and preventing a clean sh [...]
    
    ### Does this PR introduce _any_ user-facing change?
    No
    
    ### How was this patch tested?
    Added a regression test in SparkListenerSuite, "removing the last listener is uninterruptible", which removes the last listener (triggering a queue stop) from an already-interrupted thread and asserts that the removal does not throw, the caller's interrupt status is preserved, and the queue is stopped.
    
    ### Was this patch authored or co-authored using generative AI tooling?
    Cursor (Opus 4.8)
    
    Closes #57410 from zeruibao/zeruibao/SPARK-58245.
    
    Authored-by: Zerui Bao <ze...@databricks.com>
    Signed-off-by: Wenchen Fan <we...@databricks.com>
---
 .../apache/spark/scheduler/AsyncEventQueue.scala   | 16 ++++++++---
 .../spark/scheduler/SparkListenerSuite.scala       | 31 ++++++++++++++++++++++
 2 files changed, 44 insertions(+), 3 deletions(-)

diff --git a/core/src/main/scala/org/apache/spark/scheduler/AsyncEventQueue.scala b/core/src/main/scala/org/apache/spark/scheduler/AsyncEventQueue.scala
index 3931b8ff5190..735096312b9c 100644
--- a/core/src/main/scala/org/apache/spark/scheduler/AsyncEventQueue.scala
+++ b/core/src/main/scala/org/apache/spark/scheduler/AsyncEventQueue.scala
@@ -17,10 +17,11 @@
 
 package org.apache.spark.scheduler
 
-import java.util.concurrent.LinkedBlockingQueue
+import java.util.concurrent.{LinkedBlockingQueue, TimeUnit}
 import java.util.concurrent.atomic.{AtomicBoolean, AtomicLong}
 
 import com.codahale.metrics.{Gauge, Timer}
+import com.google.common.util.concurrent.Uninterruptibles
 
 import org.apache.spark.{SparkConf, SparkContext}
 import org.apache.spark.internal.Logging
@@ -141,7 +142,10 @@ private class AsyncEventQueue(
     }
     if (stopped.compareAndSet(false, true)) {
       eventCount.incrementAndGet()
-      eventQueue.put(POISON_PILL)
+      // Shutdown may run on an interrupted thread, such as a streaming execution thread.
+      // Once stopped is set, the poison pill must still be enqueued or the dispatch thread can
+      // remain blocked forever.
+      Uninterruptibles.putUninterruptibly(eventQueue, POISON_PILL)
     }
     // This thread might be trying to stop itself as part of error handling -- we can't join
     // in that case.
@@ -151,7 +155,13 @@ private class AsyncEventQueue(
       // By default, the `waitingTimeMs` is set to 0,
       // which means it will wait until all events are drained.
       val exitTimeoutMs = conf.get(LISTENER_BUS_EXIT_TIMEOUT)
-      dispatchThread.join(exitTimeoutMs)
+      // Finish waiting despite interruption while preserving the configured timeout.
+      // Uninterruptibles restores the caller's interrupt status before returning.
+      if (exitTimeoutMs == 0) {
+        Uninterruptibles.joinUninterruptibly(dispatchThread)
+      } else {
+        Uninterruptibles.joinUninterruptibly(dispatchThread, exitTimeoutMs, TimeUnit.MILLISECONDS)
+      }
     }
   }
 
diff --git a/core/src/test/scala/org/apache/spark/scheduler/SparkListenerSuite.scala b/core/src/test/scala/org/apache/spark/scheduler/SparkListenerSuite.scala
index 6dc4d4da7bfc..b3ec946fb471 100644
--- a/core/src/test/scala/org/apache/spark/scheduler/SparkListenerSuite.scala
+++ b/core/src/test/scala/org/apache/spark/scheduler/SparkListenerSuite.scala
@@ -20,6 +20,7 @@ package org.apache.spark.scheduler
 import java.io.{Externalizable, ObjectInput, ObjectOutput}
 import java.util.{Collections, IdentityHashMap}
 import java.util.concurrent.Semaphore
+import java.util.concurrent.atomic.{AtomicBoolean, AtomicReference}
 
 import scala.collection.mutable
 import scala.jdk.CollectionConverters._
@@ -548,6 +549,36 @@ class SparkListenerSuite extends SparkFunSuite with LocalSparkContext with Match
     assert(bus.findListenersByClass[BasicJobCounter]().isEmpty)
   }
 
+  test("removing the last listener is uninterruptible") {
+    val bus = new LiveListenerBus(new SparkConf(false))
+    val counter = new BasicJobCounter()
+    val removalFailure = new AtomicReference[Throwable]()
+    val interruptRestored = new AtomicBoolean(false)
+
+    bus.addToSharedQueue(counter)
+    bus.start(mockSparkContext, mockMetricsSystem)
+
+    val remover = new Thread("interrupted-listener-remover") {
+      override def run(): Unit = {
+        Thread.currentThread().interrupt()
+        try {
+          bus.removeListener(counter)
+        } catch {
+          case error: Throwable => removalFailure.set(error)
+        } finally {
+          interruptRestored.set(Thread.currentThread().isInterrupted)
+        }
+      }
+    }
+    remover.start()
+    remover.join()
+
+    assert(removalFailure.get() == null)
+    assert(interruptRestored.get())
+    assert(bus.activeQueues().isEmpty)
+    bus.stop()
+  }
+
   Seq(true, false).foreach { throwInterruptedException =>
     val suffix = if (throwInterruptedException) "throw interrupt" else "set Thread interrupted"
     test(s"interrupt within listener is handled correctly: $suffix") {


---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@spark.apache.org
For additional commands, e-mail: commits-help@spark.apache.org