This is an automated email from the ASF dual-hosted git repository.
zhengruifeng 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 2f4bd74e30f5 [SPARK-58556][CONNECT][UI] Show ML cache status in Spark
Connect UI
2f4bd74e30f5 is described below
commit 2f4bd74e30f5e6b807dd6aa993d1391efc809496
Author: Ruifeng Zheng <[email protected]>
AuthorDate: Thu Aug 13 11:14:39 2026 +0800
[SPARK-58556][CONNECT][UI] Show ML cache status in Spark Connect UI
### What changes were proposed in this pull request?
This PR adds a live ML cache status column to the session table in the
Spark Connect UI.
For active sessions, the column shows the number of models currently in
memory and the current and maximum in-memory and total cache sizes. It also
reports when memory control is disabled or the ML cache has not been used.
Closed sessions and History Server views show `N/A` because their live cache
state is unavailable. The status covers cached models; `Summary` objects are
not included.
The UI reads the cache status without updating the session's last-access
time or initializing an unused ML cache.
This PR also fixes removal of already-offloaded models. Their on-disk data
and registered size are now removed correctly, and removal reports success when
the model metadata existed even if the in-memory value had already been
offloaded.
### Why are the changes needed?
Spark Connect ML models are cached per session, but operators currently
cannot inspect cache utilization in the Spark UI. Showing the live status makes
it easier to understand model retention, memory pressure, and disk offloading
for each session.
The removal fix prevents offloaded model data and size accounting from
being retained after explicit removal.
### Does this PR introduce _any_ user-facing change?
Yes. The Spark Connect session table has a new `ML Cache` column showing
live per-session cache utilization. Explicitly removing an offloaded model now
also removes its on-disk data and size accounting.
### How was this patch tested?
Added unit coverage for ML cache status collection and Connect UI
rendering, including the live session-manager-to-page path and assertions that
rendering does not update last-access time or initialize an unused cache.
`build/sbt 'connect/testOnly
org.apache.spark.sql.connect.ui.SparkConnectServerPageSuite'`
passes (7 tests). Scalafmt and `git diff --check` pass.
### Was this patch authored or co-authored using generative AI tooling?
Generated-by: Codex (GPT-5)
Closes #57754 from zhengruifeng/connect-ml-cache-status-ui.
Authored-by: Ruifeng Zheng <[email protected]>
Signed-off-by: Ruifeng Zheng <[email protected]>
---
.../org/apache/spark/sql/connect/ml/MLCache.scala | 69 +++++-
.../spark/sql/connect/service/SessionHolder.scala | 13 +-
.../sql/connect/service/SparkConnectService.scala | 3 +-
.../service/SparkConnectSessionManager.scala | 11 +
.../sql/connect/ui/SparkConnectServerPage.scala | 248 ++++++++++++++++++++-
.../sql/connect/ui/SparkConnectServerTab.scala | 8 +-
.../org/apache/spark/sql/connect/ui/ToolTips.scala | 12 +
.../org/apache/spark/sql/connect/ml/MLSuite.scala | 34 +++
.../connect/ui/SparkConnectServerPageSuite.scala | 186 +++++++++++++++-
9 files changed, 563 insertions(+), 21 deletions(-)
diff --git
a/sql/connect/server/src/main/scala/org/apache/spark/sql/connect/ml/MLCache.scala
b/sql/connect/server/src/main/scala/org/apache/spark/sql/connect/ml/MLCache.scala
index 5deb9ce1c3f1..91bc09afacf7 100644
---
a/sql/connect/server/src/main/scala/org/apache/spark/sql/connect/ml/MLCache.scala
+++
b/sql/connect/server/src/main/scala/org/apache/spark/sql/connect/ml/MLCache.scala
@@ -23,6 +23,7 @@ import java.util.concurrent.{ConcurrentHashMap,
ConcurrentMap, TimeUnit}
import java.util.concurrent.atomic.{AtomicBoolean, AtomicLong}
import scala.collection.mutable
+import scala.jdk.CollectionConverters._
import scala.util.control.NonFatal
import com.google.common.cache.{CacheBuilder, RemovalNotification}
@@ -74,14 +75,28 @@ private[connect] class MLCache(sessionHolder:
SessionHolder) extends Logging {
Connect.CONNECT_SESSION_CONNECT_ML_CACHE_MEMORY_CONTROL_OFFLOADING_TIMEOUT)
}
+ private case class ModelMetadata(
+ className: String,
+ modelString: String,
+ estimatedSizeBytes: Option[Long])
+
+ // Keep lightweight metadata after a model is evicted from memory so the UI
can report
+ // offloaded models without loading them back into memory.
+ private val cachedModelMetadata = new ConcurrentHashMap[String,
ModelMetadata]()
+ private val inMemoryModelIds = ConcurrentHashMap.newKeySet[String]()
+
private[ml] case class CacheItem(obj: Object, sizeBytes: Long)
private[ml] val cachedModel: ConcurrentMap[String, CacheItem] = {
if (getMemoryControlEnabled) {
CacheBuilder
.newBuilder()
.softValues()
- .removalListener((removed: RemovalNotification[String, CacheItem]) =>
- totalMLCacheInMemorySizeBytes.addAndGet(-removed.getValue.sizeBytes))
+ .removalListener((removed: RemovalNotification[String, CacheItem]) => {
+ Option(removed.getValue).foreach { value =>
+ totalMLCacheInMemorySizeBytes.addAndGet(-value.sizeBytes)
+ }
+ inMemoryModelIds.remove(removed.getKey)
+ })
.maximumWeight(getMaxInMemoryCacheSizeKB)
.weigher((key: String, value: CacheItem) => {
Math.ceil(value.sizeBytes.toDouble / 1024).toInt
@@ -149,6 +164,7 @@ private[connect] class MLCache(sessionHolder:
SessionHolder) extends Logging {
} else {
0L // Don't need to calculate size if disables memory-control.
}
+ inMemoryModelIds.add(objectId)
cachedModel.put(objectId, CacheItem(obj, sizeBytes))
if (getMemoryControlEnabled) {
val savePath = getModelOffloadingPath(objectId)
@@ -163,6 +179,12 @@ private[connect] class MLCache(sessionHolder:
SessionHolder) extends Logging {
totalMLCacheInMemorySizeBytes.addAndGet(sizeBytes)
totalMLCacheSizeBytes.addAndGet(sizeBytes)
}
+ cachedModelMetadata.put(
+ objectId,
+ ModelMetadata(
+ obj.getClass.getName,
+ obj.toString,
+ if (getMemoryControlEnabled) Some(sizeBytes) else None))
} else {
throw new RuntimeException("'MLCache.register' only accepts model or
summary objects.")
}
@@ -219,6 +241,7 @@ private[connect] class MLCache(sessionHolder:
SessionHolder) extends Logging {
loadPath.toString,
loadFromLocal = true)
val sizeBytes = estimateObjectSize(obj)
+ inMemoryModelIds.add(refId)
cachedModel.put(refId, CacheItem(obj, sizeBytes))
totalMLCacheInMemorySizeBytes.addAndGet(sizeBytes)
}
@@ -231,6 +254,7 @@ private[connect] class MLCache(sessionHolder:
SessionHolder) extends Logging {
verifyObjectId(refId)
val removedModel = cachedModel.remove(refId)
val removedFromMem = removedModel != null
+ inMemoryModelIds.remove(refId)
val removedFromDisk = if (!evictOnly && removedModel != null &&
getMemoryControlEnabled) {
totalMLCacheSizeBytes.addAndGet(-removedModel.sizeBytes)
val removePath = getModelOffloadingPath(refId)
@@ -244,6 +268,9 @@ private[connect] class MLCache(sessionHolder:
SessionHolder) extends Logging {
} else {
false
}
+ if (removedFromMem && (!evictOnly || !getMemoryControlEnabled)) {
+ cachedModelMetadata.remove(refId)
+ }
removedFromMem || removedFromDisk
}
@@ -264,6 +291,9 @@ private[connect] class MLCache(sessionHolder:
SessionHolder) extends Logging {
def clear(): Int = this.synchronized {
val size = cachedModel.size()
cachedModel.clear()
+ cachedModelMetadata.clear()
+ inMemoryModelIds.clear()
+ totalMLCacheInMemorySizeBytes.set(0)
totalMLCacheSizeBytes.set(0)
if (getMemoryControlEnabled) {
SparkFileUtils.cleanDirectory(new File(offloadedModelsDir.toString))
@@ -280,4 +310,39 @@ private[connect] class MLCache(sessionHolder:
SessionHolder) extends Logging {
}
info.result()
}
+
+ /** Returns a cache snapshot without loading or touching any cached model. */
+ def getStatus: MLCacheStatus = this.synchronized {
+ val models = cachedModelMetadata.asScala.iterator.map { case (id,
metadata) =>
+ MLCacheModelInfo(
+ id = id,
+ className = metadata.className,
+ modelString = metadata.modelString,
+ estimatedSizeBytes = metadata.estimatedSizeBytes,
+ inMemory = inMemoryModelIds.contains(id))
+ }.toSeq
+ MLCacheStatus(
+ memoryControlEnabled = getMemoryControlEnabled,
+ inMemorySizeBytes = totalMLCacheInMemorySizeBytes.get(),
+ maxInMemorySizeBytes = sessionHolder.session.conf.get(
+
Connect.CONNECT_SESSION_CONNECT_ML_CACHE_MEMORY_CONTROL_MAX_IN_MEMORY_SIZE),
+ totalSizeBytes = totalMLCacheSizeBytes.get(),
+ maxTotalSizeBytes = getMLCacheMaxSize,
+ models = models)
+ }
}
+
+private[connect] case class MLCacheModelInfo(
+ id: String,
+ className: String,
+ modelString: String,
+ estimatedSizeBytes: Option[Long],
+ inMemory: Boolean)
+
+private[connect] case class MLCacheStatus(
+ memoryControlEnabled: Boolean,
+ inMemorySizeBytes: Long,
+ maxInMemorySizeBytes: Long,
+ totalSizeBytes: Long,
+ maxTotalSizeBytes: Long,
+ models: Seq[MLCacheModelInfo])
diff --git
a/sql/connect/server/src/main/scala/org/apache/spark/sql/connect/service/SessionHolder.scala
b/sql/connect/server/src/main/scala/org/apache/spark/sql/connect/service/SessionHolder.scala
index 2276230545e6..e97bcb0c786e 100644
---
a/sql/connect/server/src/main/scala/org/apache/spark/sql/connect/service/SessionHolder.scala
+++
b/sql/connect/server/src/main/scala/org/apache/spark/sql/connect/service/SessionHolder.scala
@@ -38,7 +38,7 @@ import org.apache.spark.sql.classic.SparkSession
import org.apache.spark.sql.connect.IllegalStateErrors
import org.apache.spark.sql.connect.common.InvalidPlanInput
import org.apache.spark.sql.connect.config.Connect
-import org.apache.spark.sql.connect.ml.MLCache
+import org.apache.spark.sql.connect.ml.{MLCache, MLCacheStatus}
import org.apache.spark.sql.connect.pipelines.DataflowGraphRegistry
import org.apache.spark.sql.connect.planner.PythonStreamingQueryListener
import org.apache.spark.sql.connect.planner.StreamingForeachBatchHelper
@@ -132,7 +132,16 @@ case class SessionHolder(userId: String, sessionId:
String, session: SparkSessio
new ConcurrentHashMap()
// ML model cache
- private[connect] lazy val mlCache = new MLCache(this)
+ @volatile private var mlCacheInitialized = false
+ private[connect] lazy val mlCache = {
+ val cache = new MLCache(this)
+ mlCacheInitialized = true
+ cache
+ }
+
+ private[connect] def getMLCacheStatus: Option[MLCacheStatus] = {
+ if (mlCacheInitialized) Some(mlCache.getStatus) else None
+ }
// Mapping from id to StreamingQueryListener. Used for methods like
removeListener() in
// StreamingQueryManager.
diff --git
a/sql/connect/server/src/main/scala/org/apache/spark/sql/connect/service/SparkConnectService.scala
b/sql/connect/server/src/main/scala/org/apache/spark/sql/connect/service/SparkConnectService.scala
index c76794e3b6ec..0c18a7491c1f 100644
---
a/sql/connect/server/src/main/scala/org/apache/spark/sql/connect/service/SparkConnectService.scala
+++
b/sql/connect/server/src/main/scala/org/apache/spark/sql/connect/service/SparkConnectService.scala
@@ -386,7 +386,8 @@ object SparkConnectService extends Logging {
Some(
new SparkConnectServerTab(
new SparkConnectServerAppStatusStore(kvStore),
- SparkConnectServerTab.getSparkUI(sc)))
+ SparkConnectServerTab.getSparkUI(sc),
+ Some(sessionManager)))
} else {
None
}
diff --git
a/sql/connect/server/src/main/scala/org/apache/spark/sql/connect/service/SparkConnectSessionManager.scala
b/sql/connect/server/src/main/scala/org/apache/spark/sql/connect/service/SparkConnectSessionManager.scala
index d3ddf592e9e7..c7558465a0b9 100644
---
a/sql/connect/server/src/main/scala/org/apache/spark/sql/connect/service/SparkConnectSessionManager.scala
+++
b/sql/connect/server/src/main/scala/org/apache/spark/sql/connect/service/SparkConnectSessionManager.scala
@@ -32,6 +32,7 @@ import org.apache.spark.internal.Logging
import org.apache.spark.internal.LogKeys.{INTERVAL, SESSION_HOLD_INFO}
import org.apache.spark.sql.classic.SparkSession
import
org.apache.spark.sql.connect.config.Connect.{CONNECT_SESSION_MANAGER_CLOSED_SESSIONS_TOMBSTONES_SIZE,
CONNECT_SESSION_MANAGER_DEFAULT_SESSION_TIMEOUT,
CONNECT_SESSION_MANAGER_MAINTENANCE_INTERVAL}
+import org.apache.spark.sql.connect.ml.MLCacheStatus
import org.apache.spark.util.ThreadUtils
/**
@@ -284,6 +285,16 @@ class SparkConnectSessionManager extends Logging {
closedSessionsCache.asMap.asScala.values.toSeq
}
+ // Read live cache state directly without updating the sessions' last-access
times.
+ private[connect] def getMLCacheStatuses: Map[SessionKey,
Option[MLCacheStatus]] = {
+ sessionStore
+ .entrySet()
+ .asScala
+ .iterator
+ .map(entry => entry.getKey -> entry.getValue.getMLCacheStatus)
+ .toMap
+ }
+
/**
* Schedules periodic maintenance checks if it is not already scheduled.
*
diff --git
a/sql/connect/server/src/main/scala/org/apache/spark/sql/connect/ui/SparkConnectServerPage.scala
b/sql/connect/server/src/main/scala/org/apache/spark/sql/connect/ui/SparkConnectServerPage.scala
index ea78b2dc59f6..0d55c2e7866f 100644
---
a/sql/connect/server/src/main/scala/org/apache/spark/sql/connect/ui/SparkConnectServerPage.scala
+++
b/sql/connect/server/src/main/scala/org/apache/spark/sql/connect/ui/SparkConnectServerPage.scala
@@ -26,6 +26,8 @@ import scala.xml.Node
import jakarta.servlet.http.HttpServletRequest
import org.apache.spark.internal.Logging
+import org.apache.spark.sql.connect.ml.{MLCacheModelInfo, MLCacheStatus}
+import org.apache.spark.sql.connect.service.SessionKey
import org.apache.spark.sql.connect.ui.ToolTips._
import org.apache.spark.ui._
import org.apache.spark.ui.UIUtils._
@@ -52,6 +54,12 @@ private[ui] class SparkConnectServerPage(parent:
SparkConnectServerTab)
/** Render the page */
def render(request: HttpServletRequest): Seq[Node] = {
+ // Do not hold the status store lock while waiting for live ML cache
snapshots. ML cache
+ // operations can perform disk I/O while holding their own locks.
+ val mlCacheStatuses = parent.getMLCacheStatuses
+ val initializedMLCacheStatuses =
mlCacheStatuses.toSeq.flatMap(_.iterator.collect {
+ case (key, Some(status)) => key -> status
+ })
val content = store.synchronized { // make sure all parts in this page are
consistent
generateBasicStats() ++
<br/> ++
@@ -62,8 +70,9 @@ private[ui] class SparkConnectServerPage(parent:
SparkConnectServerTab)
{store.getTotalRunning}
Request(s)
</h4> ++
- generateSessionStatsTable(request) ++
- generateSQLStatsTable(request)
+ generateSessionStatsTable(request, mlCacheStatuses) ++
+ generateSQLStatsTable(request) ++
+ generateMLCacheStatsTable(request, initializedMLCacheStatuses)
}
UIUtils.headerSparkPage(request, "Spark Connect", content, parent)
}
@@ -132,7 +141,9 @@ private[ui] class SparkConnectServerPage(parent:
SparkConnectServerTab)
}
/** Generate stats of batch sessions of the Spark Connect server */
- private def generateSessionStatsTable(request: HttpServletRequest):
Seq[Node] = {
+ private def generateSessionStatsTable(
+ request: HttpServletRequest,
+ mlCacheStatuses: Option[Map[SessionKey, Option[MLCacheStatus]]]):
Seq[Node] = {
val numSessions = store.getSessionList.size
val table = if (numSessions > 0) {
@@ -149,7 +160,8 @@ private[ui] class SparkConnectServerPage(parent:
SparkConnectServerTab)
store.getSessionList,
"connect",
UIUtils.prependBaseUri(request, parent.basePath),
- sessionTableTag).table(sessionTablePage))
+ sessionTableTag,
+ mlCacheStatuses).table(sessionTablePage))
} catch {
case e @ (_: IllegalArgumentException | _: IndexOutOfBoundsException)
=>
Some(<div class="alert alert-danger">
@@ -179,6 +191,83 @@ private[ui] class SparkConnectServerPage(parent:
SparkConnectServerTab)
content
}
+
+ /** Generate live ML cache statistics for active Spark Connect sessions. */
+ private def generateMLCacheStatsTable(
+ request: HttpServletRequest,
+ mlCacheStatuses: Seq[(SessionKey, MLCacheStatus)]): Seq[Node] = {
+ val populatedCacheStatuses = mlCacheStatuses.filter(_._2.models.nonEmpty)
+ val models = populatedCacheStatuses.flatMap { case (key, status) =>
+ status.models.map(MLCacheModelTableRow(key.userId, key.sessionId, _))
+ }
+ if (models.isEmpty) {
+ return Seq.empty
+ }
+
+ val tableTag = "mlcachemodels"
+ val tablePage =
Option(request.getParameter(s"$tableTag.page")).map(_.toInt).getOrElse(1)
+ val table =
+ try {
+ new MLCacheModelStatsPagedTable(
+ request,
+ parent,
+ models,
+ "connect",
+ UIUtils.prependBaseUri(request, parent.basePath),
+ tableTag).table(tablePage)
+ } catch {
+ case e @ (_: IllegalArgumentException | _: IndexOutOfBoundsException)
=>
+ <div class="alert alert-danger">
+ <p>Error while rendering ML cache table:</p>
+ <pre>
+ {Utils.exceptionString(e)}
+ </pre>
+ </div>
+ }
+
+ val inMemoryModels = models.count(_.model.inMemory)
+ val memoryControlledStatuses =
+ populatedCacheStatuses.map(_._2).filter(_.memoryControlEnabled)
+ val inMemorySize = memoryControlledStatuses.map(s =>
BigInt(s.inMemorySizeBytes)).sum
+ val maxInMemorySize = memoryControlledStatuses.map(s =>
BigInt(s.maxInMemorySizeBytes)).sum
+ val totalSize = memoryControlledStatuses.map(s =>
BigInt(s.totalSizeBytes)).sum
+ val maxTotalSize = memoryControlledStatuses.map(s =>
BigInt(s.maxTotalSizeBytes)).sum
+ val sizeStats = if (memoryControlledStatuses.nonEmpty) {
+ Seq(
+ <li>
+ <strong>Estimated size (In-memory): </strong>
+ {Utils.bytesToString(inMemorySize)} /
{Utils.bytesToString(maxInMemorySize)}
+ </li>,
+ <li>
+ <strong>Estimated size (In-memory and Offloaded data): </strong>
+ {Utils.bytesToString(totalSize)} /
{Utils.bytesToString(maxTotalSize)}
+ </li>)
+ } else {
+ Seq.empty
+ }
+
+ <span id="mlcachestat" class="collapse-table" data-bs-toggle="collapse"
+ data-bs-target="#aggregated-mlcachestat"
+ aria-expanded="true" aria-controls="aggregated-mlcachestat"
+ data-collapse-name="collapse-aggregated-mlcachestat">
+ <h4>
+ <span class="collapse-table-arrow arrow-open"></span>
+ <a>ML Cache Statistics ({models.size})</a>
+ </h4>
+ </span> ++
+ <div class="collapsible-table collapse show" id="aggregated-mlcachestat">
+ <ul class="list-unstyled">
+ <li><strong>Sessions with cached models:
</strong>{populatedCacheStatuses.size}</li>
+ <li>
+ <strong>Cached models: </strong>
+ {models.size} ({inMemoryModels} in memory, {models.size -
inMemoryModels} offloaded)
+ </li>
+ {sizeStats}
+ </ul>
+ <h5>Cached Models</h5>
+ {table}
+ </div>
+ }
}
private[ui] class SqlStatsPagedTable(
@@ -361,7 +450,8 @@ private[ui] class SessionStatsPagedTable(
data: Seq[SessionInfo],
subPath: String,
basePath: String,
- sessionStatsTableTag: String)
+ sessionStatsTableTag: String,
+ mlCacheStatuses: Option[Map[SessionKey, Option[MLCacheStatus]]])
extends PagedTable[SessionInfo] {
private val (sortColumn, desc, pageSize) =
@@ -404,7 +494,8 @@ private[ui] class SessionStatsPagedTable(
("Start Time", true, None),
("Finish Time", true, None),
("Duration", true, Some(SPARK_CONNECT_SESSION_DURATION)),
- ("Total Execute", true, Some(SPARK_CONNECT_SESSION_TOTAL_EXECUTE)))
+ ("Total Execute", true, Some(SPARK_CONNECT_SESSION_TOTAL_EXECUTE)),
+ ("ML Cache", false, Some(SPARK_CONNECT_SESSION_ML_CACHE)))
isSortColumnValid(sessionTableHeadersAndTooltips, sortColumn)
@@ -431,8 +522,153 @@ private[ui] class SessionStatsPagedTable(
<td> {if (session.finishTimestamp > 0)
formatDate(session.finishTimestamp)} </td>
<td> {formatDurationVerbose(session.totalTime)} </td>
<td> {session.totalExecution.toString} </td>
+ <td> {renderMLCacheStatus(session)} </td>
</tr>
}
+
+ private def renderMLCacheStatus(session: SessionInfo): Seq[Node] = {
+ if (session.finishTimestamp > 0) {
+ <span>N/A</span>
+ } else {
+ mlCacheStatuses
+ .flatMap(_.get(SessionKey(session.userId, session.sessionId)))
+ .map {
+ case Some(status) if status.models.nonEmpty =>
+ if (status.memoryControlEnabled) {
+ val inMemoryModels = status.models.count(_.inMemory)
+ val modelLabel = if (inMemoryModels == 1) "model" else "models"
+ <span>
+ {s"$inMemoryModels $modelLabel in memory"}<br/>
+ {
+ s"${Utils.bytesToString(status.inMemorySizeBytes)} / " +
+ s"${Utils.bytesToString(status.maxInMemorySizeBytes)} memory"
+ }<br/>
+ {
+ s"${Utils.bytesToString(status.totalSizeBytes)} / " +
+ s"${Utils.bytesToString(status.maxTotalSizeBytes)} total"
+ }
+ </span>
+ } else {
+ val cachedModels = status.models.size
+ val modelLabel = if (cachedModels == 1) "model" else "models"
+ <span>
+ Memory control disabled<br/>
+ {s"$cachedModels cached $modelLabel"}
+ </span>
+ }
+ case Some(_) | None =>
+ <span>Not used</span>
+ }
+ .getOrElse(<span>N/A</span>)
+ }
+ }
+}
+
+private[ui] case class MLCacheModelTableRow(
+ userId: String,
+ sessionId: String,
+ model: MLCacheModelInfo)
+
+private[ui] class MLCacheModelStatsPagedTable(
+ request: HttpServletRequest,
+ parent: SparkConnectServerTab,
+ data: Seq[MLCacheModelTableRow],
+ subPath: String,
+ basePath: String,
+ tableTag: String)
+ extends PagedTable[MLCacheModelTableRow] {
+
+ private val (sortColumn, desc, pageSize) =
+ getTableParameters(request, tableTag, "Estimated Size")
+
+ private val encodedSortColumn = URLEncoder.encode(sortColumn, UTF_8.name())
+ private val parameterPath =
s"$basePath/$subPath/?${getParameterOtherTable(request, tableTag)}"
+
+ override val dataSource =
+ new MLCacheModelTableDataSource(data, pageSize, sortColumn, desc)
+
+ override def tableId: String = tableTag
+
+ override def tableCssClass: String =
+ "table table-bordered table-sm table-striped table-head-clickable
table-cell-width-limited"
+
+ override def pageLink(page: Int): String = {
+ parameterPath +
+ s"&$pageNumberFormField=$page" +
+ s"&$tableTag.sort=$encodedSortColumn" +
+ s"&$tableTag.desc=$desc" +
+ s"&$pageSizeFormField=$pageSize" +
+ s"#$tableTag"
+ }
+
+ override def pageSizeFormField: String = s"$tableTag.pageSize"
+
+ override def pageNumberFormField: String = s"$tableTag.page"
+
+ override def goButtonFormPath: String =
+ s"$parameterPath&$tableTag.sort=$encodedSortColumn" +
+ s"&$tableTag.desc=$desc#$tableTag"
+
+ override def headers: Seq[Node] = {
+ val headersAndTooltips: Seq[(String, Boolean, Option[String])] = Seq(
+ ("User", true, None),
+ ("Session ID", true, None),
+ ("Model ID", true, None),
+ ("Model Class", true, None),
+ ("Model Details", true, Some(SPARK_CONNECT_ML_CACHE_MODEL_DETAILS)),
+ ("Estimated Size", true, Some(SPARK_CONNECT_ML_CACHE_ESTIMATED_SIZE)),
+ ("Storage", true, Some(SPARK_CONNECT_ML_CACHE_STORAGE)))
+
+ isSortColumnValid(headersAndTooltips, sortColumn)
+ headerRow(headersAndTooltips, desc, pageSize, sortColumn, parameterPath,
tableTag, tableTag)
+ }
+
+ override def row(row: MLCacheModelTableRow): Seq[Node] = {
+ val model = row.model
+ val sessionLink = "%s/%s/session/?id=%s&userId=%s".format(
+ UIUtils.prependBaseUri(request, parent.basePath),
+ parent.prefix,
+ URLEncoder.encode(row.sessionId, UTF_8.name()),
+ ConnectUiUtils.encodeUserId(row.userId))
+ <tr>
+ <td>{row.userId}</td>
+ <td><a href={sessionLink}>{row.sessionId}</a></td>
+ <td>{model.id}</td>
+ <td>{model.className}</td>
+ <td>{model.modelString}</td>
+
<td>{model.estimatedSizeBytes.map(Utils.bytesToString).getOrElse("N/A")}</td>
+ <td>{if (model.inMemory) "In memory" else "Offloaded"}</td>
+ </tr>
+ }
+}
+
+private[ui] class MLCacheModelTableDataSource(
+ info: Seq[MLCacheModelTableRow],
+ pageSize: Int,
+ sortColumn: String,
+ desc: Boolean)
+ extends PagedDataSource[MLCacheModelTableRow](pageSize) {
+
+ private val data = info.sorted(ordering(sortColumn, desc))
+
+ override def dataSize: Int = data.size
+
+ override def sliceData(from: Int, to: Int): Seq[MLCacheModelTableRow] =
data.slice(from, to)
+
+ private def ordering(sortColumn: String, desc: Boolean):
Ordering[MLCacheModelTableRow] = {
+ val ordering: Ordering[MLCacheModelTableRow] = sortColumn match {
+ case "User" => Ordering.by(_.userId)
+ case "Session ID" => Ordering.by(_.sessionId)
+ case "Model ID" => Ordering.by(_.model.id)
+ case "Model Class" => Ordering.by(_.model.className)
+ case "Model Details" => Ordering.by(_.model.modelString)
+ case "Estimated Size" =>
+ Ordering.by((row: MLCacheModelTableRow) =>
row.model.estimatedSizeBytes)
+ case "Storage" => Ordering.by(_.model.inMemory)
+ case unknownColumn => throw new IllegalArgumentException(s"Unknown
column: $unknownColumn")
+ }
+ if (desc) ordering.reverse else ordering
+ }
}
private[ui] class SqlStatsTableRow(
diff --git
a/sql/connect/server/src/main/scala/org/apache/spark/sql/connect/ui/SparkConnectServerTab.scala
b/sql/connect/server/src/main/scala/org/apache/spark/sql/connect/ui/SparkConnectServerTab.scala
index c5ea0bf618b5..0de43122a194 100644
---
a/sql/connect/server/src/main/scala/org/apache/spark/sql/connect/ui/SparkConnectServerTab.scala
+++
b/sql/connect/server/src/main/scala/org/apache/spark/sql/connect/ui/SparkConnectServerTab.scala
@@ -21,12 +21,15 @@ import java.util.Date
import org.apache.spark.SparkContext
import org.apache.spark.internal.Logging
+import org.apache.spark.sql.connect.ml.MLCacheStatus
+import org.apache.spark.sql.connect.service.{SessionKey,
SparkConnectSessionManager}
import org.apache.spark.sql.errors.QueryExecutionErrors
import org.apache.spark.ui.{SparkUI, SparkUITab}
private[connect] class SparkConnectServerTab(
val store: SparkConnectServerAppStatusStore,
- sparkUI: SparkUI)
+ sparkUI: SparkUI,
+ sessionManager: Option[SparkConnectSessionManager] = None)
extends SparkUITab(sparkUI, "connect")
with Logging {
@@ -47,6 +50,9 @@ private[connect] class SparkConnectServerTab(
parent.detachTab(this)
}
+ def getMLCacheStatuses: Option[Map[SessionKey, Option[MLCacheStatus]]] =
+ sessionManager.map(_.getMLCacheStatuses)
+
override def displayOrder: Int = 3
}
diff --git
a/sql/connect/server/src/main/scala/org/apache/spark/sql/connect/ui/ToolTips.scala
b/sql/connect/server/src/main/scala/org/apache/spark/sql/connect/ui/ToolTips.scala
index 9b51ace83c6c..30762b9f0f5d 100644
---
a/sql/connect/server/src/main/scala/org/apache/spark/sql/connect/ui/ToolTips.scala
+++
b/sql/connect/server/src/main/scala/org/apache/spark/sql/connect/ui/ToolTips.scala
@@ -36,4 +36,16 @@ private[ui] object ToolTips {
val SPARK_CONNECT_SESSION_DURATION =
"Elapsed time since session start, or until closed if the session was
closed"
+ val SPARK_CONNECT_SESSION_ML_CACHE =
+ "Current cached ML model usage for an active Spark Connect session"
+
+ val SPARK_CONNECT_ML_CACHE_ESTIMATED_SIZE =
+ "Approximate model size recorded when it was added to the Spark Connect ML
cache"
+
+ val SPARK_CONNECT_ML_CACHE_MODEL_DETAILS =
+ "Output of model.toString recorded when the model was added to the cache"
+
+ val SPARK_CONNECT_ML_CACHE_STORAGE =
+ "Whether the model is currently in driver memory or offloaded to
driver-local disk"
+
}
diff --git
a/sql/connect/server/src/test/scala/org/apache/spark/sql/connect/ml/MLSuite.scala
b/sql/connect/server/src/test/scala/org/apache/spark/sql/connect/ml/MLSuite.scala
index 9ba5a499ba8f..76dca9c938a7 100644
---
a/sql/connect/server/src/test/scala/org/apache/spark/sql/connect/ml/MLSuite.scala
+++
b/sql/connect/server/src/test/scala/org/apache/spark/sql/connect/ml/MLSuite.scala
@@ -387,6 +387,34 @@ class MLSuite extends MLHelper {
}
}
+ test("MLCache status") {
+ val sessionHolder = SparkConnectTestUtils.createDummySessionHolder(spark)
+ sessionHolder.session.conf
+
.set(Connect.CONNECT_SESSION_CONNECT_ML_CACHE_MEMORY_CONTROL_ENABLED.key,
"true")
+ sessionHolder.session.conf
+
.set(Connect.CONNECT_SESSION_CONNECT_ML_CACHE_MEMORY_CONTROL_MAX_IN_MEMORY_SIZE.key,
16384)
+ sessionHolder.session.conf
+
.set(Connect.CONNECT_SESSION_CONNECT_ML_CACHE_MEMORY_CONTROL_MAX_STORAGE_SIZE.key,
65536)
+
+ // Reading UI status should not initialize an unused cache.
+ assert(sessionHolder.getMLCacheStatus.isEmpty)
+
+ val modelId = trainLogisticRegressionModel(sessionHolder)
+ val status = sessionHolder.getMLCacheStatus.get
+ assert(status.memoryControlEnabled)
+ assert(status.inMemorySizeBytes > 0)
+ assert(status.maxInMemorySizeBytes === 16384)
+ assert(status.totalSizeBytes === status.inMemorySizeBytes)
+ assert(status.maxTotalSizeBytes === 65536)
+ assert(status.models.size === 1)
+ val modelInfo = status.models.head
+ assert(modelInfo.id === modelId)
+ assert(modelInfo.className === classOf[LogisticRegressionModel].getName)
+ assert(modelInfo.modelString.startsWith("LogisticRegressionModel: uid="))
+ assert(modelInfo.estimatedSizeBytes.contains(status.totalSizeBytes))
+ assert(modelInfo.inMemory)
+ }
+
test("MLCache offloading works") {
val sessionHolder = SparkConnectTestUtils.createDummySessionHolder(spark)
sessionHolder.session.conf
@@ -420,6 +448,11 @@ class MLSuite extends MLHelper {
assert(sessionHolder.mlCache.totalMLCacheInMemorySizeBytes.get() <=
memorySizeBytes)
}
+ val status = sessionHolder.getMLCacheStatus.get
+ assert(status.models.size === modelIdList.size)
+ assert(status.models.count(_.inMemory) === maxNumModels)
+ assert(status.models.map(_.id).toSet === modelIdList.toSet)
+
// Assert all models can be loaded back from disk after they are offloaded.
for (modelId <- modelIdList) {
assert(sessionHolder.mlCache.get(modelId) != null)
@@ -461,6 +494,7 @@ class MLSuite extends MLHelper {
assert(mlCache2.get(modelId) != null)
mlCache2.close()
assert(mlCache2.cachedModel.isEmpty)
+ assert(mlCache2.getStatus.models.isEmpty)
// Test 3: Edge case - register then remove model, close should still run
cleanup
val edgeCaseSessionHolder =
SparkConnectTestUtils.createDummySessionHolder(spark)
diff --git
a/sql/connect/server/src/test/scala/org/apache/spark/sql/connect/ui/SparkConnectServerPageSuite.scala
b/sql/connect/server/src/test/scala/org/apache/spark/sql/connect/ui/SparkConnectServerPageSuite.scala
index 7f6af17bc41b..8adaa729d2bc 100644
---
a/sql/connect/server/src/test/scala/org/apache/spark/sql/connect/ui/SparkConnectServerPageSuite.scala
+++
b/sql/connect/server/src/test/scala/org/apache/spark/sql/connect/ui/SparkConnectServerPageSuite.scala
@@ -17,7 +17,7 @@
package org.apache.spark.sql.connect.ui
-import java.util.{Calendar, Locale}
+import java.util.{Calendar, Locale, UUID}
import jakarta.servlet.http.HttpServletRequest
import org.apache.commons.text.StringEscapeUtils
@@ -26,8 +26,11 @@ import org.scalatest.BeforeAndAfter
import org.apache.spark.{SharedSparkContext, SparkConf, SparkFunSuite}
import org.apache.spark.scheduler.SparkListenerJobStart
+import org.apache.spark.sql.classic.SparkSession
+import org.apache.spark.sql.connect.ml.{MLCacheModelInfo, MLCacheStatus}
import org.apache.spark.sql.connect.service._
import org.apache.spark.status.ElementTrackingStore
+import org.apache.spark.ui.SparkUI
import org.apache.spark.util.kvstore.InMemoryStore
class SparkConnectServerPageSuite
@@ -47,7 +50,10 @@ class SparkConnectServerPageSuite
/**
* Run a dummy session and return the store
*/
- private def getStatusStore: SparkConnectServerAppStatusStore = {
+ private def getStatusStore(
+ closeSession: Boolean = true,
+ sessionId: String = "sessionId",
+ userId: String = "userId"): SparkConnectServerAppStatusStore = {
kvstore = new ElementTrackingStore(new InMemoryStore, new SparkConf())
// val server = mock(classOf[SparkConnectServer], RETURNS_SMART_NULLS)
val sparkConf = new SparkConf
@@ -56,14 +62,14 @@ class SparkConnectServerPageSuite
val statusStore = new SparkConnectServerAppStatusStore(kvstore)
listener.onOtherEvent(
- SparkListenerConnectSessionStarted("sessionId", "userId",
System.currentTimeMillis()))
+ SparkListenerConnectSessionStarted(sessionId, userId,
System.currentTimeMillis()))
listener.onOtherEvent(
SparkListenerConnectOperationStarted(
"jobTag",
"operationId",
System.currentTimeMillis(),
- "sessionId",
- "userId",
+ sessionId,
+ userId,
"userName",
"dummy query",
Set()))
@@ -74,14 +80,16 @@ class SparkConnectServerPageSuite
SparkListenerConnectOperationFinished("jobTag", "operationId",
System.currentTimeMillis()))
listener.onOtherEvent(
SparkListenerConnectOperationClosed("jobTag", "operationId",
System.currentTimeMillis()))
- listener.onOtherEvent(
- SparkListenerConnectSessionClosed("sessionId", "userId",
System.currentTimeMillis()))
+ if (closeSession) {
+ listener.onOtherEvent(
+ SparkListenerConnectSessionClosed(sessionId, userId,
System.currentTimeMillis()))
+ }
statusStore
}
test("Spark Connect Server page should load successfully") {
- val store = getStatusStore
+ val store = getStatusStore()
val request = mock(classOf[HttpServletRequest])
val tab = mock(classOf[SparkConnectServerTab], RETURNS_SMART_NULLS)
@@ -89,6 +97,7 @@ class SparkConnectServerPageSuite
when(tab.store).thenReturn(store)
when(tab.appName).thenReturn("testing")
when(tab.headerTabs).thenReturn(Seq.empty)
+ when(tab.getMLCacheStatuses).thenReturn(None)
val page = new SparkConnectServerPage(tab)
val html = page.render(request).toString().toLowerCase(Locale.ROOT)
@@ -96,6 +105,9 @@ class SparkConnectServerPageSuite
assert(html.contains("session statistics (1)"))
assert(html.contains("request statistics (1)"))
assert(html.contains("dummy query"))
+ assert(html.contains("ml cache"))
+ assert(html.contains("<span>n/a</span>"))
+ assert(!html.contains("ml cache statistics"))
// Pagination support
assert(html.contains("<label class=\"text-nowrap\">1 pages. jump
to</label>"))
@@ -107,7 +119,7 @@ class SparkConnectServerPageSuite
}
test("Spark Connect Server session page should load successfully") {
- val store = getStatusStore
+ val store = getStatusStore()
val request = mock(classOf[HttpServletRequest])
when(request.getParameter("id")).thenReturn("sessionId")
@@ -134,6 +146,162 @@ class SparkConnectServerPageSuite
" data-bs-target=\"#aggregated-sqlsessionstat\""))
}
+ test("Spark Connect Server page should show live ML cache statistics and
model details") {
+ val store = getStatusStore(closeSession = false)
+
+ val request = mock(classOf[HttpServletRequest])
+ val tab = mock(classOf[SparkConnectServerTab], RETURNS_SMART_NULLS)
+ when(tab.startTime).thenReturn(Calendar.getInstance().getTime)
+ when(tab.store).thenReturn(store)
+ when(tab.appName).thenReturn("testing")
+ when(tab.headerTabs).thenReturn(Seq.empty)
+ val status = MLCacheStatus(
+ memoryControlEnabled = true,
+ inMemorySizeBytes = 1024,
+ maxInMemorySizeBytes = 4096,
+ totalSizeBytes = 2048,
+ maxTotalSizeBytes = 8192,
+ models = Seq(
+ MLCacheModelInfo(
+ id = "model-id-1",
+ className =
"org.apache.spark.ml.classification.LogisticRegressionModel",
+ modelString = "LogisticRegressionModel: uid=logreg-1",
+ estimatedSizeBytes = Some(1024),
+ inMemory = true),
+ MLCacheModelInfo(
+ id = "model-id-2",
+ className =
"org.apache.spark.ml.classification.LogisticRegressionModel",
+ modelString = "LogisticRegressionModel: uid=logreg-2",
+ estimatedSizeBytes = Some(1024),
+ inMemory = false)))
+ when(tab.getMLCacheStatuses).thenReturn(
+ Some(Map(SessionKey("userId", "sessionId") -> Some(status))))
+
+ val page = new SparkConnectServerPage(tab)
+ val html = page.render(request).toString().toLowerCase(Locale.ROOT)
+
+ val sessionStatsIndex = html.indexOf("session statistics")
+ val mlCacheStatsIndex = html.indexOf("ml cache statistics (2)")
+ val requestStatsIndex = html.indexOf("request statistics")
+ assert(sessionStatsIndex < requestStatsIndex && requestStatsIndex <
mlCacheStatsIndex)
+ assert(html.contains("1 model in memory"))
+ assert(html.contains("1024.0 b / 4.0 kib memory"))
+ assert(html.contains("2.0 kib / 8.0 kib total"))
+ assert(html.contains("2 (1 in memory, 1 offloaded)"))
+ assert(html.contains("estimated size (in-memory)"))
+ assert(html.contains("1024.0 b / 4.0 kib"))
+ assert(html.contains("estimated size (in-memory and offloaded data)"))
+ assert(html.contains("2.0 kib / 8.0 kib"))
+ assert(html.contains("model-id-1"))
+ assert(html.contains("logisticregressionmodel: uid=logreg-1"))
+ assert(html.contains("in memory"))
+ assert(html.contains("offloaded"))
+ }
+
+ test("Spark Connect Server page should show unused and unavailable ML cache
states") {
+ val store = getStatusStore(closeSession = false)
+
+ val request = mock(classOf[HttpServletRequest])
+ val tab = mock(classOf[SparkConnectServerTab], RETURNS_SMART_NULLS)
+ when(tab.startTime).thenReturn(Calendar.getInstance().getTime)
+ when(tab.store).thenReturn(store)
+ when(tab.appName).thenReturn("testing")
+ when(tab.headerTabs).thenReturn(Seq.empty)
+
+ when(tab.getMLCacheStatuses).thenReturn(None)
+ val unavailableHtml =
+ new
SparkConnectServerPage(tab).render(request).toString().toLowerCase(Locale.ROOT)
+ assert(unavailableHtml.contains("<span>n/a</span>"))
+
+ when(tab.getMLCacheStatuses).thenReturn(Some(Map(SessionKey("userId",
"sessionId") -> None)))
+ val uninitializedHtml =
+ new
SparkConnectServerPage(tab).render(request).toString().toLowerCase(Locale.ROOT)
+ assert(uninitializedHtml.contains("<span>not used</span>"))
+
+ val clearedStatus = MLCacheStatus(
+ memoryControlEnabled = true,
+ inMemorySizeBytes = 0,
+ maxInMemorySizeBytes = 4096,
+ totalSizeBytes = 0,
+ maxTotalSizeBytes = 8192,
+ models = Seq.empty)
+ when(tab.getMLCacheStatuses).thenReturn(
+ Some(Map(SessionKey("userId", "sessionId") -> Some(clearedStatus))))
+ val clearedHtml =
+ new
SparkConnectServerPage(tab).render(request).toString().toLowerCase(Locale.ROOT)
+ assert(clearedHtml.contains("<span>not used</span>"))
+ assert(!clearedHtml.contains("ml cache statistics"))
+
+ when(tab.getMLCacheStatuses).thenReturn(Some(Map.empty))
+ val removedHtml =
+ new
SparkConnectServerPage(tab).render(request).toString().toLowerCase(Locale.ROOT)
+ assert(removedHtml.contains("<span>n/a</span>"))
+
+ val disabledStatus = MLCacheStatus(
+ memoryControlEnabled = false,
+ inMemorySizeBytes = 0,
+ maxInMemorySizeBytes = 0,
+ totalSizeBytes = 0,
+ maxTotalSizeBytes = 0,
+ models = Seq(
+ MLCacheModelInfo(
+ id = "model-id",
+ className = "model-class",
+ modelString = "model-details",
+ estimatedSizeBytes = None,
+ inMemory = true)))
+ when(tab.getMLCacheStatuses).thenReturn(
+ Some(Map(SessionKey("userId", "sessionId") -> Some(disabledStatus))))
+ val disabledHtml =
+ new
SparkConnectServerPage(tab).render(request).toString().toLowerCase(Locale.ROOT)
+ assert(disabledHtml.contains("memory control disabled"))
+ assert(disabledHtml.contains("1 cached model"))
+ }
+
+ test("Spark Connect Server page should read ML cache status without touching
the session") {
+ val key = SessionKey("userId", UUID.randomUUID().toString)
+ val store =
+ getStatusStore(closeSession = false, sessionId = key.sessionId, userId =
key.userId)
+ val sessionManager = new SparkConnectSessionManager()
+ sessionManager.initializeBaseSession(() => new SparkSession(sc))
+ val sessionHolder = sessionManager.getOrCreateIsolatedSession(key, None)
+
+ val request = mock(classOf[HttpServletRequest])
+ val sparkUI = SparkUI.create(
+ Some(sc),
+ sc.statusStore,
+ sc.conf,
+ sc.env.securityManager,
+ sc.appName,
+ "",
+ sc.startTime)
+ val tab =
+ new SparkConnectServerTab(store, sparkUI, Some(sessionManager))
+
+ try {
+ val lastAccessTime = sessionHolder.getSessionHolderInfo.lastAccessTimeMs
+ Thread.sleep(10)
+ val unusedHtml =
+ new
SparkConnectServerPage(tab).render(request).toString().toLowerCase(Locale.ROOT)
+ assert(unusedHtml.contains("<span>not used</span>"))
+ assert(sessionHolder.getMLCacheStatus.isEmpty)
+ assert(sessionHolder.getSessionHolderInfo.lastAccessTimeMs ===
lastAccessTime)
+
+ sessionHolder.mlCache
+ val initializedLastAccessTime =
sessionHolder.getSessionHolderInfo.lastAccessTimeMs
+ Thread.sleep(10)
+ val initializedHtml =
+ new
SparkConnectServerPage(tab).render(request).toString().toLowerCase(Locale.ROOT)
+ assert(initializedHtml.contains("<span>not used</span>"))
+ assert(sessionHolder.getMLCacheStatus.exists(_.models.isEmpty))
+ assert(sessionHolder.getSessionHolderInfo.lastAccessTimeMs ===
initializedLastAccessTime)
+ } finally {
+ tab.detach()
+ sessionManager.closeSession(key)
+ sessionManager.shutdown()
+ }
+ }
+
test("SPARK-58097: session page only shows the requested user's operations")
{
// Two users share the same session UUID, each running a distinct query.
kvstore = new ElementTrackingStore(new InMemoryStore, new SparkConf())
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]