venkata91 commented on code in PR #59277:
URL: https://github.com/apache/spark/pull/59277#discussion_r4242350497


##########
sql/connect/server/src/main/scala/org/apache/spark/sql/connect/service/SparkConnectSessionManager.scala:
##########
@@ -224,44 +224,58 @@ class SparkConnectSessionManager extends Logging {
     session
   }
 
-  // Removes session from sessionStore and returns it.
-  private def removeSessionHolder(
-      key: SessionKey,
-      allowReconnect: Boolean = false): Option[SessionHolder] = {
-    var sessionHolder: Option[SessionHolder] = None
-
-    // The session holder should remain in the session store until it is added 
to the closed session
-    // cache, because of a subtle data race: a new session with the same key 
can be created if the
-    // closed session cache does not contain the key right after the key has 
been removed from the
-    // session store.
-    sessionHolder = Option(sessionStore.get(key))
-
-    sessionHolder.foreach { s =>
-      if (!allowReconnect) {
-        // Put into closedSessionsCache to prevent the same session from being 
recreated by
-        // getOrCreateIsolatedSession when reconnection isn't allowed.
-        closedSessionsCache.put(s.key, s.getSessionHolderInfo)
-      }
+  // Removes the session with the given key from sessionStore, if any, and 
returns it.
+  private[service] def removeSessionHolder(key: SessionKey): 
Option[SessionHolder] = {
+    Option(sessionStore.get(key)).filter(tryRemoveSessionHolder)
+  }
 
-      // Then, remove the session holder from the session store.
-      sessionStore.remove(key)
-    }
-    sessionHolder
+  // Removes the given session from sessionStore if it is still stored there. 
Returns true only
+  // for the one caller that removed it; that caller must then call 
shutdownSessionHolder.
+  //
+  // The session is tombstoned before it is removed, and the tombstone is kept 
until
+  // shutdownSessionHolder has finished closing it. Otherwise a session with 
the same key could be
+  // created while the old session is still closing, and the key-based cleanup 
in
+  // SessionHolder.close() would tear it down.
+  private[service] def tryRemoveSessionHolder(sessionHolder: SessionHolder): 
Boolean = {
+    // Tombstone and remove the session in one atomic step for the key, 
matched by identity, so
+    // that two callers cannot both remove the same session, a caller that 
loses cannot re-put a
+    // tombstone that the owner already dropped, and a later session with the 
same key is never
+    // removed.
+    val removed = new AtomicBoolean(false)
+    sessionStore.computeIfPresent(
+      sessionHolder.key,
+      (_, current) => {
+        if (current eq sessionHolder) {
+          closedSessionsCache.put(current.key, current.getSessionHolderInfo)

Review Comment:
   Fixed in f2ebb5a9e5. Reconnect-allowed closes (`allow_reconnect=true` or the 
session opt-in) now fence the id in a separate, unbounded `closingSessions` map 
that is dropped after `close()` finishes. They never go into the bounded 
`closedSessionsCache`, so they can no longer evict other sessions' tombstones. 
Whether to keep or release the id is decided once, inside the identity-checked 
removal. Same-key checks look at both structures. A new test, `a 
reconnect-allowed close fences its id without taking a tombstone slot`, covers 
this.



##########
sql/connect/server/src/main/scala/org/apache/spark/sql/connect/service/SparkConnectSessionManager.scala:
##########
@@ -437,13 +454,21 @@ class SparkConnectSessionManager extends Logging {
       })
   }
 
+  /**
+   * Used for testing.
+   */
+  private[service] def putSessionHolderForTesting(sessionHolder: 
SessionHolder): Unit = {

Review Comment:
   Removed `putSessionHolderForTesting` in f2ebb5a9e5; the tests use the 
existing `putSessionForTesting`.



##########
sql/connect/server/src/main/scala/org/apache/spark/sql/connect/config/Connect.scala:
##########
@@ -150,6 +150,18 @@ object Connect {
       .intConf
       .createWithDefaultString("1000")
 
+  val CONNECT_SESSION_ALLOW_RECONNECT_AFTER_CLOSE =
+    buildConf("spark.connect.session.allowReconnectAfterClose")
+      .doc(
+        "When true, the session id is not tombstoned when this session is 
closed, for example " +

Review Comment:
   Rewrote the conf doc in f2ebb5a9e5/b4f98b9d37. It now says the id is 
released after close, that a client of the old session gets 
`INVALID_HANDLE.SESSION_CHANGED`, and that the user must create a new 
`SparkSession` with the same id. That session is empty (UDFs, temp views, 
cached data and so on must be rebuilt) and must set this conf again.



##########
sql/connect/server/src/main/scala/org/apache/spark/sql/connect/config/Connect.scala:
##########
@@ -150,6 +150,18 @@ object Connect {
       .intConf
       .createWithDefaultString("1000")
 
+  val CONNECT_SESSION_ALLOW_RECONNECT_AFTER_CLOSE =
+    buildConf("spark.connect.session.allowReconnectAfterClose")

Review Comment:
   Added the conf to the Spark Connect table in `docs/configuration.md` in 
f2ebb5a9e5.



##########
sql/connect/server/src/test/scala/org/apache/spark/sql/connect/service/SparkConnectSessionManagerSuite.scala:
##########
@@ -159,6 +164,143 @@ class SparkConnectSessionManagerSuite extends 
SharedSparkSession {
     assert(closedSessionInfo.get.closedTimeMs.isDefined)
   }
 
+  private def assertSessionClosed(key: SessionKey): Unit = {
+    val ex = intercept[SparkSQLException] {
+      SparkConnectService.sessionManager.getOrCreateIsolatedSession(key, None)
+    }
+    assert(ex.getCondition == "INVALID_HANDLE.SESSION_CLOSED")
+  }
+
+  private def releaseSession(key: SessionKey, allowReconnect: Boolean): Unit = 
{
+    val observer = new StreamObserver[proto.ReleaseSessionResponse] {
+      override def onNext(v: proto.ReleaseSessionResponse): Unit = {}
+      override def onError(t: Throwable): Unit = throw t
+      override def onCompleted(): Unit = {}
+    }
+    val request = proto.ReleaseSessionRequest
+      .newBuilder()
+      .setUserContext(proto.UserContext.newBuilder().setUserId(key.userId))
+      .setSessionId(key.sessionId)
+      .setAllowReconnect(allowReconnect)
+      .build()
+    new SparkConnectReleaseSessionHandler(observer).handle(request)
+  }
+
+  test("expired session is tombstoned by default") {
+    val key = SessionKey("user", UUID.randomUUID().toString)
+    SparkConnectService.sessionManager.getOrCreateIsolatedSession(key, None)
+
+    
SparkConnectService.sessionManager.periodicMaintenance(defaultInactiveTimeoutMs 
= 0L)
+
+    assertSessionClosed(key)
+  }
+
+  test("expired session that allows reconnect after close can be recreated as 
a new session") {
+    val key = SessionKey("user", UUID.randomUUID().toString)
+    val oldHolder = 
SparkConnectService.sessionManager.getOrCreateIsolatedSession(key, None)
+    
oldHolder.session.conf.set(Connect.CONNECT_SESSION_ALLOW_RECONNECT_AFTER_CLOSE.key,
 "true")
+    oldHolder.session.conf.set("spark.sql.shuffle.partitions", "7")
+
+    
SparkConnectService.sessionManager.periodicMaintenance(defaultInactiveTimeoutMs 
= 0L)
+    assert(SparkConnectService.sessionManager.listActiveSessions.isEmpty)
+
+    // The id is reusable, and the recreated session starts from a clean state.
+    val newHolder = 
SparkConnectService.sessionManager.getOrCreateIsolatedSession(key, None)
+    assert(newHolder.session.conf.get("spark.sql.shuffle.partitions") != "7")
+    // Opting in is per session: the new session is tombstoned again unless it 
opts in too.
+    
SparkConnectService.sessionManager.periodicMaintenance(defaultInactiveTimeoutMs 
= 0L)
+    assertSessionClosed(key)
+  }
+
+  /** A session holder whose close() blocks until `release` is counted down. */
+  private class BlockingCloseSessionHolder(key: SessionKey)
+      extends SessionHolder(key.userId, key.sessionId, spark.newSession()) {
+    val closeStarted = new CountDownLatch(1)
+    val release = new CountDownLatch(1)
+    val closeCalls = new AtomicInteger(0)
+
+    override private[connect] def close(): Unit = {
+      closeCalls.incrementAndGet()
+      closeStarted.countDown()
+      assert(release.await(1, TimeUnit.MINUTES))
+      super.close()
+    }
+  }
+
+  private def startBlockingCloseSession(key: SessionKey): 
BlockingCloseSessionHolder = {
+    val holder = new BlockingCloseSessionHolder(key)
+    holder.initializeSession()
+    
holder.session.conf.set(Connect.CONNECT_SESSION_ALLOW_RECONNECT_AFTER_CLOSE.key,
 "true")
+    SparkConnectService.sessionManager.putSessionHolderForTesting(holder)
+    holder
+  }
+
+  test("session id stays tombstoned until the owning close completes") {
+    val sessionManager = SparkConnectService.sessionManager
+    val key = SessionKey("user", UUID.randomUUID().toString)
+    val holder = startBlockingCloseSession(key)
+
+    val owner = new Thread(() => sessionManager.closeSession(key))
+    owner.start()
+    try {
+      assert(holder.closeStarted.await(1, TimeUnit.MINUTES))
+      // The old session is removed but still closing: it must not be 
recreated in this window.
+      assertSessionClosed(key)
+
+      // Callers that lose the removal, such as a concurrent ReleaseSession or 
a maintenance run
+      // that observed the holder before it was removed, must not close it 
again or touch its
+      // tombstone.
+      assert(!sessionManager.tryRemoveSessionHolder(holder))
+      sessionManager.closeSession(key)
+      sessionManager.periodicMaintenance(defaultInactiveTimeoutMs = 0L)

Review Comment:
   Added `maintenance that observed a session but lost its removal does not 
close it` in f2ebb5a9e5. It blocks maintenance after it has seen the holder, 
lets a concurrent close win, then checks that maintenance does not close or 
re-tombstone anything. I checked that the test fails if the removal gate in 
`periodicMaintenance` is removed.



##########
sql/connect/server/src/main/scala/org/apache/spark/sql/connect/service/SparkConnectSessionManager.scala:
##########
@@ -224,44 +224,58 @@ class SparkConnectSessionManager extends Logging {
     session
   }
 
-  // Removes session from sessionStore and returns it.
-  private def removeSessionHolder(
-      key: SessionKey,
-      allowReconnect: Boolean = false): Option[SessionHolder] = {
-    var sessionHolder: Option[SessionHolder] = None
-
-    // The session holder should remain in the session store until it is added 
to the closed session
-    // cache, because of a subtle data race: a new session with the same key 
can be created if the
-    // closed session cache does not contain the key right after the key has 
been removed from the
-    // session store.
-    sessionHolder = Option(sessionStore.get(key))
-
-    sessionHolder.foreach { s =>
-      if (!allowReconnect) {
-        // Put into closedSessionsCache to prevent the same session from being 
recreated by
-        // getOrCreateIsolatedSession when reconnection isn't allowed.
-        closedSessionsCache.put(s.key, s.getSessionHolderInfo)
-      }
+  // Removes the session with the given key from sessionStore, if any, and 
returns it.
+  private[service] def removeSessionHolder(key: SessionKey): 
Option[SessionHolder] = {
+    Option(sessionStore.get(key)).filter(tryRemoveSessionHolder)
+  }
 
-      // Then, remove the session holder from the session store.
-      sessionStore.remove(key)
-    }
-    sessionHolder
+  // Removes the given session from sessionStore if it is still stored there. 
Returns true only
+  // for the one caller that removed it; that caller must then call 
shutdownSessionHolder.
+  //
+  // The session is tombstoned before it is removed, and the tombstone is kept 
until
+  // shutdownSessionHolder has finished closing it. Otherwise a session with 
the same key could be
+  // created while the old session is still closing, and the key-based cleanup 
in
+  // SessionHolder.close() would tear it down.
+  private[service] def tryRemoveSessionHolder(sessionHolder: SessionHolder): 
Boolean = {
+    // Tombstone and remove the session in one atomic step for the key, 
matched by identity, so
+    // that two callers cannot both remove the same session, a caller that 
loses cannot re-put a
+    // tombstone that the owner already dropped, and a later session with the 
same key is never
+    // removed.
+    val removed = new AtomicBoolean(false)
+    sessionStore.computeIfPresent(
+      sessionHolder.key,
+      (_, current) => {
+        if (current eq sessionHolder) {
+          closedSessionsCache.put(current.key, current.getSessionHolderInfo)
+          removed.set(true)
+          null
+        } else {
+          current
+        }
+      })
+    removed.get()
   }
 
   // Shuts down the session after removing.
-  private def shutdownSessionHolder(
+  private[service] def shutdownSessionHolder(

Review Comment:
   Made `removeSessionHolder` and `shutdownSessionHolder` `private` again in 
f2ebb5a9e5. `tryRemoveSessionHolder` is `private[service]` because the race 
tests call it directly.



-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: [email protected]

For queries about this service, please contact Infrastructure at:
[email protected]


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

Reply via email to