HyukjinKwon commented on code in PR #59277:
URL: https://github.com/apache/spark/pull/59277#discussion_r4242765483
##########
sql/connect/server/src/main/scala/org/apache/spark/sql/connect/service/SparkConnectSessionManager.scala:
##########
@@ -224,46 +241,75 @@ class SparkConnectSessionManager extends Logging {
session
}
- // Removes session from sessionStore and returns it.
+ // Removes the session with the given key from sessionStore, if any. Returns
the removed
+ // session and whether its tombstone is dropped after the close, see
tryRemoveSessionHolder.
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)
- }
-
- // Then, remove the session holder from the session store.
- sessionStore.remove(key)
+ allowReconnect: Boolean): Option[(SessionHolder, Boolean)] = {
+ Option(sessionStore.get(key)).flatMap { sessionHolder =>
+ tryRemoveSessionHolder(sessionHolder, allowReconnect).map(sessionHolder
-> _)
}
- sessionHolder
+ }
+
+ // Removes the given session from sessionStore if it is still stored there.
Returns
+ // Some(dropTombstone) only for the one caller that removed it; that caller
must then call
+ // shutdownSessionHolder with it. Returns None if the session was not
removed.
+ //
+ // The session id is fenced before the session is removed, and the fence 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. dropTombstone is decided here,
once: if true, the
+ // fence is an entry in closingSessions that is dropped after the close, so
the session id can
+ // be reused; otherwise it is a tombstone in closedSessionsCache that is
kept.
+ private[service] def tryRemoveSessionHolder(
+ sessionHolder: SessionHolder,
+ allowReconnect: Boolean = false): Option[Boolean] = {
+ // Fence 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 fence
+ // that the owner already dropped, and a later session with the same key
is never removed.
+ var removed: Option[Boolean] = None
+ sessionStore.computeIfPresent(
+ sessionHolder.key,
+ (_, current) => {
+ if (current eq sessionHolder) {
+ val dropTombstone = allowReconnect ||
current.allowReconnectAfterClose
+ if (dropTombstone) {
+ closingSessions.put(current.key, current)
+ } else {
+ closedSessionsCache.put(current.key, current.getSessionHolderInfo)
+ }
+ removed = Some(dropTombstone)
+ null
+ } else {
+ current
+ }
+ })
+ removed
}
// Shuts down the session after removing.
private def shutdownSessionHolder(
sessionHolder: SessionHolder,
- allowReconnect: Boolean = false): Unit = {
- sessionHolder.close()
- if (!allowReconnect) {
- // Update in closedSessionsCache: above it wasn't updated with
closedTime etc. yet.
- closedSessionsCache.put(sessionHolder.key,
sessionHolder.getSessionHolderInfo)
+ dropTombstone: Boolean): Unit = {
+ try {
+ sessionHolder.close()
+ } finally {
Review Comment:
**Nit (P3):** Two guarantees added here would regress without any test
failing:
- If `close()` throws, this `finally` still releases a reconnect-allowed id
and refreshes a default tombstone. `BlockingCloseSessionHolder` always calls
`super.close()`, so no test makes `close()` throw. If the release moved back
after `close()`, a throwing close would leave a permanent `closingSessions`
entry, because that map is never evicted, and the id would get `SESSION_CLOSED`
until the server restarts.
- `validateCloneTargetSession` now checks `isSessionClosed`, so cloning into
an id whose reconnect-allowed close is still running fails with
`TARGET_SESSION_ID_ALREADY_CLOSED`. No test clones into an id that is still
closing, so reverting line 212 to the cache-only check would keep every test
green.
A throwing variant of the blocking holder and one `cloneSession` call while
the close is held open would cover both.
**Verification:**
- **Regression:** With a close() that throws, a reconnect-allowed close
leaves the id reusable for a new session, and a default close leaves it
tombstoned with a closed time.
- **Regression:** While a reconnect-allowed close is held open, cloning into
its id fails with TARGET_SESSION_ID_ALREADY_CLOSED. After the close returns,
the clone succeeds.
##########
sql/connect/server/src/main/scala/org/apache/spark/sql/connect/config/Connect.scala:
##########
@@ -150,6 +150,23 @@ 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 and
must create a new " +
Review Comment:
**Nit (P3):** This holds for session-creating RPCs that carry
`client_observed_server_side_session_id` (ExecutePlan, AnalyzePlan, Config,
AddArtifacts and so on), but not in general:
- ReattachExecute, GetStatus, ReleaseExecute, and the CloneSession source go
through `getIsolatedSession`. Its not-found branch throws `SESSION_NOT_FOUND`
before `validateSessionId` runs. A PySpark client that is mid-stream when its
opted-in session expires gets `RESPONSE_ALREADY_RECEIVED` from `reattach.py`
and is not marked closed.
- A client that never sends the observed id, which is optional in
`base.proto`, gets the new, empty session without any error.
Two changes would make the doc hold. First, `getIsolatedSession` could throw
`sessionChangedError(key)` from its not-found branch when an observed id is
supplied, after the `isSessionClosed` check. Second, the doc here and in
`docs/configuration.md` could say that the signal relies on the client sending
its observed server-side session id, as the built-in Python and Scala clients
do.
**Verification:**
- **Behavior:** After an opted-in session expires, a lookup through
getIsolatedSession that carries the old server-side session id reports
SESSION_CHANGED, and one without an observed id reports SESSION_NOT_FOUND.
- **Compatibility:** A closed or still-closing id keeps reporting
SESSION_CLOSED through getIsolatedSession whether or not an observed id is
supplied, and a live session with a matching observed id is still returned.
- **Inspection:** The conf doc and the docs/configuration.md row describe
the observed-id precondition for SESSION_CHANGED and contain the same text.
##########
sql/connect/server/src/main/scala/org/apache/spark/sql/connect/service/SparkConnectSessionManager.scala:
##########
@@ -97,6 +109,11 @@ class SparkConnectSessionManager extends Logging {
key,
Some(() => {
validateSessionCreate(key)
+ // A client that observed a previous session expects that session, so
do not create a
+ // new one that it cannot use. Throwing here leaves sessionStore
unchanged.
+ if (previouslyObservedSesssionId.isDefined) {
+ throw sessionChangedError(key)
Review Comment:
**Nit (P3):** This error is now the documented signal for a client whose
opted-in session was closed on a server that is still running. The
`INVALID_HANDLE.SESSION_CHANGED` message in `error-conditions.json` says "The
existing Spark server driver instance has restarted. Please reconnect.", so
users who hit it after an inactivity expiry are sent looking for a restart that
never happened. The message also doesn't say that a new `SparkSession` is
needed. PySpark and the Scala `ResponseValidator` key on the condition name, so
a more general message would not change client handling, for example: "The
server-side session has changed, for example because the server restarted or
the session was closed. Please create a new session."
**Verification:**
- **Inspection:** The SESSION_CHANGED message names both a server restart
and a closed session whose id was reused as possible causes, and asks the user
to create a new session.
- **Compatibility:** Error-condition registry format checks still pass, and
client detection by the INVALID_HANDLE.SESSION_CHANGED condition is unchanged.
--
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]