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

jackylee-ch pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/gluten.git


The following commit(s) were added to refs/heads/main by this push:
     new 8b701473f4 [GLUTEN-12608][CORE] Make ShuffleManagerRouter cache 
tolerate the executor lifecycle (#12609)
8b701473f4 is described below

commit 8b701473f465e4d7eb05335b0529bad9327db8b3
Author: YangJie <[email protected]>
AuthorDate: Sat Jul 25 07:25:48 2026 +0800

    [GLUTEN-12608][CORE] Make ShuffleManagerRouter cache tolerate the executor 
lifecycle (#12609)
---
 .../spark/shuffle/ShuffleManagerRouter.scala       |  45 +++---
 .../shuffle/ShuffleManagerRouterCacheSuite.scala   | 154 +++++++++++++++++++++
 2 files changed, 176 insertions(+), 23 deletions(-)

diff --git 
a/gluten-core/src/main/scala/org/apache/spark/shuffle/ShuffleManagerRouter.scala
 
b/gluten-core/src/main/scala/org/apache/spark/shuffle/ShuffleManagerRouter.scala
index 1e76972fb2..bd202ad52d 100644
--- 
a/gluten-core/src/main/scala/org/apache/spark/shuffle/ShuffleManagerRouter.scala
+++ 
b/gluten-core/src/main/scala/org/apache/spark/shuffle/ShuffleManagerRouter.scala
@@ -61,7 +61,10 @@ private class ShuffleManagerRouter(lookup: 
ShuffleManagerLookup)
   }
 
   override def unregisterShuffle(shuffleId: Int): Boolean = {
-    cache.remove(shuffleId).unregisterShuffle(shuffleId)
+    // On a multi-executor cluster, Spark broadcasts RemoveShuffle to every 
executor, so a router
+    // that never registered or served this shuffleId still receives 
unregisterShuffle. Tolerate
+    // the miss instead of asserting: report that this router removed nothing.
+    cache.remove(shuffleId).exists(_.unregisterShuffle(shuffleId))
   }
 
   override def shuffleBlockResolver: ShuffleBlockResolver = resolver
@@ -83,13 +86,12 @@ private class ShuffleManagerRouter(lookup: 
ShuffleManagerLookup)
           s"${handle.getClass} is not a BaseShuffleHandle so is not supported 
by " +
             s"GlutenShuffleManager")
     }
-    val shuffleId = baseShuffleHandle.shuffleId
-    if (cache.has(shuffleId)) {
-      return
-    }
-    val dependency = baseShuffleHandle.dependency
-    val manager = lookup.findShuffleManager(dependency)
-    cache.store(shuffleId, manager)
+    // store is idempotent and resolves the manager lazily, so several task 
threads first-touching
+    // the same new shuffleId install it exactly once, while an already-cached 
shuffle skips the
+    // lookup entirely.
+    cache.store(
+      baseShuffleHandle.shuffleId,
+      lookup.findShuffleManager(baseShuffleHandle.dependency))
   }
 }
 
@@ -98,17 +100,13 @@ private object ShuffleManagerRouter {
     private val cache: java.util.Map[Int, ShuffleManager] =
       new java.util.concurrent.ConcurrentHashMap()
 
-    def has(shuffleId: Int): Boolean = {
-      cache.containsKey(shuffleId)
-    }
-
-    def store(shuffleId: Int, manager: ShuffleManager): ShuffleManager = {
-      cache.compute(
-        shuffleId,
-        (id, m) => {
-          assert(m == null, s"Shuffle manager was already cached for shuffle 
id: $id")
-          manager
-        })
+    def store(shuffleId: Int, manager: => ShuffleManager): ShuffleManager = {
+      // Idempotent: on a multi-core executor several task threads may 
first-touch the same new
+      // shuffleId concurrently. computeIfAbsent lets the first caller install 
the manager and the
+      // rest observe it, instead of racing into an assertion. The by-name 
manager is resolved only
+      // on a miss, so an already-cached shuffle skips the lookup. lookup 
resolves the same manager
+      // for a given dependency, so returning an already-cached entry is safe.
+      cache.computeIfAbsent(shuffleId, _ => manager)
     }
 
     def get(shuffleId: Int): ShuffleManager = {
@@ -117,10 +115,11 @@ private object ShuffleManagerRouter {
       manager
     }
 
-    def remove(shuffleId: Int): ShuffleManager = {
-      val manager = cache.remove(shuffleId)
-      assert(manager != null, s"Shuffle manager not registered for shuffle id: 
$shuffleId")
-      manager
+    def remove(shuffleId: Int): Option[ShuffleManager] = {
+      // On a multi-executor cluster, Spark broadcasts RemoveShuffle to every 
executor, so this
+      // router may be asked to remove a shuffleId it never cached. Return 
None instead of
+      // asserting the entry was present.
+      Option(cache.remove(shuffleId))
     }
 
     def size(): Int = {
diff --git 
a/gluten-core/src/test/scala/org/apache/spark/shuffle/ShuffleManagerRouterCacheSuite.scala
 
b/gluten-core/src/test/scala/org/apache/spark/shuffle/ShuffleManagerRouterCacheSuite.scala
new file mode 100644
index 0000000000..bcbea892b8
--- /dev/null
+++ 
b/gluten-core/src/test/scala/org/apache/spark/shuffle/ShuffleManagerRouterCacheSuite.scala
@@ -0,0 +1,154 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *    http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.spark.shuffle
+
+import org.apache.spark.{ShuffleDependency, SparkConf, TaskContext}
+import org.apache.spark.internal.config.SHUFFLE_MANAGER
+import org.apache.spark.internal.config.UI.UI_ENABLED
+import org.apache.spark.sql.test.SharedSparkSession
+
+import java.util.concurrent.{CopyOnWriteArrayList, CyclicBarrier}
+import java.util.concurrent.atomic.AtomicInteger
+
+/**
+ * Reproduces the two ways [[ShuffleManagerRouter]]'s cache mishandles the 
executor-side lifecycle,
+ * where the router is populated lazily and concurrently by task threads 
rather than by a single
+ * driver coordinator.
+ */
+class ShuffleManagerRouterCacheSuite extends SharedSparkSession {
+  import ShuffleManagerRouterCacheSuite._
+
+  override protected def sparkConf: SparkConf =
+    super.sparkConf
+      .set(SHUFFLE_MANAGER.key, classOf[GlutenShuffleManager].getName)
+      .set(UI_ENABLED, false)
+
+  override protected def beforeEach(): Unit = {
+    ShuffleManagerRegistry.get().clear()
+    // Register a no-op manager that accepts every dependency, so router 
lookups resolve
+    // without delegating into a real SortShuffleManager.
+    ShuffleManagerRegistry
+      .get()
+      .register(
+        new LookupKey {
+          override def accepts[K, V, C](dependency: ShuffleDependency[K, V, 
C]): Boolean = true
+        },
+        classOf[AcceptAllShuffleManager].getName)
+  }
+
+  override protected def afterEach(): Unit = {
+    ShuffleManagerRegistry.get().clear()
+  }
+
+  test("unregisterShuffle tolerates a shuffleId never registered on this 
router") {
+    // On a multi-executor cluster, Spark broadcasts RemoveShuffle to every 
executor, so a
+    // router that never registered/served this shuffleId still receives 
unregisterShuffle.
+    val gm = spark.sparkContext.env.shuffleManager
+    // Force the router to build before exercising it.
+    gm.shuffleBlockResolver
+
+    val neverRegistered = 987654321
+    // Should behave like SortShuffleManager: return a boolean, not throw.
+    assert(!gm.unregisterShuffle(neverRegistered))
+  }
+
+  test("concurrent first-touch of a new shuffleId does not fail tasks") {
+    // On a multi-core executor, N tasks of the same new shuffleId call 
getReader/getWriter
+    // concurrently. The cache is populated lazily, so they race to register 
the manager.
+    val gm = spark.sparkContext.env.shuffleManager
+    gm.shuffleBlockResolver // force build
+
+    val concurrency = 4
+    val iterations = 200
+    val idGen = new AtomicInteger(100000)
+    val errors = new CopyOnWriteArrayList[Throwable]()
+
+    (0 until iterations).foreach {
+      _ =>
+        val shuffleId = idGen.getAndIncrement()
+        val barrier = new CyclicBarrier(concurrency)
+        val threads = (0 until concurrency).map {
+          _ =>
+            val t = new Thread(
+              () => {
+                try {
+                  barrier.await()
+                  val dep: ShuffleDependency[Any, Any, Any] = null
+                  val handle = new BaseShuffleHandle(shuffleId, dep)
+                  gm.getReader(
+                    handle,
+                    0,
+                    1,
+                    0,
+                    1,
+                    null.asInstanceOf[TaskContext],
+                    null.asInstanceOf[ShuffleReadMetricsReporter])
+                } catch {
+                  case th: Throwable => errors.add(th)
+                }
+              })
+            t.start()
+            t
+        }
+        threads.foreach(_.join())
+    }
+
+    assert(
+      errors.isEmpty,
+      s"Expected no errors from concurrent first-touch but got ${errors.size}: 
" +
+        errors.toArray.map(_.toString).toSet.mkString("; "))
+  }
+}
+
+object ShuffleManagerRouterCacheSuite {
+
+  /** A minimal [[ShuffleManager]] that accepts everything and does no real 
work. */
+  class AcceptAllShuffleManager(conf: SparkConf) extends ShuffleManager {
+    override def registerShuffle[K, V, C](
+        shuffleId: Int,
+        dependency: ShuffleDependency[K, V, C]): ShuffleHandle = {
+      new BaseShuffleHandle(shuffleId, dependency)
+    }
+
+    override def getWriter[K, V](
+        handle: ShuffleHandle,
+        mapId: Long,
+        context: TaskContext,
+        metrics: ShuffleWriteMetricsReporter): ShuffleWriter[K, V] = {
+      null.asInstanceOf[ShuffleWriter[K, V]]
+    }
+
+    override def getReader[K, C](
+        handle: ShuffleHandle,
+        startMapIndex: Int,
+        endMapIndex: Int,
+        startPartition: Int,
+        endPartition: Int,
+        context: TaskContext,
+        metrics: ShuffleReadMetricsReporter): ShuffleReader[K, C] = {
+      null.asInstanceOf[ShuffleReader[K, C]]
+    }
+
+    override def unregisterShuffle(shuffleId: Int): Boolean = true
+
+    override def shuffleBlockResolver: ShuffleBlockResolver = {
+      null.asInstanceOf[ShuffleBlockResolver]
+    }
+
+    override def stop(): Unit = {}
+  }
+}


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to