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

zaynt4606 pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/celeborn.git


The following commit(s) were added to refs/heads/main by this push:
     new d6fa6f7f2a [CELEBORN-2499] Show shuffle slot assignments in the Spark 
UI Celeborn tab
d6fa6f7f2a is described below

commit d6fa6f7f2a4c765dd0a165536f5069e69c8529af
Author: ForVic <[email protected]>
AuthorDate: Mon Oct 26 10:00:14 2026 +0800

    [CELEBORN-2499] Show shuffle slot assignments in the Spark UI Celeborn tab
    
    ### What changes were proposed in this pull request?
    
    JIRA: https://issues.apache.org/jira/browse/CELEBORN-2499
    
    Show the slots reserved on each worker for every Celeborn shuffle on the 
Celeborn tab of the Spark UI and the History Server. This is the "Shuffle 
Assignments" follow-up planned in CELEBORN-2415. The driver-side 
LifecycleManager already knows the assignment, so no executor instrumentation 
is needed.
    
    - **client**: once a shuffle is registered, `LifecycleManager` reports the 
slots reserved on each worker to a `ShuffleAssignmentListener`, together with 
the Celeborn shuffle id and the application (Spark) shuffle id. With stage 
rerun enabled, the two ids differ. 
`SparkShuffleManager.registerShuffleAssignmentListener` hands the listener to 
its lazily created LifecycleManager.
    - **spark-ui**:
      - `CelebornPlugin` registers the listener and posts a 
`CelebornShuffleAssignmentEvent` per shuffle, so applications without the 
plugin post nothing. Registration happens in `registerMetrics` because Spark 4 
initializes the shuffle manager after the driver plugins' `init`.
      - `CelebornListener` persists two things, for both the live UI and 
History Server replay:
        - a compact per-shuffle summary, retaining up to 
`spark.ui.retainedStages` shuffles;
        - the shuffles and slots of each worker over all shuffles, which stay 
exact after old shuffles are dropped.
      - The tab gains a *Shuffle Assignments* summary line and two collapsed 
tables:
        - *Shuffle Assignments*: Spark and Celeborn shuffle ids, workers, 
reserved slots, and slots per worker as min / avg / max.
        - *Worker Slots*: the shuffles and reserved slots of each worker.
    
    ### Why are the changes needed?
    
    The Celeborn tab shows nothing about which workers serve an application's 
shuffles or how evenly their slots are spread, which is needed to understand 
worker hotspots for a specific application.
    
    ### Does this PR resolve a correctness bug?
    
    - [ ] Yes
    
    ### Does this PR introduce _any_ user-facing change?
    
    - [x] Yes
    
    The Celeborn tab gains the *Shuffle Assignments* summary line and two 
collapsed tables. Nothing changes for applications that don't enable 
`CelebornPlugin`. Some cases are not covered:
    - Slots that partition splits or revives reallocate later are not included.
    - Shuffle managers that create their own LifecycleManager instead of using 
Celeborn's `SparkShuffleManager` (e.g. Gluten's) report no assignments.
    
    Same workload and cluster (Spark 3.5.9, 3 workers, replication enabled).
    
    Before (main):
    
    
![before](https://raw.githubusercontent.com/ForVic/celeborn/8c2f15655556718cc357ed4440afc9fbac4bfb36/before-celeborn-tab.png)
    
    After, with the collapsed sections expanded:
    
    
![after](https://raw.githubusercontent.com/ForVic/celeborn/8c2f15655556718cc357ed4440afc9fbac4bfb36/after-expanded.png)
    
    ### How was this patch tested?
    
    - `CelebornListenerSuite`:
      - per-shuffle summaries and per-worker totals are persisted from the 
event;
      - per-worker totals stay exact beyond `spark.ui.retainedStages`;
      - the History Server KVStore serializer round trip works.
    - `CelebornPluginSuite`: a local SparkContext with `CelebornPlugin` and 
`SparkShuffleManager` has the listener registered, and reported assignments 
reach the UI store.
    - `LifecycleManagerSuite`: against a mini cluster, the listener receives 
both shuffle ids and primary plus replica slots for every partition.
    - `./build/mvn -P<profile> -pl client-spark/spark-3-ui test` passes for 
`spark-3.0`, `spark-3.5` and `spark-4.0`.
    - Manual: the screenshots above, from a local cluster with 3 Celeborn 
workers, Spark 3.5.9 standalone and the History Server, which shows the same 
data on replay.
    
    Closes #3891 from ForVic/ForVic/spark-ui-shuffle-assignments.
    
    Lead-authored-by: ForVic <[email protected]>
    Co-authored-by: zhengtao <[email protected]>
    Signed-off-by: zhengtao <[email protected]>
    
    AI-Contributed/Feature: 0/303
    AI-Contributed/UT: 0/128
---
 client-spark/spark-3-ui/pom.xml                    |  5 ++
 .../spark/shuffle/celeborn/CelebornListener.scala  | 44 +++++++++++
 .../spark/shuffle/celeborn/CelebornPlugin.scala    | 16 +++-
 .../celeborn/CelebornShuffleAssignmentEvent.scala  | 31 ++++++++
 .../shuffle/celeborn/CelebornStatusStore.scala     | 42 +++++++++++
 .../shuffle/celeborn/ui/CelebornShufflePage.scala  | 87 ++++++++++++++++++++++
 .../shuffle/celeborn/CelebornListenerSuite.scala   | 50 ++++++++++++-
 .../shuffle/celeborn/CelebornPluginSuite.scala     | 53 +++++++++++++
 .../shuffle/celeborn/SparkShuffleManager.java      | 15 ++++
 .../client/listener/ShuffleAssignmentListener.java | 33 ++++++++
 .../apache/celeborn/client/LifecycleManager.scala  | 29 +++++++-
 project/CelebornBuild.scala                        |  1 +
 .../tests/client/LifecycleManagerSuite.scala       | 25 +++++++
 13 files changed, 428 insertions(+), 3 deletions(-)

diff --git a/client-spark/spark-3-ui/pom.xml b/client-spark/spark-3-ui/pom.xml
index d11f0a29a6..eb31e3315f 100644
--- a/client-spark/spark-3-ui/pom.xml
+++ b/client-spark/spark-3-ui/pom.xml
@@ -45,6 +45,11 @@
         </exclusion>
       </exclusions>
     </dependency>
+    <dependency>
+      <groupId>org.apache.spark</groupId>
+      <artifactId>spark-sql_${scala.binary.version}</artifactId>
+      <scope>test</scope>
+    </dependency>
     <dependency>
       <groupId>javax.servlet</groupId>
       <artifactId>javax.servlet-api</artifactId>
diff --git 
a/client-spark/spark-3-ui/src/main/scala/org/apache/spark/shuffle/celeborn/CelebornListener.scala
 
b/client-spark/spark-3-ui/src/main/scala/org/apache/spark/shuffle/celeborn/CelebornListener.scala
index a230cff08b..efcc56841a 100644
--- 
a/client-spark/spark-3-ui/src/main/scala/org/apache/spark/shuffle/celeborn/CelebornListener.scala
+++ 
b/client-spark/spark-3-ui/src/main/scala/org/apache/spark/shuffle/celeborn/CelebornListener.scala
@@ -19,8 +19,12 @@ package org.apache.spark.shuffle.celeborn
 
 import java.util.concurrent.atomic.AtomicLong
 
+import scala.collection.JavaConverters._
+import scala.collection.mutable
+
 import org.apache.spark.SparkConf
 import org.apache.spark.internal.Logging
+import org.apache.spark.internal.config.Status.MAX_RETAINED_STAGES
 import org.apache.spark.scheduler._
 import org.apache.spark.util.Utils
 import org.apache.spark.util.kvstore.KVStore
@@ -57,6 +61,13 @@ private[celeborn] class CelebornListener(
   private val lastUpdateTimestamp = new AtomicLong(-1L)
   private val updateIntervalMillis = 5000L
 
+  // Bound the retained shuffles like Spark bounds its retained stages.
+  private val retainedShuffles = conf.get(MAX_RETAINED_STAGES)
+  // Only accessed from the listener bus thread.
+  private var assignedShuffles = 0L
+  private val workerShuffles = mutable.HashMap[String, Int]()
+  private val workerSlots = mutable.HashMap[String, Int]()
+
   def register(sc: org.apache.spark.SparkContext): Unit = {
     sc.addSparkListener(this)
     logInfo("CelebornListener registered successfully")
@@ -95,6 +106,7 @@ private[celeborn] class CelebornListener(
         totalCompressedWriteBytes.addAndGet(e.getCompressedBytesWritten)
         mayUpdate()
       }
+    case e: CelebornShuffleAssignmentEvent => onShuffleAssignment(e)
     case _ =>
   }
 
@@ -132,6 +144,38 @@ private[celeborn] class CelebornListener(
     }
   }
 
+  private def onShuffleAssignment(event: CelebornShuffleAssignmentEvent): Unit 
= {
+    val slots = event.workerSlots.values
+    kvstore.write(ShuffleAssignmentUIData(
+      event.shuffleId,
+      event.appShuffleId,
+      event.workerSlots.size,
+      slots.sum,
+      if (slots.isEmpty) 0 else slots.min,
+      if (slots.isEmpty) 0 else slots.max))
+    dropLowestShuffleAssignments()
+
+    assignedShuffles += 1
+    event.workerSlots.foreach { case (worker, numSlots) =>
+      workerShuffles(worker) = workerShuffles.getOrElse(worker, 0) + 1
+      workerSlots(worker) = workerSlots.getOrElse(worker, 0) + numSlots
+    }
+    kvstore.write(WorkerSlotsUIData(assignedShuffles, workerShuffles.toMap, 
workerSlots.toMap))
+  }
+
+  // Drops the lowest Celeborn shuffle ids, which roughly are the oldest 
shuffles.
+  private def dropLowestShuffleAssignments(): Unit = {
+    val uiDataClass = classOf[ShuffleAssignmentUIData]
+    val excess = kvstore.count(uiDataClass) - retainedShuffles
+    if (excess > 0) {
+      val dropped = Utils.tryWithResource(
+        kvstore.view(uiDataClass).max(excess).closeableIterator()) { iter =>
+        iter.asScala.map(_.shuffleId).toList
+      }
+      dropped.foreach(shuffleId => kvstore.delete(uiDataClass, shuffleId))
+    }
+  }
+
   override def onApplicationEnd(applicationEnd: SparkListenerApplicationEnd): 
Unit = {
     mayUpdate(force = true)
     logInfo("CelebornListener: application ended, final flush completed")
diff --git 
a/client-spark/spark-3-ui/src/main/scala/org/apache/spark/shuffle/celeborn/CelebornPlugin.scala
 
b/client-spark/spark-3-ui/src/main/scala/org/apache/spark/shuffle/celeborn/CelebornPlugin.scala
index a38a419cac..54c85dc2ef 100644
--- 
a/client-spark/spark-3-ui/src/main/scala/org/apache/spark/shuffle/celeborn/CelebornPlugin.scala
+++ 
b/client-spark/spark-3-ui/src/main/scala/org/apache/spark/shuffle/celeborn/CelebornPlugin.scala
@@ -19,7 +19,9 @@ package org.apache.spark.shuffle.celeborn
 
 import java.util
 
-import org.apache.spark.SparkContext
+import scala.collection.JavaConverters._
+
+import org.apache.spark.{SparkContext, SparkEnv}
 import org.apache.spark.api.plugin.{DriverPlugin, PluginContext, SparkPlugin}
 import org.apache.spark.internal.Logging
 import org.apache.spark.shuffle.celeborn.ui.CelebornUITab
@@ -66,6 +68,18 @@ private class CelebornDriverPlugin extends DriverPlugin with 
Logging {
     sc.ui.foreach { ui =>
       new CelebornUITab(new CelebornStatusStore(ui.store.store), ui)
     }
+    // Not in init: Spark 4 initializes the shuffle manager after the driver 
plugins.
+    SparkEnv.get.shuffleManager match {
+      case manager: SparkShuffleManager =>
+        manager.registerShuffleAssignmentListener((shuffleId, appShuffleId, 
workerSlots) =>
+          sc.listenerBus.post(CelebornShuffleAssignmentEvent(
+            shuffleId,
+            appShuffleId,
+            workerSlots.asScala.map { case (worker, slots) => worker -> 
slots.intValue }.toMap)))
+      case other =>
+        logInfo("Shuffle assignments are not reported by shuffle manager " +
+          s"${Option(other).map(_.getClass.getName).orNull}.")
+    }
   }
 
   override def shutdown(): Unit = {}
diff --git 
a/client-spark/spark-3-ui/src/main/scala/org/apache/spark/shuffle/celeborn/CelebornShuffleAssignmentEvent.scala
 
b/client-spark/spark-3-ui/src/main/scala/org/apache/spark/shuffle/celeborn/CelebornShuffleAssignmentEvent.scala
new file mode 100644
index 0000000000..fa0240fb69
--- /dev/null
+++ 
b/client-spark/spark-3-ui/src/main/scala/org/apache/spark/shuffle/celeborn/CelebornShuffleAssignmentEvent.scala
@@ -0,0 +1,31 @@
+/*
+ * 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.celeborn
+
+import org.apache.spark.scheduler.SparkListenerEvent
+
+/**
+ * Slots reserved on each worker when a Celeborn shuffle is registered, keyed 
by the
+ * worker's host:rpcPort:pushPort:fetchPort:replicatePort. Recorded in the 
event log;
+ * the listener writes it to the KVStore both for the live UI and on History 
Server replay.
+ */
+private[celeborn] case class CelebornShuffleAssignmentEvent(
+    shuffleId: Int,
+    appShuffleId: Int,
+    workerSlots: Map[String, Int])
+  extends SparkListenerEvent
diff --git 
a/client-spark/spark-3-ui/src/main/scala/org/apache/spark/shuffle/celeborn/CelebornStatusStore.scala
 
b/client-spark/spark-3-ui/src/main/scala/org/apache/spark/shuffle/celeborn/CelebornStatusStore.scala
index 05076cf439..dbc4f64940 100644
--- 
a/client-spark/spark-3-ui/src/main/scala/org/apache/spark/shuffle/celeborn/CelebornStatusStore.scala
+++ 
b/client-spark/spark-3-ui/src/main/scala/org/apache/spark/shuffle/celeborn/CelebornStatusStore.scala
@@ -17,7 +17,10 @@
 
 package org.apache.spark.shuffle.celeborn
 
+import scala.collection.JavaConverters._
+
 import com.fasterxml.jackson.annotation.JsonIgnore
+import org.apache.spark.util.Utils
 import org.apache.spark.util.kvstore.{KVIndex, KVStore}
 
 private[celeborn] case class AggregatedTaskInfoUIData(
@@ -52,6 +55,30 @@ private[celeborn] class CelebornBuildInfoUIData(
   def id: String = classOf[CelebornBuildInfoUIData].getName
 }
 
+private[celeborn] case class ShuffleAssignmentUIData(
+    shuffleId: Int,
+    appShuffleId: Int,
+    workers: Int,
+    slots: Int,
+    minWorkerSlots: Int,
+    maxWorkerSlots: Int) {
+
+  @JsonIgnore
+  @KVIndex
+  def id: Int = shuffleId
+}
+
+/** Shuffles and slots of each worker, over all shuffles of the application. */
+private[celeborn] case class WorkerSlotsUIData(
+    shuffles: Long,
+    workerShuffles: Map[String, Int],
+    workerSlots: Map[String, Int]) {
+
+  @JsonIgnore
+  @KVIndex
+  def id: String = classOf[WorkerSlotsUIData].getName
+}
+
 /**
  * Marker persisted during History Server replay when the application's 
recorded
  * `spark.plugins` or `spark.plugins.defaultList` contains [[CelebornPlugin]].
@@ -95,6 +122,21 @@ private[celeborn] class CelebornStatusStore(store: KVStore) 
{
     }
   }
 
+  def shuffleAssignments(): Seq[ShuffleAssignmentUIData] = {
+    
Utils.tryWithResource(store.view(classOf[ShuffleAssignmentUIData]).closeableIterator())
 {
+      iter => iter.asScala.toList
+    }
+  }
+
+  def workerSlots(): WorkerSlotsUIData = {
+    val kClass = classOf[WorkerSlotsUIData]
+    try {
+      store.read(kClass, kClass.getName)
+    } catch {
+      case _: NoSuchElementException => WorkerSlotsUIData(0L, Map.empty, 
Map.empty)
+    }
+  }
+
   def extensionEnabled(): Boolean = {
     val kClass = classOf[CelebornExtensionEnabledUIData]
     try {
diff --git 
a/client-spark/spark-3-ui/src/main/scala/org/apache/spark/shuffle/celeborn/ui/CelebornShufflePage.scala
 
b/client-spark/spark-3-ui/src/main/scala/org/apache/spark/shuffle/celeborn/ui/CelebornShufflePage.scala
index 41edad1130..911a3831da 100644
--- 
a/client-spark/spark-3-ui/src/main/scala/org/apache/spark/shuffle/celeborn/ui/CelebornShufflePage.scala
+++ 
b/client-spark/spark-3-ui/src/main/scala/org/apache/spark/shuffle/celeborn/ui/CelebornShufflePage.scala
@@ -20,6 +20,7 @@ package org.apache.spark.shuffle.celeborn.ui
 import scala.xml.Node
 
 import org.apache.spark.internal.Logging
+import org.apache.spark.shuffle.celeborn.ShuffleAssignmentUIData
 import 
org.apache.spark.shuffle.celeborn.ui.SparkServletBridge.HttpServletRequest
 import org.apache.spark.ui.{UIUtils, WebUIPage}
 import org.apache.spark.util.Utils
@@ -54,6 +55,11 @@ private[celeborn] class CelebornShufflePage(parent: 
CelebornUITab)
     val taskInfo = store.aggregatedTaskInfo()
     val properties = store.celebornProperties()
     val buildInfo = store.buildInfo()
+    val assignments = store.shuffleAssignments().sortBy(-_.shuffleId)
+    val workerSlots = store.workerSlots()
+    val workerRows = workerSlots.workerSlots.toSeq.map { case (worker, slots) 
=>
+      (worker, workerSlots.workerShuffles.getOrElse(worker, 0), slots)
+    }.sortBy { case (worker, _, slots) => (-slots, worker) }
 
     val writeBytes = taskInfo.shuffleWriteBytes
     val readBytes = taskInfo.shuffleReadBytes
@@ -67,6 +73,19 @@ private[celeborn] class CelebornShufflePage(parent: 
CelebornUITab)
       if (ms <= 0) "N/A" else f"${bytes.toDouble / 1000.0 / 1000.0 / 
(ms.toDouble / 1000.0)}%.2f"
     def pct(part: Long, total: Long): String =
       if (total <= 0) "N/A" else f"${part.toDouble * 100.0 / 
total.toDouble}%.1f%%"
+    def assignmentSummary: String =
+      if (workerSlots.shuffles == 0) "N/A"
+      else {
+        s"Shuffles: ${workerSlots.shuffles} | Workers: ${workerRows.size} | " +
+          s"Reserved Slots: ${workerRows.map(_._3.toLong).sum}"
+      }
+    val assignmentsTitle =
+      if (workerSlots.shuffles > assignments.size) {
+        s"Shuffle Assignments (${workerSlots.shuffles}, only showing 
${assignments.size})"
+      } else {
+        "Shuffle Assignments"
+      }
+
     // 0 when not reported, e.g. by old clients.
     def compressionRatio: String =
       if (uncompressedBytes <= 0 || compressedBytes <= 0) "N/A"
@@ -104,6 +123,10 @@ private[celeborn] class CelebornShufflePage(parent: 
CelebornUITab)
           durationMs)}, Read ${pct(readMs, durationMs)})"
       }
           </li>
+          <li>
+            <strong>Shuffle Assignments: </strong>
+            {assignmentSummary}
+          </li>
         </ul>
       </div>
 
@@ -114,6 +137,25 @@ private[celeborn] class CelebornShufflePage(parent: 
CelebornUITab)
       fixedWidth = true,
       headerClasses = headerClasses)
 
+    val shuffleAssignmentTable = UIUtils.listingTable(
+      Seq(
+        "Shuffle ID",
+        "Celeborn Shuffle ID",
+        "Workers",
+        "Reserved Slots",
+        "Slots per Worker (Min / Avg / Max)"),
+      shuffleAssignmentRow,
+      assignments,
+      fixedWidth = true,
+      headerClasses = Seq.fill(5)("sorttable_numeric"))
+
+    val workerSlotsTable = UIUtils.listingTable(
+      Seq("Worker", "Shuffles", "Reserved Slots"),
+      workerSlotsRow,
+      workerRows,
+      fixedWidth = true,
+      headerClasses = Seq("sorttable_alpha", "sorttable_numeric", 
"sorttable_numeric"))
+
     val buildInfoTable = UIUtils.listingTable(
       propertyHeader,
       propertyRow,
@@ -124,6 +166,28 @@ private[celeborn] class CelebornShufflePage(parent: 
CelebornUITab)
     val content =
       <span>
         {summary}
+        <span class="collapse-aggregated-celebornShuffleAssignments 
collapse-table"
+            
onClick="collapseTable('collapse-aggregated-celebornShuffleAssignments',
+            'aggregated-celebornShuffleAssignments')">
+          <h4>
+            <span class="collapse-table-arrow arrow-closed"></span>
+            <a>{assignmentsTitle}</a>
+          </h4>
+        </span>
+        <div class="aggregated-celebornShuffleAssignments collapsible-table 
collapsed">
+          {shuffleAssignmentTable}
+        </div>
+        <span class="collapse-aggregated-celebornWorkerSlots collapse-table"
+            onClick="collapseTable('collapse-aggregated-celebornWorkerSlots',
+            'aggregated-celebornWorkerSlots')">
+          <h4>
+            <span class="collapse-table-arrow arrow-closed"></span>
+            <a>Worker Slots</a>
+          </h4>
+        </span>
+        <div class="aggregated-celebornWorkerSlots collapsible-table 
collapsed">
+          {workerSlotsTable}
+        </div>
         <span class="collapse-aggregated-celebornBuildInfo collapse-table"
             onClick="collapseTable('collapse-aggregated-celebornBuildInfo',
             'aggregated-celebornBuildInfo')">
@@ -154,6 +218,29 @@ private[celeborn] class CelebornShufflePage(parent: 
CelebornUITab)
   private def propertyHeader: Seq[String] = Seq("Name", "Value")
   private def headerClasses: Seq[String] = Seq("sorttable_alpha", 
"sorttable_alpha")
 
+  private def shuffleAssignmentRow(assignment: ShuffleAssignmentUIData): 
Seq[Node] = {
+    val perWorker =
+      if (assignment.workers == 0) "N/A"
+      else {
+        f"${assignment.minWorkerSlots} / ${assignment.slots.toDouble / 
assignment.workers}%.1f / " +
+          s"${assignment.maxWorkerSlots}"
+      }
+    <tr>
+      <td>{assignment.appShuffleId}</td>
+      <td>{assignment.shuffleId}</td>
+      <td>{assignment.workers}</td>
+      <td>{assignment.slots}</td>
+      <td 
sorttable_customkey={assignment.maxWorkerSlots.toString}>{perWorker}</td>
+    </tr>
+  }
+
+  private def workerSlotsRow(row: (String, Int, Int)): Seq[Node] =
+    <tr>
+      <td>{row._1}</td>
+      <td>{row._2}</td>
+      <td>{row._3}</td>
+    </tr>
+
   private def propertyRow(kv: (String, String)): Seq[Node] =
     <tr>
       <td>{kv._1}</td>
diff --git 
a/client-spark/spark-3-ui/src/test/scala/org/apache/spark/shuffle/celeborn/CelebornListenerSuite.scala
 
b/client-spark/spark-3-ui/src/test/scala/org/apache/spark/shuffle/celeborn/CelebornListenerSuite.scala
index e2314ed4f8..f7192039f1 100644
--- 
a/client-spark/spark-3-ui/src/test/scala/org/apache/spark/shuffle/celeborn/CelebornListenerSuite.scala
+++ 
b/client-spark/spark-3-ui/src/test/scala/org/apache/spark/shuffle/celeborn/CelebornListenerSuite.scala
@@ -20,9 +20,10 @@ package org.apache.spark.shuffle.celeborn
 import com.fasterxml.jackson.databind.ObjectMapper
 import org.apache.spark.{SparkConf, Success, TaskEndReason, TaskKilled, 
TaskState}
 import org.apache.spark.executor.TaskMetrics
-import org.apache.spark.internal.config.Status.ASYNC_TRACKING_ENABLED
+import org.apache.spark.internal.config.Status.{ASYNC_TRACKING_ENABLED, 
MAX_RETAINED_STAGES}
 import org.apache.spark.scheduler.{JobSucceeded, 
SparkListenerEnvironmentUpdate, SparkListenerEvent, SparkListenerJobEnd, 
SparkListenerTaskEnd, TaskInfo, TaskLocality}
 import org.apache.spark.status.ElementTrackingStore
+import org.apache.spark.status.KVUtils.KVStoreScalaSerializer
 import org.apache.spark.util.Utils
 import org.apache.spark.util.kvstore.InMemoryStore
 import org.junit.Assert.{assertEquals, assertFalse, assertTrue}
@@ -317,4 +318,51 @@ class CelebornListenerSuite {
     assertEquals("abcdef12", info("Commit Id"))
     assertEquals("abcdef1234567890", info("Revision"))
   }
+
+  @Test
+  def shuffleAssignmentPersistedFromPostedEvent(): Unit = {
+    val store = new InMemoryStore()
+    val statusStore = new CelebornStatusStore(store)
+    val listener = new CelebornListener(store, new SparkConf())
+
+    listener.onOtherEvent(CelebornShuffleAssignmentEvent(3, 1, Map("w1" -> 2, 
"w2" -> 4)))
+    listener.onOtherEvent(CelebornShuffleAssignmentEvent(4, 2, Map("w2" -> 1)))
+
+    assertEquals(
+      Seq(ShuffleAssignmentUIData(3, 1, 2, 6, 2, 4), 
ShuffleAssignmentUIData(4, 2, 1, 1, 1, 1)),
+      statusStore.shuffleAssignments())
+    assertEquals(
+      WorkerSlotsUIData(2, Map("w1" -> 1, "w2" -> 2), Map("w1" -> 2, "w2" -> 
5)),
+      statusStore.workerSlots())
+  }
+
+  @Test
+  def workerSlotsCoverShuffleAssignmentsBeyondRetainedStages(): Unit = {
+    val store = new InMemoryStore()
+    val statusStore = new CelebornStatusStore(store)
+    val listener = new CelebornListener(store, new 
SparkConf().set(MAX_RETAINED_STAGES, 2))
+
+    (0 until 3).foreach { id =>
+      listener.onOtherEvent(CelebornShuffleAssignmentEvent(id, id, Map("w1" -> 
1)))
+    }
+
+    assertEquals(Seq(1, 2), statusStore.shuffleAssignments().map(_.shuffleId))
+    assertEquals(WorkerSlotsUIData(3, Map("w1" -> 3), Map("w1" -> 3)), 
statusStore.workerSlots())
+  }
+
+  @Test
+  def shuffleAssignmentKVStoreRoundTrip(): Unit = {
+    // History Server disk stores persist UI data through this serializer.
+    val serializer = new KVStoreScalaSerializer()
+    val assignment = ShuffleAssignmentUIData(5, 1, 2, 4, 1, 3)
+    val workerSlots = WorkerSlotsUIData(1, Map("w1" -> 1, "w2" -> 1), Map("w1" 
-> 3, "w2" -> 1))
+
+    assertEquals(
+      assignment,
+      serializer.deserialize(serializer.serialize(assignment), 
classOf[ShuffleAssignmentUIData]))
+    val restored =
+      serializer.deserialize(serializer.serialize(workerSlots), 
classOf[WorkerSlotsUIData])
+    assertEquals(workerSlots, restored)
+    assertEquals(4, restored.workerSlots.values.sum)
+  }
 }
diff --git 
a/client-spark/spark-3-ui/src/test/scala/org/apache/spark/shuffle/celeborn/CelebornPluginSuite.scala
 
b/client-spark/spark-3-ui/src/test/scala/org/apache/spark/shuffle/celeborn/CelebornPluginSuite.scala
new file mode 100644
index 0000000000..07f20578a1
--- /dev/null
+++ 
b/client-spark/spark-3-ui/src/test/scala/org/apache/spark/shuffle/celeborn/CelebornPluginSuite.scala
@@ -0,0 +1,53 @@
+/*
+ * 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.celeborn
+
+import scala.collection.JavaConverters._
+
+import org.apache.spark.{SparkConf, SparkContext, SparkEnv}
+import org.junit.Assert.assertEquals
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.junit.runners.JUnit4
+
+@RunWith(classOf[JUnit4])
+class CelebornPluginSuite {
+
+  @Test
+  def shuffleAssignmentsReportedThroughSparkShuffleManager(): Unit = {
+    val conf = new SparkConf()
+      .setMaster("local")
+      .setAppName("CelebornPluginSuite")
+      .set("spark.plugins", classOf[CelebornPlugin].getName)
+      .set("spark.shuffle.manager", classOf[SparkShuffleManager].getName)
+      .set("spark.ui.enabled", "false")
+    val sc = new SparkContext(conf)
+    try {
+      val listener =
+        
SparkEnv.get.shuffleManager.asInstanceOf[SparkShuffleManager].getShuffleAssignmentListener
+      listener.onShuffleAssigned(3, 1, Map[String, Integer]("w1" -> 2, "w2" -> 
4).asJava)
+      sc.listenerBus.waitUntilEmpty()
+
+      assertEquals(
+        Seq(ShuffleAssignmentUIData(3, 1, 2, 6, 2, 4)),
+        new CelebornStatusStore(sc.statusStore.store).shuffleAssignments())
+    } finally {
+      sc.stop()
+    }
+  }
+}
diff --git 
a/client-spark/spark-3/src/main/java/org/apache/spark/shuffle/celeborn/SparkShuffleManager.java
 
b/client-spark/spark-3/src/main/java/org/apache/spark/shuffle/celeborn/SparkShuffleManager.java
index 7b5a2fbd65..b731d4923f 100644
--- 
a/client-spark/spark-3/src/main/java/org/apache/spark/shuffle/celeborn/SparkShuffleManager.java
+++ 
b/client-spark/spark-3/src/main/java/org/apache/spark/shuffle/celeborn/SparkShuffleManager.java
@@ -36,6 +36,7 @@ import org.slf4j.LoggerFactory;
 
 import org.apache.celeborn.client.LifecycleManager;
 import org.apache.celeborn.client.ShuffleClient;
+import org.apache.celeborn.client.listener.ShuffleAssignmentListener;
 import org.apache.celeborn.client.security.CryptoHandler;
 import org.apache.celeborn.common.CelebornConf;
 import org.apache.celeborn.common.protocol.FallbackPolicy;
@@ -99,6 +100,7 @@ public class SparkShuffleManager implements ShuffleManager {
   private String appUniqueId;
 
   private LifecycleManager lifecycleManager;
+  private ShuffleAssignmentListener shuffleAssignmentListener;
   private ShuffleClient shuffleClient;
   private volatile SortShuffleManager _sortShuffleManager;
   private final ConcurrentHashMap.KeySetView<Integer, Boolean> sortShuffleIds =
@@ -192,6 +194,9 @@ public class SparkShuffleManager implements ShuffleManager {
           lifecycleManager = new LifecycleManager(appUniqueId, celebornConf);
           lifecycleManager.applicationCount().increment();
           
lifecycleManager.registerCancelShuffleCallback(SparkUtils::cancelShuffle);
+          if (shuffleAssignmentListener != null) {
+            
lifecycleManager.registerShuffleAssignmentListener(shuffleAssignmentListener);
+          }
           // Register only when the UI plugin is opted in, otherwise every map 
task
           // posts an event nobody consumes and each one flushes the event log.
           if (SparkUtils.isCelebornPluginEnabled(conf)) {
@@ -559,6 +564,16 @@ public class SparkShuffleManager implements ShuffleManager 
{
     }
   }
 
+  /** Registers a listener for the LifecycleManager, which is created on the 
first shuffle. */
+  public synchronized void 
registerShuffleAssignmentListener(ShuffleAssignmentListener listener) {
+    shuffleAssignmentListener = listener;
+  }
+
+  // for testing
+  public synchronized ShuffleAssignmentListener getShuffleAssignmentListener() 
{
+    return shuffleAssignmentListener;
+  }
+
   // for testing
   public LifecycleManager getLifecycleManager() {
     return this.lifecycleManager;
diff --git 
a/client/src/main/java/org/apache/celeborn/client/listener/ShuffleAssignmentListener.java
 
b/client/src/main/java/org/apache/celeborn/client/listener/ShuffleAssignmentListener.java
new file mode 100644
index 0000000000..41b082eab4
--- /dev/null
+++ 
b/client/src/main/java/org/apache/celeborn/client/listener/ShuffleAssignmentListener.java
@@ -0,0 +1,33 @@
+/*
+ * 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.celeborn.client.listener;
+
+import java.util.Map;
+
+/** The Listener about the slots reserved for newly registered shuffles. */
+public interface ShuffleAssignmentListener {
+
+  /**
+   * Notifies the slots reserved on each worker once a shuffle is registered.
+   *
+   * @param shuffleId the Celeborn shuffle id
+   * @param appShuffleId the application shuffle id, which differs on stage 
rerun
+   * @param workerSlots slots reserved on each worker, keyed by its unique id
+   */
+  void onShuffleAssigned(int shuffleId, int appShuffleId, Map<String, Integer> 
workerSlots);
+}
diff --git 
a/client/src/main/scala/org/apache/celeborn/client/LifecycleManager.scala 
b/client/src/main/scala/org/apache/celeborn/client/LifecycleManager.scala
index 77ff0715ae..7074899fc1 100644
--- a/client/src/main/scala/org/apache/celeborn/client/LifecycleManager.scala
+++ b/client/src/main/scala/org/apache/celeborn/client/LifecycleManager.scala
@@ -39,7 +39,7 @@ import com.google.common.annotations.VisibleForTesting
 import com.google.common.cache.{Cache, CacheBuilder}
 
 import org.apache.celeborn.client.LifecycleManager.{ShuffleAllocatedWorkers, 
ShuffleFailedWorkers}
-import org.apache.celeborn.client.listener.{MapperEndCallback, 
WorkerStatusListener}
+import org.apache.celeborn.client.listener.{MapperEndCallback, 
ShuffleAssignmentListener, WorkerStatusListener}
 import org.apache.celeborn.common.{CelebornConf, CommitMetadata}
 import org.apache.celeborn.common.CelebornConf.ACTIVE_STORAGE_TYPES
 import org.apache.celeborn.common.client.{ApplicationInfoProvider, 
MasterClient}
@@ -904,6 +904,28 @@ class LifecycleManager(val appUniqueId: String, val conf: 
CelebornConf) extends
         StatusCode.SUCCESS,
         allPrimaryPartitionLocations,
         serdeVersion))
+      reportShuffleAssignment(shuffleId, slots)
+    }
+  }
+
+  private def reportShuffleAssignment(shuffleId: Int, slots: WorkerResource): 
Unit = {
+    shuffleAssignmentListener.foreach { listener =>
+      val workerSlots = new util.HashMap[String, Integer]()
+      slots.asScala.foreach { case (workerInfo, (primaryLocations, 
replicaLocations)) =>
+        val numSlots = primaryLocations.size + replicaLocations.size
+        if (numSlots > 0) {
+          workerSlots.put(workerInfo.toUniqueId, numSlots)
+        }
+      }
+      try {
+        listener.onShuffleAssigned(
+          shuffleId,
+          celebornShuffleIdToAppShuffleIdMap.getOrDefault(shuffleId, 
shuffleId),
+          workerSlots)
+      } catch {
+        case NonFatal(e) =>
+          logWarning(s"Failed to report the slot assignment of shuffle 
$shuffleId.", e)
+      }
     }
   }
 
@@ -2227,6 +2249,11 @@ class LifecycleManager(val appUniqueId: String, val 
conf: CelebornConf) extends
     celebornSkewShuffleCheckCallback = Some(callback)
   }
 
+  @volatile private var shuffleAssignmentListener: 
Option[ShuffleAssignmentListener] = None
+  def registerShuffleAssignmentListener(listener: ShuffleAssignmentListener): 
Unit = {
+    shuffleAssignmentListener = Some(listener)
+  }
+
   // Initialize at the end of LifecycleManager construction.
   initialize()
 
diff --git a/project/CelebornBuild.scala b/project/CelebornBuild.scala
index 8595f62f58..bf57c8c7b4 100644
--- a/project/CelebornBuild.scala
+++ b/project/CelebornBuild.scala
@@ -1140,6 +1140,7 @@ trait SparkClientProjects {
         commonSettings,
         libraryDependencies ++= Seq(
           "org.apache.spark" %% "spark-core" % sparkVersion % "provided",
+          "org.apache.spark" %% "spark-sql" % sparkVersion % "test",
           Dependencies.javaxServletApi % "provided",
           Dependencies.jakartaServletApi % "provided"
         ) ++ commonUnitTestDependencies,
diff --git 
a/tests/spark-it/src/test/scala/org/apache/celeborn/tests/client/LifecycleManagerSuite.scala
 
b/tests/spark-it/src/test/scala/org/apache/celeborn/tests/client/LifecycleManagerSuite.scala
index ade825596f..75892d70c2 100644
--- 
a/tests/spark-it/src/test/scala/org/apache/celeborn/tests/client/LifecycleManagerSuite.scala
+++ 
b/tests/spark-it/src/test/scala/org/apache/celeborn/tests/client/LifecycleManagerSuite.scala
@@ -18,6 +18,7 @@
 package org.apache.celeborn.tests.client
 
 import java.util
+import java.util.concurrent.ConcurrentLinkedQueue
 
 import scala.collection.JavaConverters._
 
@@ -131,6 +132,30 @@ class LifecycleManagerSuite extends WithShuffleClientSuite 
with MiniClusterFeatu
     lifecycleManager.stop()
   }
 
+  test("CELEBORN-2499: report the slots reserved on each worker for a 
registered shuffle") {
+    prepareService()
+    val reported = new ConcurrentLinkedQueue[(Int, Int, Map[String, Int])]()
+    lifecycleManager.registerShuffleAssignmentListener { (shuffleId, 
appShuffleId, workerSlots) =>
+      val slots = workerSlots.asScala.map { case (worker, n) => worker -> 
n.intValue }.toMap
+      reported.add((shuffleId, appShuffleId, slots))
+    }
+    val shuffleId = nextShuffleId
+    val numPartitions = 9
+
+    shuffleClient.getPartitionLocation(shuffleId, 8, numPartitions)
+
+    eventually(timeout(3.seconds), interval(10.milliseconds)) {
+      assert(reported.size == 1)
+    }
+    val (reportedShuffleId, appShuffleId, workerSlots) = reported.peek()
+    assert(reportedShuffleId == shuffleId)
+    assert(appShuffleId == shuffleId)
+    // Replication is enabled: a primary and a replica slot per partition.
+    assert(workerSlots.values.sum == 2 * numPartitions)
+    assert(workerSlots.values.forall(_ > 0))
+    
assert(workerSlots.keySet.subsetOf(workerInfos.keySet.map(_.workerInfo.toUniqueId)))
+  }
+
   test("CELEBORN-2479: mapper end callback reports only the accepted attempt") 
{
     prepareService()
     val numMappers = 8

Reply via email to