HyukjinKwon commented on code in PR #59277:
URL: https://github.com/apache/spark/pull/59277#discussion_r4262598365
##########
sql/connect/server/src/main/scala/org/apache/spark/sql/connect/config/Connect.scala:
##########
@@ -150,6 +150,28 @@ object Connect {
.intConf
.createWithDefaultString("1000")
+ val CONNECT_SESSION_ALLOW_RECONNECT_AFTER_CLOSE =
+ buildConf("spark.connect.session.allowReconnectAfterClose")
+ .doc(
+ "When true, the session id can be reused after this session is closed,
for example " +
+ "on inactivity expiry. The id stays reserved, and requests fail with
" +
+ "INVALID_HANDLE.SESSION_CLOSED, until the close finishes. After
that, a client that " +
+ "used the closed session gets INVALID_HANDLE.SESSION_CHANGED if it
sends its " +
+ "observed server-side session id, as the built-in Python and Scala
clients do. " +
Review Comment:
**Nit (P3):** The doc says a client that used the closed session gets
`INVALID_HANDLE.SESSION_CHANGED` if it sends its observed server-side session
id, "as the built-in Python and Scala clients do". Neither built-in client sets
`client_observed_server_side_session_id` on `AddArtifactsRequest` or
`ArtifactStatusesRequest`: see the request builders in
`python/pyspark/sql/connect/client/artifact.py` and in the Scala client
`ArtifactManager`. `SparkConnectAddArtifactsHandler` and
`SparkConnectArtifactStatusesHandler` then call
`getOrCreateIsolatedSession(key, None)`, so the new creation-path check never
runs for them.
These are often the first RPCs a stale client sends. The Scala client's
`execute` and `analyze` call `uploadAllClassFileArtifacts()` before the plan
RPC, so a REPL cell that defines classes uploads them first. PySpark's
`createDataFrame` uses `ArtifactStatuses` and `AddArtifacts` to cache a large
local relation. After an opted-in session expires, that first upload silently
creates a new session under the id. That session has default confs, so it has
not opted in, and it already holds the stale client's artifacts. The
replacement `SparkSession` the doc tells users to create then attaches to that
session rather than to an empty one. If nobody reattaches and sets the conf
again before that session times out, maintenance tombstones the id again. That
is the outcome the observed-id check in `getOrCreateIsolatedSession` was added
to prevent.
**Recommended change:** Set client_observed_server_side_session_id on the
AddArtifacts and ArtifactStatuses requests built by the Python and Scala client
artifact managers once the client has observed a server-side session id, as the
clients already do for their other session RPCs. Add Spark Connect client
artifact tests and a session manager test for an artifact RPC from a stale
client after an opted-in expiry.
**Why this works:** With the observed id present, both artifact handlers
reach getOrCreateIsolatedSession's new check, which throws SESSION_CHANGED
before creating a session. A stale client's first artifact upload after an
opted-in expiry then gets the same signal as its other RPCs, leaves no
non-opted-in session behind, and the doc's built-in-client claim holds.
**Scope:** Make the built-in clients' artifact requests carry the client's
observed server-side session id.
**Compatibility:** A fresh client with no observed server-side session id
still creates its session on its first artifact upload.
**Risks:** The Python ArtifactManager does not hold the client's observed
server-side session id today, so it needs the client's current value rather
than a copy taken at construction. Older servers already read the optional
field on artifact requests, so sending it only changes the outcome when the
observed session no longer exists.
**Constraints:** Preserve review-frame:observed-id-session-changed: a closed
or still-closing id still reports SESSION_CLOSED before SESSION_CHANGED. Leave
ReleaseSession behavior unchanged under review-frame:stale-release-limitation.
**Success:** After an opted-in session expires, an AddArtifacts or
ArtifactStatuses request from a stale built-in Python or Scala client fails
with INVALID_HANDLE.SESSION_CHANGED, and no session exists for the id
afterwards. A client that has not yet observed a server-side session id can
still upload artifacts before its first query and gets a new session. The conf
doc's statement that the built-in clients send their observed server-side
session id holds for every session-creating RPC they issue.
**Verification:**
- **Behavior:** A built-in client that has observed a server-side session id
sends it on artifact upload and artifact status requests, and a client that has
not observed one sends none.
- **Regression:** After an opted-in session expires, an artifact upload or
status request carrying the old observed id fails with SESSION_CHANGED and
leaves no session for the id, while a new client without an observed id can
then upload artifacts and create the session.
- **Compatibility:** Artifact uploads and status checks on a live session
whose observed id matches keep succeeding.
##########
sql/connect/server/src/test/scala/org/apache/spark/sql/connect/service/SparkConnectSessionManagerSuite.scala:
##########
@@ -159,6 +235,295 @@ 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. If
`blockInfoOn` is
+ * set, the first getSessionHolderInfo call on that thread blocks until
`releaseInfo` 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)
+
+ @volatile var blockInfoOn: Thread = null
+ val infoBlocked = new CountDownLatch(1)
+ val releaseInfo = new CountDownLatch(1)
+
+ override private[connect] def close(): Unit = {
+ closeCalls.incrementAndGet()
+ closeStarted.countDown()
+ assert(release.await(1, TimeUnit.MINUTES))
+ super.close()
+ }
+
+ override def getSessionHolderInfo: SessionHolderInfo = {
+ if (blockInfoOn eq Thread.currentThread()) {
+ blockInfoOn = null
+ infoBlocked.countDown()
+ assert(releaseInfo.await(1, TimeUnit.MINUTES))
+ }
+ super.getSessionHolderInfo
+ }
+ }
+
+ private def startBlockingCloseSession(
+ key: SessionKey,
+ allowReconnectAfterClose: Boolean = true): BlockingCloseSessionHolder = {
+ val holder = new BlockingCloseSessionHolder(key)
+ holder.initializeSession()
+ holder.session.conf.set(
+ Connect.CONNECT_SESSION_ALLOW_RECONNECT_AFTER_CLOSE.key,
+ allowReconnectAfterClose.toString)
+ SparkConnectService.sessionManager.putSessionForTesting(holder)
+ holder
+ }
+
+ test("session id stays tombstoned until the owning close completes") {
Review Comment:
Thanks @venkata91, I checked f3548e5953. `session id stays tombstoned until
the owning close completes` now runs for both values of the opt-in. For a
default close, same-id creation fails with `SESSION_CLOSED` while the close is
held open, the losing removals don't close it again, and the tombstone is kept
afterwards with `closedTimeMs` set. The PR description also now lists the
`SESSION_CHANGED` change for requests with an observed id as a user-facing
change that applies without the opt-in.
<!-- SPARK_DEV_REVIEW_REPLY
{"feedback_id":"inline:4255049992","thread_id":"inline:4255049992","verdict_sha256":"c3e71497203cd96fb8e733fa958df1d21bdb50af02c780290ea88c200dc241be"}
-->
--
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]