/*
 * 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.sql.connect.service

import java.nio.file.Path
import java.util.concurrent.{ConcurrentHashMap, ConcurrentMap, TimeUnit}

import scala.collection.mutable
import scala.concurrent.{ExecutionContext, Future}
import scala.jdk.CollectionConverters._
import scala.util.Try

import com.google.common.base.Ticker
import com.google.common.cache.{Cache, CacheBuilder}

import org.apache.spark.{SparkEnv, SparkException, SparkSQLException}
import org.apache.spark.api.python.PythonFunction.PythonAccumulator
import org.apache.spark.connect.proto
import org.apache.spark.internal.{Logging, LogKeys}
import org.apache.spark.sql.DataFrame
import org.apache.spark.sql.catalyst.plans.logical.LogicalPlan
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.pipelines.DataflowGraphRegistry
import org.apache.spark.sql.connect.planner.PythonStreamingQueryListener
import org.apache.spark.sql.connect.planner.StreamingForeachBatchHelper
import org.apache.spark.sql.connect.service.SessionHolder.{ERROR_CACHE_SIZE, ERROR_CACHE_TIMEOUT_SEC}
import org.apache.spark.sql.pipelines.graph.PipelineUpdateContext
import org.apache.spark.sql.streaming.StreamingQueryListener
import org.apache.spark.util.{SystemClock, Utils}

// Unique key identifying session by combination of user, and session id
case class SessionKey(userId: String, sessionId: String)

/**
 * Object used to hold the Spark Connect session state.
 */
case class SessionHolder(userId: String, sessionId: String, session: SparkSession)
    extends Logging {

  // Cache which stores recently resolved logical plans to improve the performance of plan analysis.
  // Only plans that explicitly specify "cachePlan = true" in transformRelation will be cached.
  // Analyzing a large plan may be expensive, and it is not uncommon to build the plan step-by-step
  // with several analysis during the process. This cache aids the recursive analysis process by
  // memorizing `LogicalPlan`s which may be a sub-tree in a subsequent plan.
  private lazy val planCache: Option[Cache[proto.Relation, LogicalPlan]] = {
    if (SparkEnv.get.conf.get(Connect.CONNECT_SESSION_PLAN_CACHE_SIZE) <= 0) {
      logWarning(
        log"Session plan cache is disabled due to non-positive cache size." +
          log" Current value of " +
          log"'${MDC(LogKeys.CONFIG, Connect.CONNECT_SESSION_PLAN_CACHE_SIZE.key)}' is ${MDC(
              LogKeys.CACHE_SIZE,
              SparkEnv.get.conf.get(Connect.CONNECT_SESSION_PLAN_CACHE_SIZE))}")
      None
    } else {
      Some(
        CacheBuilder
          .newBuilder()
          .maximumSize(SparkEnv.get.conf.get(Connect.CONNECT_SESSION_PLAN_CACHE_SIZE))
          .build[proto.Relation, LogicalPlan]())
    }
  }

  // Time when the session was started.
  private val startTimeMs: Long = System.currentTimeMillis()

  // Time when the session was last accessed (retrieved from SparkConnectSessionManager)
  @volatile private var lastAccessTimeMs: Long = System.currentTimeMillis()

  // Time when the session was closed.
  // Set only by close(), and only once.
  @volatile private var closedTimeMs: Option[Long] = None

  /**
   * Whether this session is closing or already closed. closedTimeMs is set at the very beginning
   * of [[close]], before any session resources (e.g. running streaming queries) are cleaned up,
   * so this can be used to detect a session shutdown that races with newly started operations.
   */
  private[connect] def isClosing: Boolean = closedTimeMs.isDefined

  // Custom timeout after a session expires due to inactivity.
  // Used by SparkConnectSessionManager instead of default timeout if set.
  // Setting it to -1 indicated forever.
  @volatile private var customInactiveTimeoutMs: Option[Long] = None

  // Set of active operation IDs for this session.
  private val activeOperationIds: mutable.Set[String] = mutable.Set.empty

  // Cache of inactive operations for this session, either completed, interrupted or abandoned.
  private lazy val inactiveOperations: Cache[String, TerminationInfo] =
    CacheBuilder
      .newBuilder()
      .ticker(Ticker.systemTicker())
      .expireAfterAccess(
        SparkEnv.get.conf.get(Connect.CONNECT_INACTIVE_OPERATIONS_CACHE_EXPIRATION_MINS),
        TimeUnit.MINUTES)
      .build[String, TerminationInfo]()

  // The cache that maps an error id to a throwable. The throwable in cache is independent to
  // each other.
  private[connect] val errorIdToError = CacheBuilder
    .newBuilder()
    .ticker(Ticker.systemTicker())
    .maximumSize(ERROR_CACHE_SIZE)
    .expireAfterAccess(ERROR_CACHE_TIMEOUT_SEC, TimeUnit.SECONDS)
    .build[String, Throwable]()

  val eventManager: SessionEventsManager = SessionEventsManager(this, new SystemClock())

  // Mapping from relation ID (passed to client) to runtime dataframe. Used for callbacks like
  // foreachBatch() in Streaming, and DataFrame.checkpoint API. Lazy since most sessions don't
  // need it.
  private[spark] lazy val dataFrameCache: ConcurrentMap[String, DataFrame] =
    new ConcurrentHashMap()

  // ML model cache
  private[connect] lazy val mlCache = new MLCache(this)

  // Mapping from id to StreamingQueryListener. Used for methods like removeListener() in
  // StreamingQueryManager.
  private lazy val listenerCache: ConcurrentMap[String, StreamingQueryListener] =
    new ConcurrentHashMap()

  // Mapping from graphId to the pipeline update context. This is used to manage the lifecycle of
  // pipeline executions.
  private lazy val pipelineExecutions =
    new ConcurrentHashMap[String, PipelineUpdateContext]()

  // Registry for dataflow graphs specific to this session
  private[connect] lazy val dataflowGraphRegistry = new DataflowGraphRegistry()

  // Handles Python process clean up for streaming queries. Initialized on first use in a query.
  private[connect] lazy val streamingForeachBatchRunnerCleanerCache =
    new StreamingForeachBatchHelper.CleanerCache(this)

  private[connect] lazy val streamingServersideListenerHolder = new ServerSideListenerHolder(this)

  def key: SessionKey = SessionKey(userId, sessionId)

  // Returns the server side session ID and asserts that it must be different from the client-side
  // session ID.
  def serverSessionId: String = {
    if (Utils.isTesting && session == null) {
      // Testing-only: Some sessions created by SparkConnectTestUtils.createDummySessionHolder are
      // not fully initialized and don't have an underlying SparkSession.
      ""
    } else {
      assert(session.sessionUUID != sessionId)
      session.sessionUUID
    }
  }

  /**
   * Add an operation ID to this session.
   *
   * Called only by SparkConnectExecutionManager when a new execution is started.
   */
  private[service] def addOperationId(operationId: String): Unit = {
    if (closedTimeMs.isDefined) {
      // Do not accept new executions if the session is closing.
      throw new SparkSQLException(
        errorClass = "INVALID_HANDLE.SESSION_CLOSED",
        messageParameters = Map("handle" -> sessionId))
    }

    activeOperationIds.synchronized {
      if (activeOperationIds.contains(operationId)) {
        throw IllegalStateErrors.executeHolderAlreadyExists(operationId)
      }
      activeOperationIds.add(operationId)
    }
  }

  /**
   * Returns the status of the operation in this session given the operation id. Operations are
   * cached for when they are inactive (completed, interrupted or abandoned). Cache expiration is
   * configured with CONNECT_INACTIVE_OPERATIONS_CACHE_EXPIRATION_MINS.
   *
   * @param operationId
   * @return
   *   Some(true) if the operation is currently active, Some(false) if the operation was
   *   completed, interrupted or abandoned recently, None if no operation with this id is found in
   *   this session.
   */
  private[service] def getOperationStatus(operationId: String): Option[Boolean] = {
    if (activeOperationIds.contains(operationId)) {
      return Some(true)
    }
    Option(inactiveOperations.getIfPresent(operationId)) match {
      case Some(_) =>
        return Some(false)
      case None =>
        return None
    }
  }

  /**
   * Returns the TerminationInfo for an inactive operation if it exists in the cache. Cache
   * expiration is configured with CONNECT_INACTIVE_OPERATIONS_CACHE_EXPIRATION_MINS.
   *
   * @param operationId
   * @return
   *   Some(TerminationInfo) if the operation was closed recently, None if no inactive operation
   *   with this id is found.
   */
  private[service] def getInactiveOperationInfo(operationId: String): Option[TerminationInfo] = {
    Option(inactiveOperations.getIfPresent(operationId))
  }

  /**
   * Returns all inactive operations for this session. These are operations that were closed and
   * are still in the cache. Cache expiration is configured with
   * CONNECT_INACTIVE_OPERATIONS_CACHE_EXPIRATION_MINS.
   *
   * @return
   *   Sequence of TerminationInfo for inactive operations.
   */
  private[service] def listInactiveOperations(): Seq[TerminationInfo] = {
    inactiveOperations.asMap().values().asScala.toSeq
  }

  /**
   * Returns all active operation IDs for this session.
   *
   * @return
   *   Sequence of operation IDs that are currently active.
   */
  private[service] def listActiveOperationIds(): Seq[String] = {
    activeOperationIds.synchronized {
      activeOperationIds.toSeq
    }
  }

  /**
   * Close an operation in this session by storing its TerminationInfo and removing from active
   * set.
   */
  private[service] def closeOperation(executeHolder: ExecuteHolder): Unit = {
    val terminationInfo = executeHolder.getTerminationInfo
    inactiveOperations.put(terminationInfo.operationId, terminationInfo)
    activeOperationIds.remove(terminationInfo.operationId)
  }

  /**
   * Interrupt all executions in the session.
   * @return
   *   list of operationIds of interrupted executions
   */
  private[service] def interruptAll(): Seq[String] = {
    val interruptedIds = new mutable.ArrayBuffer[String]()
    val operationsIds =
      SparkConnectService.streamingSessionManager.cleanupRunningQueries(this, blocking = false)
    activeOperationIds.foreach { operationId =>
      val executeKey = ExecuteKey(userId, sessionId, operationId)
      SparkConnectService.executionManager.getExecuteHolder(executeKey).foreach { executeHolder =>
        if (executeHolder.interrupt()) {
          interruptedIds += operationId
        }
      }
    }
    interruptedIds.toSeq ++ operationsIds
  }

  /**
   * Interrupt executions in the session with a given tag.
   * @return
   *   list of operationIds of interrupted executions
   */
  private[service] def interruptTag(tag: String): Seq[String] = {
    val interruptedIds = new mutable.ArrayBuffer[String]()
    val queries = SparkConnectService.streamingSessionManager.getTaggedQuery(tag, session)
    queries.foreach(q => Future(q.query.stop())(ExecutionContext.global))
    activeOperationIds.foreach { operationId =>
      val executeKey = ExecuteKey(userId, sessionId, operationId)
      SparkConnectService.executionManager.getExecuteHolder(executeKey).foreach { executeHolder =>
        if (executeHolder.sparkSessionTags.contains(tag)) {
          if (executeHolder.interrupt()) {
            closeOperation(executeHolder)
            interruptedIds += operationId
          }
        }
      }
    }
    interruptedIds.toSeq ++ queries.map(_.operationId)
  }

  /**
   * Interrupt the execution with the given operation_id
   * @return
   *   list of operationIds of interrupted executions (one element or empty)
   */
  private[service] def interruptOperation(operationId: String): Seq[String] = {
    val interruptedIds = new mutable.ArrayBuffer[String]()
    val executeKey = ExecuteKey(userId, sessionId, operationId)
    SparkConnectService.executionManager.getExecuteHolder(executeKey).foreach { executeHolder =>
      if (executeHolder.interrupt()) {
        closeOperation(executeHolder)
        interruptedIds += operationId
      }
    }
    interruptedIds.toSeq
  }

  private[connect] def artifactManager = session.artifactManager

  /**
   * Add an artifact to this SparkConnect session.
   *
   * @param remoteRelativePath
   * @param serverLocalStagingPath
   * @param fragment
   */
  private[connect] def addArtifact(
      remoteRelativePath: Path,
      serverLocalStagingPath: Path,
      fragment: Option[String]): Unit = {
    artifactManager.addArtifact(remoteRelativePath, serverLocalStagingPath, fragment)
  }

  /**
   * A [[ClassLoader]] for jar/class file resources specific to this SparkConnect session.
   */
  def classloader: ClassLoader = artifactManager.classloader

  private[connect] def updateAccessTime(): Unit = {
    lastAccessTimeMs = System.currentTimeMillis()
    logInfo(
      log"Session with userId: ${MDC(LogKeys.USER_ID, userId)} and " +
        log"sessionId: ${MDC(LogKeys.SESSION_ID, sessionId)} accessed at " +
        log"timestamp ${MDC(LogKeys.LAST_ACCESS_TIME, lastAccessTimeMs)}.")
  }

  private[connect] def setCustomInactiveTimeoutMs(newInactiveTimeoutMs: Option[Long]): Unit = {
    customInactiveTimeoutMs = newInactiveTimeoutMs
    logInfo(
      log"Session with userId: ${MDC(LogKeys.USER_ID, userId)} and " +
        log"sessionId: ${MDC(LogKeys.SESSION_ID, sessionId)} inactive timeout set to " +
        log"${MDC(LogKeys.TIMEOUT, customInactiveTimeoutMs)} ms")
  }

  /**
   * Initialize the session.
   *
   * Called only by SparkConnectSessionManager.
   */
  private[connect] def initializeSession(): Unit = {
    eventManager.postStarted()
  }

  /**
   * Expire this session and trigger state cleanup mechanisms.
   *
   * Called only by SparkConnectSessionManager.shutdownSessionHolder.
   */
  private[connect] def close(): Unit = {
    // Called only by SparkConnectSessionManager.shutdownSessionHolder.
    // It is not called under SparkConnectSessionManager.sessionsLock, but it's guaranteed to be
    // called only once, since removing the session from SparkConnectSessionManager.sessionStore is
    // synchronized and guaranteed to happen only once.
    if (closedTimeMs.isDefined) {
      throw IllegalStateErrors.sessionAlreadyClosed(key.toString)
    }
    logInfo(
      log"Closing session with userId: ${MDC(LogKeys.USER_ID, userId)} and " +
        log"sessionId: ${MDC(LogKeys.SESSION_ID, sessionId)}")
    closedTimeMs = Some(System.currentTimeMillis())

    if (Utils.isTesting && eventManager.status == SessionStatus.Pending) {
      // Testing-only: Some sessions created by SparkConnectTestUtils.createDummySessionHolder are
      // not fully initialized and can't be closed.
      return
    }

    // Note on the below notes about concurrency:
    // While closing the session can potentially race with operations started on the session, the
    // intended use is that the client session will get closed when it's really not used anymore,
    // or that it expires due to inactivity, in which case there should be no races.

    // Clean up all artifacts.
    // Note: there can be concurrent AddArtifact calls still adding something.
    artifactManager.close()

    // Clean up running streaming queries.
    // Note: there can be concurrent streaming queries being started.
    SparkConnectService.streamingSessionManager.cleanupRunningQueries(this, blocking = true)
    streamingForeachBatchRunnerCleanerCache.cleanUpAll() // Clean up any streaming workers.
    removeAllListeners() // removes all listener and stop python listener processes if necessary.
    // Stops all pipeline execution and clears the pipeline execution cache
    removeAllPipelineExecutions()

    // Clean up dataflow graphs
    dataflowGraphRegistry.dropAllDataflowGraphs()

    // if there is a server side listener, clean up related resources
    if (streamingServersideListenerHolder.isServerSideListenerRegistered) {
      streamingServersideListenerHolder.cleanUp()
    }

    // Clean up all executions.
    // After closedTimeMs is defined, SessionHolder.addExecuteHolder() will not allow new executions
    // to be added for this session anymore. Because both SessionHolder.addExecuteHolder() and
    // SparkConnectExecutionManager.removeAllExecutionsForSession() are executed under
    // executionsLock, this guarantees that removeAllExecutionsForSession triggered here will
    // remove all executions and no new executions will be added in the meanwhile.
    SparkConnectService.executionManager.removeAllExecutionsForSession(this.key)

    // Clean up ML cache (only if ML models were created)
    mlCache.close()

    session.cleanupPythonWorkerLogs()

    eventManager.postClosed()
  }

  /**
   * Execute a block of code with this session as the active SparkConnect session.
   * @param f
   * @tparam T
   */
  def withSession[T](f: SparkSession => T): T = {
    artifactManager.withResources {
      session.withActive {
        f(session)
      }
    }
  }

  /** Get SessionInfo with information about this SessionHolder. */
  def getSessionHolderInfo: SessionHolderInfo =
    SessionHolderInfo(
      userId = userId,
      sessionId = sessionId,
      serverSessionId = serverSessionId,
      status = eventManager.status,
      startTimeMs = startTimeMs,
      lastAccessTimeMs = lastAccessTimeMs,
      customInactiveTimeoutMs = customInactiveTimeoutMs,
      closedTimeMs = closedTimeMs)

  /**
   * Caches given DataFrame with the ID. The cache does not expire. The entry needs to be
   * explicitly removed by the owners of the DataFrame once it is not needed.
   */
  private[connect] def cacheDataFrameById(dfId: String, df: DataFrame): Unit = {
    if (dataFrameCache.putIfAbsent(dfId, df) != null) {
      throw SparkException.internalError(s"A dataframe is already associated with id $dfId")
    }
  }

  /**
   * Returns [[DataFrame]] cached for DataFrame ID `dfId`. If it is not found, throw
   * [[InvalidPlanInput]].
   */
  private[connect] def getDataFrameOrThrow(dfId: String): DataFrame = {
    Option(dataFrameCache.get(dfId))
      .getOrElse {
        throw InvalidPlanInput(
          "CONNECT_INVALID_PLAN.DATAFRAME_NOT_FOUND",
          Map("dfId" -> dfId, "sessionId" -> sessionId))
      }
  }

  private[connect] def removeCachedDataFrame(dfId: String): DataFrame = {
    dataFrameCache.remove(dfId)
  }

  /**
   * Caches given StreamingQueryListener with the ID.
   */
  private[connect] def cacheListenerById(id: String, listener: StreamingQueryListener): Unit = {
    if (listenerCache.putIfAbsent(id, listener) != null) {
      throw SparkException.internalError(s"A listener is already associated with id $id")
    }
  }

  /**
   * Returns [[StreamingQueryListener]] cached for Listener ID `id`. If it is not found, return
   * None.
   */
  private[connect] def getListener(id: String): Option[StreamingQueryListener] = {
    Option(listenerCache.get(id))
  }

  /**
   * Removes corresponding StreamingQueryListener by ID. Terminates the python process if it's a
   * Spark Connect PythonStreamingQueryListener.
   */
  private[connect] def removeCachedListener(id: String): Unit = {
    Option(listenerCache.remove(id)) match {
      case Some(pyListener: PythonStreamingQueryListener) => pyListener.stopListenerProcess()
      case _ => // do nothing
    }
  }

  /**
   * Stop all streaming listener threads, and removes all python process if applicable. Only
   * called when session is expired.
   */
  private def removeAllListeners(): Unit = {
    listenerCache.forEach((id, listener) => {
      session.streams.removeListener(listener)
      removeCachedListener(id)
    })
  }

  /**
   * List listener IDs that have been register on server side.
   */
  private[connect] def listListenerIds(): Seq[String] = {
    listenerCache.keySet().asScala.toSeq
  }

  /**
   * Caches the pipeline execution context for a given graph ID.
   * @param graphId
   *   The id of the graph being executed.
   * @param pipelineUpdateContext
   *   The context for the pipeline execution.
   */
  private[connect] def cachePipelineExecution(
      graphId: String,
      pipelineUpdateContext: PipelineUpdateContext): Unit = {
    pipelineExecutions.compute(
      graphId,
      (_, existing) => {
        if (Option(existing).isDefined) {
          throw IllegalStateErrors.executeHolderAlreadyExistsGraphId(graphId)
        }

        pipelineUpdateContext
      })
  }

  /** Stops the pipeline execution and removes it from the cache. */
  private def removeCachedPipelineExecution(graphId: String): Unit = {
    pipelineExecutions.compute(
      graphId,
      (_, context) => {
        if (context.pipelineExecution.executionStarted) {
          context.pipelineExecution.stopPipeline()
        }
        // Remove the execution.
        null
      })
  }

  /** Stops all pipeline executions and clears the pipeline execution cache. */
  def removeAllPipelineExecutions(): Unit = {
    pipelineExecutions.forEach((graphId, _) => {
      removeCachedPipelineExecution(graphId)
    })
    pipelineExecutions.clear()
  }

  /**
   * Returns [[PipelineUpdateContext]] cached for the given graphId. If it is not found, return
   * None.
   */
  private[connect] def getPipelineExecution(graphId: String): Option[PipelineUpdateContext] = {
    Option(pipelineExecutions.get(graphId))
  }

  /**
   * An accumulator for Python executors.
   *
   * The accumulated results will be sent to the Python client via observed_metrics message.
   */
  private[connect] val pythonAccumulator: Option[PythonAccumulator] =
    Try(session.sparkContext.collectionAccumulator[Array[Byte]]).toOption

  /**
   * Transform a relation into a logical plan, using the plan cache if enabled. The plan cache is
   * enable only if `spark.connect.session.planCache.maxSize` is greater than zero AND
   * `spark.connect.session.planCache.enabled` is true.
   * @param rel
   *   The relation to transform.
   * @param cachePlan
   *   Whether to cache the result logical plan.
   * @param transform
   *   Function to transform the relation into a logical plan.
   * @return
   *   The logical plan.
   */
  private[connect] def usePlanCache(rel: proto.Relation, cachePlan: Boolean)(
      transform: proto.Relation => LogicalPlan): LogicalPlan = {
    val planCacheEnabled = Option(session)
      .forall(_.sessionState.conf.getConf(Connect.CONNECT_SESSION_PLAN_CACHE_ENABLED, true))
    // We only cache plans that have a plan ID.
    val hasPlanId = rel.hasCommon && rel.getCommon.hasPlanId

    // Always cache a `Read.DataSource` to avoid re-analyzing the same `DataSource` twice.
    lazy val alwaysCacheDataSourceReadsEnabled = Option(session)
      .forall(_.conf.get(Connect.CONNECT_ALWAYS_CACHE_DATA_SOURCE_READS_ENABLED, true))
    lazy val isDataSourceRead = rel.hasRead && rel.getRead.hasDataSource

    def getPlanCache(rel: proto.Relation): Option[LogicalPlan] =
      planCache match {
        case Some(cache) if planCacheEnabled && hasPlanId =>
          Option(cache.getIfPresent(rel)) match {
            case Some(plan) =>
              logDebug(s"Using cached plan for relation '$rel': $plan")
              Some(plan)
            case None => None
          }
        case _ => None
      }
    def putPlanCache(rel: proto.Relation, plan: LogicalPlan): Unit =
      planCache match {
        case Some(cache) if planCacheEnabled && hasPlanId =>
          cache.put(rel, plan)
        case _ =>
      }

    getPlanCache(rel)
      .getOrElse({
        val plan = transform(rel)
        if (cachePlan || (alwaysCacheDataSourceReadsEnabled && isDataSourceRead)) {
          putPlanCache(rel, plan)
        }
        plan
      })
  }

  // For testing. Expose the plan cache for testing purposes.
  private[service] def getPlanCache: Option[Cache[proto.Relation, LogicalPlan]] = planCache
}

object SessionHolder {

  // The maximum number of distinct errors in the cache.
  private val ERROR_CACHE_SIZE = 20

  // The maximum time for an error to stay in the cache.
  private val ERROR_CACHE_TIMEOUT_SEC = 60
}

/** Basic information about SessionHolder. */
case class SessionHolderInfo(
    userId: String,
    sessionId: String,
    serverSessionId: String,
    status: SessionStatus,
    customInactiveTimeoutMs: Option[Long],
    startTimeMs: Long,
    lastAccessTimeMs: Long,
    closedTimeMs: Option[Long]) {
  def key: SessionKey = SessionKey(userId, sessionId)
}
