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 5deb9ce1c3f19..5c3db4b99e307 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
@@ -142,24 +157,32 @@ private[connect] class MLCache(sessionHolder: SessionHolder) extends Logging {
if (obj.isInstanceOf[Summary]) {
cachedModel.put(objectId, CacheItem(obj, 0))
} else if (obj.isInstanceOf[Model[_]]) {
- val sizeBytes = if (getMemoryControlEnabled) {
- val _sizeBytes = estimateObjectSize(obj)
- checkModelSize(_sizeBytes)
- _sizeBytes
+ val model = obj.asInstanceOf[Model[_]]
+ val estimatedSizeBytes = if (getMemoryControlEnabled) {
+ val sizeBytes = estimateObjectSize(model)
+ checkModelSize(sizeBytes)
+ Some(sizeBytes)
} else {
- 0L // Don't need to calculate size if disables memory-control.
+ // Avoid adding model-size estimation overhead when memory control is disabled.
+ None
}
- cachedModel.put(objectId, CacheItem(obj, sizeBytes))
if (getMemoryControlEnabled) {
val savePath = getModelOffloadingPath(objectId)
- obj.asInstanceOf[MLWritable].write.saveToLocal(savePath.toString)
- if (obj.isInstanceOf[HasTrainingSummary[_]]
- && obj.asInstanceOf[HasTrainingSummary[_]].hasSummary) {
- obj
+ model.asInstanceOf[MLWritable].write.saveToLocal(savePath.toString)
+ if (model.isInstanceOf[HasTrainingSummary[_]]
+ && model.asInstanceOf[HasTrainingSummary[_]].hasSummary) {
+ model
.asInstanceOf[HasTrainingSummary[_]]
.saveSummary(savePath.resolve("summary").toString)
}
- Files.writeString(savePath.resolve(modelClassNameFile), obj.getClass.getName)
+ Files.writeString(savePath.resolve(modelClassNameFile), model.getClass.getName)
+ }
+ cachedModelMetadata.put(
+ objectId,
+ ModelMetadata(model.getClass.getName, model.toString, estimatedSizeBytes))
+ inMemoryModelIds.add(objectId)
+ cachedModel.put(objectId, CacheItem(model, estimatedSizeBytes.getOrElse(0L)))
+ estimatedSizeBytes.foreach { sizeBytes =>
totalMLCacheInMemorySizeBytes.addAndGet(sizeBytes)
totalMLCacheSizeBytes.addAndGet(sizeBytes)
}
@@ -219,6 +242,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,8 +255,12 @@ private[connect] class MLCache(sessionHolder: SessionHolder) extends Logging {
verifyObjectId(refId)
val removedModel = cachedModel.remove(refId)
val removedFromMem = removedModel != null
- val removedFromDisk = if (!evictOnly && removedModel != null && getMemoryControlEnabled) {
- totalMLCacheSizeBytes.addAndGet(-removedModel.sizeBytes)
+ inMemoryModelIds.remove(refId)
+ val metadata = Option(cachedModelMetadata.get(refId))
+ val removedFromDisk = if (!evictOnly && metadata.nonEmpty && getMemoryControlEnabled) {
+ metadata.get.estimatedSizeBytes.foreach { sizeBytes =>
+ totalMLCacheSizeBytes.addAndGet(-sizeBytes)
+ }
val removePath = getModelOffloadingPath(refId)
val offloadingPath = new File(removePath.toString)
if (offloadingPath.exists()) {
@@ -244,7 +272,9 @@ private[connect] class MLCache(sessionHolder: SessionHolder) extends Logging {
} else {
false
}
- removedFromMem || removedFromDisk
+ val removeMetadata = !evictOnly || !getMemoryControlEnabled
+ val removedMetadata = removeMetadata && cachedModelMetadata.remove(refId) != null
+ removedFromMem || removedFromDisk || removedMetadata
}
/**
@@ -264,6 +294,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 +313,40 @@ 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 = mutable.ArrayBuilder.make[MLCacheModelInfo]
+ cachedModelMetadata.asScala.foreach { case (id, metadata) =>
+ models += MLCacheModelInfo(
+ id = id,
+ className = metadata.className,
+ modelString = metadata.modelString,
+ estimatedSizeBytes = metadata.estimatedSizeBytes,
+ inMemory = inMemoryModelIds.contains(id))
+ }
+ 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.result().toIndexedSeq.sortBy(_.id))
+ }
}
+
+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 2276230545e67..e97bcb0c786e3 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 c76794e3b6ec1..0c18a7491c1f2 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 d3ddf592e9e7d..ca124ba9693fa 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,18 @@ 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: Seq[(SessionKey, MLCacheStatus)] = {
+ sessionStore
+ .entrySet()
+ .asScala
+ .flatMap { entry =>
+ entry.getValue.getMLCacheStatus.map(entry.getKey -> _)
+ }
+ .toSeq
+ .sortBy { case (key, _) => (key.userId, key.sessionId) }
+ }
+
/**
* 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 ea78b2dc59f6a..b3af78f62e361 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,7 @@ import scala.xml.Node
import jakarta.servlet.http.HttpServletRequest
import org.apache.spark.internal.Logging
+import org.apache.spark.sql.connect.ml.MLCacheModelInfo
import org.apache.spark.sql.connect.ui.ToolTips._
import org.apache.spark.ui._
import org.apache.spark.ui.UIUtils._
@@ -63,7 +64,8 @@ private[ui] class SparkConnectServerPage(parent: SparkConnectServerTab)
Request(s)
++
generateSessionStatsTable(request) ++
- generateSQLStatsTable(request)
+ generateSQLStatsTable(request) ++
+ generateMLCacheStatsTable(request)
}
UIUtils.headerSparkPage(request, "Spark Connect", content, parent)
}
@@ -179,6 +181,80 @@ private[ui] class SparkConnectServerPage(parent: SparkConnectServerTab)
content
}
+
+ /** Generate live ML cache statistics for active Spark Connect sessions. */
+ private def generateMLCacheStatsTable(request: HttpServletRequest): Seq[Node] = {
+ val cacheStatuses = parent.getMLCacheStatuses.filter(_._2.models.nonEmpty)
+ val models = cacheStatuses.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) =>
+
+
Error while rendering ML cache table:
+
+ {Utils.exceptionString(e)}
+
+
+ }
+
+ val inMemoryModels = models.count(_.model.inMemory)
+ val memoryControlledStatuses = cacheStatuses.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(
+
+ Estimated size (In-memory):
+ {Utils.bytesToString(inMemorySize)} / {Utils.bytesToString(maxInMemorySize)}
+ ,
+
+ Estimated size (In-memory and Offloaded data):
+ {Utils.bytesToString(totalSize)} / {Utils.bytesToString(maxTotalSize)}
+ )
+ } else {
+ Seq.empty
+ }
+
+
+
+ ++
+
+
+ - Sessions with cached models: {cacheStatuses.size}
+ -
+ Cached models:
+ {models.size} ({inMemoryModels} in memory, {models.size - inMemoryModels} offloaded)
+
+ {sizeStats}
+
+
Cached Models
+ {table}
+
+ }
}
private[ui] class SqlStatsPagedTable(
@@ -435,6 +511,114 @@ private[ui] class SessionStatsPagedTable(
}
}
+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))
+
+ | {row.userId} |
+ {row.sessionId} |
+ {model.id} |
+ {model.className} |
+ {model.modelString} |
+ {model.estimatedSizeBytes.map(Utils.bytesToString).getOrElse("N/A")} |
+ {if (model.inMemory) "In memory" else "Offloaded"} |
+
+ }
+}
+
+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.isDefined, row.model.estimatedSizeBytes.getOrElse(0L)))
+ 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(
val jobTag: String,
val jobId: Seq[String],
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 c5ea0bf618b52..d720acb31fee2 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,10 @@ private[connect] class SparkConnectServerTab(
parent.detachTab(this)
}
+ def getMLCacheStatuses: Seq[(SessionKey, MLCacheStatus)] = {
+ sessionManager.toSeq.flatMap(_.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 9b51ace83c6c1..be2ab4c4903b2 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,13 @@ 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_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 9ba5a499ba8fe..b78423e8687e9 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,10 +448,24 @@ 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)
}
+
+ val statusBeforeRemove = sessionHolder.getMLCacheStatus.get
+ val offloadedModel = statusBeforeRemove.models.find(!_.inMemory).get
+ assert(sessionHolder.mlCache.remove(offloadedModel.id))
+ val statusAfterRemove = sessionHolder.getMLCacheStatus.get
+ assert(!statusAfterRemove.models.exists(_.id == offloadedModel.id))
+ assert(
+ statusAfterRemove.totalSizeBytes ===
+ statusBeforeRemove.totalSizeBytes - offloadedModel.estimatedSizeBytes.get)
}
test("Model size limit") {
@@ -461,6 +503,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 7f6af17bc41b3..8def63046cecf 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
@@ -26,6 +26,7 @@ import org.scalatest.BeforeAndAfter
import org.apache.spark.{SharedSparkContext, SparkConf, SparkFunSuite}
import org.apache.spark.scheduler.SparkListenerJobStart
+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.util.kvstore.InMemoryStore
@@ -47,7 +48,7 @@ class SparkConnectServerPageSuite
/**
* Run a dummy session and return the store
*/
- private def getStatusStore: SparkConnectServerAppStatusStore = {
+ private def getStatusStore(closeSession: Boolean = true): SparkConnectServerAppStatusStore = {
kvstore = new ElementTrackingStore(new InMemoryStore, new SparkConf())
// val server = mock(classOf[SparkConnectServer], RETURNS_SMART_NULLS)
val sparkConf = new SparkConf
@@ -74,14 +75,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 +92,7 @@ class SparkConnectServerPageSuite
when(tab.store).thenReturn(store)
when(tab.appName).thenReturn("testing")
when(tab.headerTabs).thenReturn(Seq.empty)
+ when(tab.getMLCacheStatuses).thenReturn(Seq.empty)
val page = new SparkConnectServerPage(tab)
val html = page.render(request).toString().toLowerCase(Locale.ROOT)
@@ -96,6 +100,7 @@ class SparkConnectServerPageSuite
assert(html.contains("session statistics (1)"))
assert(html.contains("request statistics (1)"))
assert(html.contains("dummy query"))
+ assert(!html.contains("ml cache statistics"))
// Pagination support
assert(html.contains(""))
@@ -107,7 +112,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 +139,56 @@ 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)
+ when(tab.getMLCacheStatuses).thenReturn(
+ Seq(
+ SessionKey("userId", "sessionId") ->
+ 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)))))
+
+ 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("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-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())