spark-test commented on code in PR #59952:
URL: https://github.com/apache/spark/pull/59952#discussion_r4429037137


##########
sql/connect/server/src/main/scala/org/apache/spark/sql/connect/service/SessionHolder.scala:
##########
@@ -439,6 +435,15 @@ case class SessionHolder(userId: String, sessionId: 
String, session: SparkSessio
     // Clean up ML cache (only if ML models were created)
     mlCache.close()
 
+    // Close the catalogs loaded by this session, then clean up all artifacts. 
Catalogs are closed
+    // first because they may have been loaded by the session classloader, 
which is closed with
+    // the artifacts. Otherwise resources held by the catalogs (e.g. thread 
pools) keep the
+    // session classloader alive. Cloned sessions own their catalogs and 
artifacts, so this does
+    // not affect them.
+    // Note: interrupted executions may still be running; they fail if they 
try to load a catalog
+    // after this point. There can also be concurrent AddArtifact calls still 
adding something.
+    session.sessionState.close()

Review Comment:
   **Non-blocking (P2):** Artifacts used to be released first in `close()`, so 
a failure in a later step could not skip them. Now they are released only here, 
after `removeAllPipelineExecutions()` and the other teardown steps, and 
`removeAllPipelineExecutions()` throws when a pipeline flow fails to stop 
(`RunTerminationException`) or its control thread does not stop in time 
(`TimeoutException`). When any earlier step throws, neither the session's 
catalogs nor its artifacts are released. The cached artifact blocks stay in the 
driver `BlockManager`, which the garbage-collection cleaner never cleans, and 
the session classloader stays open. The holder has already been removed and 
refuses a second close. Running `session.sessionState.close()` in a `finally` 
around the earlier steps, with `Utils.tryWithSafeFinally`, keeps the 
catalogs-before-artifacts order and restores the old guarantee.
   
   See **Shared repair plan 1** in the review body.



##########
sql/core/src/main/scala/org/apache/spark/sql/internal/SessionState.scala:
##########
@@ -133,6 +133,21 @@ private[sql] class SessionState(
    */
   def clone(newSparkSession: SparkSession): SessionState = 
createClone(newSparkSession, this)
 
+  /**
+   * Release the resources owned by this `SessionState`: the catalogs loaded 
by its
+   * [[CatalogManager]], then the session artifacts. Catalogs are closed first 
since they may have
+   * been loaded by the session classloader, which is closed with the 
artifacts. Clones have their
+   * own catalogs and artifacts, so closing a `SessionState` does not affect 
its clones. No catalog
+   * can be loaded afterwards.
+   */
+  def close(): Unit = {
+    try {
+      catalogManager.close()

Review Comment:
   **Blocking (P1):** `catalogManager` is `analyzer.catalogManager`, and 
`analyzer` is a lazy val, so this line builds the analyzer for any Spark 
Connect session that never analyzed a plan, such as one that only sent Config 
requests before it was released or expired. That creates the `SessionCatalog` 
(copying the Hadoop and SQL configuration), the `V2SessionCatalog`, the 
`DefaultCatalogManager` and the `Analyzer`, and runs the 
`SparkSessionExtensions` rule builders on the ReleaseSession or maintenance 
thread, only to close an empty catalog map. Closing such a session did none of 
this before. If the construction throws, `SessionHolder.close()` fails after 
the artifacts are released and skips `cleanupPythonWorkerLogs()` and 
`postClosed()`, so the session manager never records the session as closed.
   
   **Recommended change:** Make SessionState.close() close the catalog manager 
only when the analyzer has already been built. If the analyzer is first built 
after close(), close its catalog manager before the analyzer is handed out, so 
either way no catalog can be loaded through a released session.
   
   **Why this works:** SessionState records that close() ran and whether its 
analyzer has been initialized. close() sets the closed flag and reads the 
initialized flag under the lock that guards the lazy analyzer's initialization, 
then closes the existing catalog manager if there is one, and then the 
artifacts. The analyzer initializer marks itself initialized and, when the 
closed flag is already set, closes the new catalog manager before returning. A 
session that never analyzed a plan therefore releases without building 
anything, while a racing or later first analysis still finds a terminal manager.
   
   **Scope:** Track analyzer initialization and closed state in SessionState so 
that releasing a session never builds its analyzer stack.
   
   **Compatibility:** Sessions that already analyzed plans release exactly as 
before: their loaded catalogs are closed first, then their artifacts.
   
   **Risks:** The analyzer initializer closes an empty catalog manager while 
holding the SessionState lazy-initialization lock. That adds a 
SessionState-then-catalog-manager lock order, which no current code path 
reverses.
   
   **Constraints:** Catalog loads after close() must keep failing with the 
closed-manager error whether the analyzer was built before or after close(). 
Catalogs must still be closed before artifacts.
   
   **Success:** Releasing a Spark Connect session that never analyzed a plan 
does not construct its analyzer, SessionCatalog, V2SessionCatalog or catalog 
manager, and the session is still recorded as closed. Releasing a session whose 
analyzer exists still closes its loaded catalogs before its artifacts. If a 
released session's analyzer is first built after release, its catalog manager 
is already closed, so catalog loads fail with the closed-manager error.
   
   **Verification:**
   - **Regression:** Releasing a Spark Connect session that only issued 
configuration requests leaves its analyzer unbuilt (for example, an injected 
resolution-rule builder is never invoked), and the session is still recorded as 
closed.
   - **Behavior:** After such a release, the first analysis on the released 
session's state finds a closed catalog manager: loading a configured catalog 
fails with the closed-manager error.
   - **Behavior:** Releasing a session that loaded a closeable catalog still 
closes that catalog.



##########
sql/catalyst/src/main/scala/org/apache/spark/sql/connector/catalog/CatalogManager.scala:
##########
@@ -156,7 +174,10 @@ private[sql] class DefaultCatalogManager(
   override private[sql] def v2SessionCatalog: CatalogPlugin = {
     conf.getConf(SQLConf.V2_SESSION_CATALOG_IMPLEMENTATION) match {
       case "builtin" => defaultSessionCatalog
-      case _ => catalogs.getOrElseUpdate(SESSION_CATALOG_NAME, 
loadV2SessionCatalog())
+      case _ => catalogs.getOrElseUpdate(SESSION_CATALOG_NAME, {
+        checkNotClosed(SESSION_CATALOG_NAME)

Review Comment:
   **Non-blocking (P2):** `catalog()` is `synchronized`, so its check, load and 
insert are atomic with `close()`. This branch is not, and `v2SessionCatalog` is 
called directly without the lock, for example by `LookupCatalog` for 
`global_temp` names and by `CatalogV2Util.getTableProviderCatalog`. With a 
custom `spark_catalog` that the session has not loaded yet, a lookup can pass 
`checkNotClosed` before `close()` runs, finish `loadV2SessionCatalog()` after 
`close()` has cleared the map, and register an instance that is never closed. 
Later lookups then return it from the map without reaching the check. Running 
this branch under the manager lock, as `catalog()` already does when it loads 
the session catalog, closes the window without adding a new lock order.
   
   **Verification:**
   - **Regression:** With a custom spark_catalog whose initialization is held 
on a latch, a direct v2SessionCatalog load that starts before close() and 
finishes after it ends with the loaded instance closed or with the 
closed-manager error, and the manager serves no unclosed session catalog 
afterwards.
   - **Behavior:** A custom v2 session catalog loaded before close() is still 
closed by close(), later access fails with the closed-manager error, and the 
builtin session catalog stays available.



##########
sql/core/src/main/scala/org/apache/spark/sql/internal/SessionState.scala:
##########
@@ -133,6 +133,21 @@ private[sql] class SessionState(
    */
   def clone(newSparkSession: SparkSession): SessionState = 
createClone(newSparkSession, this)
 
+  /**
+   * Release the resources owned by this `SessionState`: the catalogs loaded 
by its
+   * [[CatalogManager]], then the session artifacts. Catalogs are closed first 
since they may have
+   * been loaded by the session classloader, which is closed with the 
artifacts. Clones have their
+   * own catalogs and artifacts, so closing a `SessionState` does not affect 
its clones. No catalog
+   * can be loaded afterwards.
+   */
+  def close(): Unit = {

Review Comment:
   **Non-blocking (P2):** This is the new release point, but only Spark 
Connect's `SessionHolder.close()` calls it. Two other places discard sessions 
that own their own catalog manager. In multi-session mode, the Thrift server's 
`SparkSQLSessionManager.closeSession` drops each connection's `newSession()` 
after uncaching its temp views. Each streaming query also runs in 
`StreamExecution.sparkSessionForStream`, which nothing releases when the query 
ends. That per-query session loads its own catalog instance without any user 
code when the sink is a `TransactionalCatalogPlugin` table, and it loads more 
when Spark Connect Scala `foreachBatch` code resolves tables through the batch 
DataFrame's session. Catalogs loaded on those paths keep leaking their threads 
and clients. That includes Spark Connect sessions that ran such a query, which 
is the leak this change sets out to fix.
   
   **Recommended change:** Release the connection's own session in the Thrift 
server's closeSession, and only when it is the per-connection session 
registered for that connection, never the server's root session. Release a 
streaming query's per-query session once the query has terminated and stopped 
its sources and sinks, using the same SessionState.close(). Keep a repeated 
release of the same session state harmless, so the Python foreachBatch holder 
and the streaming engine can both release it. Then update the CatalogPlugin 
Javadoc's description of when Spark releases sessions and the catalog manager's 
caller comments to match.
   
   **Why this works:** Each owner that discards a session now calls the shared 
release at its discard point, so catalogs loaded through Thrift and streaming 
per-query sessions are closed the same way as those of Spark Connect sessions.
   
   **Scope:** Extend the session-state release to Thrift server and streaming 
per-query sessions.
   
   **Compatibility:** Spark Connect session release, the Python foreachBatch 
cloned-holder release and the Thrift server's single-session mode behave as 
before.
   
   **Risks:** Releasing a streaming query's per-query session at termination 
closes its catalogs and artifact copies, so user code that kept a batch 
DataFrame's session beyond the query's lifetime would see a closed catalog 
manager.
   
   **Constraints:** Never release the Thrift server's root session: not in 
single-session mode, where every connection shares it, and not when 
closeSession falls back to it because no session was registered for the 
connection, as happens when opening the session fails. Release a streaming 
query's session only after the query has stopped its sources and sinks. 
Releasing a session that never analyzed a plan must stay cheap; this relies on 
the repair that stops session release from building the analyzer.
   
   **Exclusions:**
   - Classic sessions that applications create and drop themselves, which have 
no release point.
   
   **Success:** Closing a multi-session Thrift server connection closes the 
closeable catalogs its session loaded. When a streaming query terminates, 
closeable catalogs loaded through its per-query session are closed. This 
includes the catalog of a transactional streaming sink, Spark Connect Scala 
foreachBatch queries and queries stopped by a Spark Connect session release. 
Releasing the same session state twice has no further effect.
   
   **Verification:**
   - **Behavior:** A closeable catalog loaded by a Thrift server connection in 
multi-session mode is closed when that connection's session is closed.
   - **Behavior:** A closeable catalog loaded through a streaming query's 
per-query session, both for a transactional catalog sink and by foreachBatch 
code through the batch DataFrame's session, is closed after the query 
terminates, both for a query stopped directly and for one stopped by a Spark 
Connect session release.
   - **Compatibility:** In Thrift server single-session mode, closing a 
connection does not release the shared session or its catalogs.
   - **Compatibility:** When opening a Thrift server session fails after the 
session was created, for example because the requested database does not exist, 
the cleanup releases neither the server's root session nor its catalogs.
   - **Regression:** A Python foreachBatch query whose cloned session holder 
and streaming termination both release the same session state closes each 
catalog once and raises no error.



##########
sql/connect/server/src/test/scala/org/apache/spark/sql/connect/service/SparkConnectSessionManagerSuite.scala:
##########
@@ -177,6 +178,21 @@ class SparkConnectSessionManagerSuite extends 
SharedSparkSession {
       "pipeline execution was not removed")
   }
 
+  test("Catalogs loaded by the session are closed when the session holder is 
closed") {
+    val key = SessionKey("user", UUID.randomUUID().toString)
+    val sessionHolder = 
SparkConnectService.sessionManager.getOrCreateIsolatedSession(key, None)
+    sessionHolder.session.conf
+      .set("spark.sql.catalog.closeable", classOf[CloseableCatalog].getName)
+    // Catalogs are loaded with the active session's conf.
+    val catalog = sessionHolder.withSession {
+      
_.sessionState.catalogManager.catalog("closeable").asInstanceOf[CloseableCatalog]
+    }
+    assert(!catalog.closed)
+
+    SparkConnectService.sessionManager.closeSession(sessionHolder.key)
+    assert(catalog.closed)

Review Comment:
   **Nit (P3):** This only checks the closed flag of a catalog loaded from the 
parent classpath, so nothing fails if `SessionState.close()` releases the 
artifacts first. The order matters for catalogs loaded from session jars. If 
the session classloader is closed first, their `close()` can fail with 
`NoClassDefFoundError`, which `DefaultCatalogManager.close()` only logs, and 
they leak again. A closeable test catalog whose `close()` records whether a 
previously added session artifact is still present would catch that.
   
   See **Shared repair plan 1** in the review body.



##########
sql/catalyst/src/main/scala/org/apache/spark/sql/connector/catalog/CatalogManager.scala:
##########
@@ -526,6 +547,34 @@ private[sql] class DefaultCatalogManager(
     }
     v1SessionCatalog.setCurrentDatabase(conf.defaultDatabase)
   }
+
+  // Close and unregister every catalog loaded by this manager that implements 
`AutoCloseable`.

Review Comment:
   **Nit (P3):** As written, this limits both closing and unregistering to 
`AutoCloseable` catalogs. `close()` clears every entry and closes only the 
`AutoCloseable` ones, so a non-closeable catalog such as the test's 
`DummyCatalog` is unregistered too, and a later `catalog(name)` throws.
   
   ```suggestion
     // Unregister every catalog loaded by this manager; close the ones that 
are `AutoCloseable`.
   ```
   
   **Verification:**
   - **Inspection:** The comment above DefaultCatalogManager.close() matches 
its behavior: every loaded catalog is unregistered, the AutoCloseable ones are 
closed, and later loads fail.



##########
sql/catalyst/src/main/java/org/apache/spark/sql/connector/catalog/CatalogPlugin.java:
##########
@@ -39,6 +39,12 @@
  * {@code spark.sql.catalog.catalog-name.(key)=(value)} will be passed in the 
case insensitive
  * string map of options in initialization with the prefix removed.
  * {@code name}, is also passed and is the catalog's name; in this case, 
"catalog-name".
+ * <p>
+ * Catalog instances are loaded per session. Implementations that hold 
resources such as clients,
+ * connections or thread pools can also implement {@link java.io.Closeable} (or
+ * {@link AutoCloseable}). Spark calls {@code close()} when it releases the 
session that loaded

Review Comment:
   **Non-blocking (P2):** This paragraph reads as if `close()` ends the 
instance's use, so an implementation that follows it will shut down its clients 
and thread pools there. Spark can still call the instance afterwards. Session 
release interrupts executions without waiting for them, so an interrupted CTAS 
can still run `dropTable` or `abortStagedChanges` on it. A table that the 
released session cached is refreshed through this instance when another session 
writes it, and a failed refresh silently drops the cache entry. Temp views over 
time-travel reads that were copied into a cloned session also keep using it. 
The paragraph also leaves out that `close()` runs at most once, on a 
Spark-internal thread outside the session's classloader and active session, 
that its exceptions are only logged, that it should not block for long, and 
that existing `AutoCloseable` implementations start receiving it with this 
change. The new comments on `SessionState.close()` and `SessionHolder.close()`, 
wh
 ich say that closing a session does not affect its clones, need the same 
correction.
   
   **Verification:**
   - **Inspection:** The CatalogPlugin class Javadoc describes the call count, 
the calling thread and context, possible overlap with and later calls on the 
instance, failure handling, and the first release that calls close(), 
consistently with the catalog manager's close() and Spark Connect session 
release.
   - **Inspection:** The SessionState.close() scaladoc and the 
SessionHolder.close() comment say that temp views copied into a cloned session 
can still use the closed session's catalog instances.



-- 
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