This is an automated email from the ASF dual-hosted git repository.

HyukjinKwon pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/spark.git


The following commit(s) were added to refs/heads/master by this push:
     new 424330ab97f6 [SPARK-57655][CONNECT][PYTHON] Avoid re-entrant Spark 
Connect ML cache cleanup RPC hang
424330ab97f6 is described below

commit 424330ab97f6480433a680c91983784c7c7c56de
Author: Hyukjin Kwon <[email protected]>
AuthorDate: Wed Jun 24 19:43:18 2026 +0900

    [SPARK-57655][CONNECT][PYTHON] Avoid re-entrant Spark Connect ML cache 
cleanup RPC hang
    
    ### What changes were proposed in this pull request?
    Add a same-thread re-entrancy guard around the best-effort ML-cache RPCs 
`SparkConnectClient._cleanup_ml_cache` / `_delete_ml_cache`. If one is already 
in flight on the current thread, the nested call is skipped and a `WARNING` is 
logged instead of issuing a second blocking RPC.
    
    ### Why are the changes needed?
    A rare CI hang — e.g. `pyspark.ml.tests.connect.test_parity_clustering` 
timing out at 450s — traces to a **re-entrant ML-cache RPC**. While a 
cleanup/delete RPC is blocked in gRPC with the GIL released, CPython runs a 
pending `RemoteModelRef` finalizer (`__del__` → `del_remote_cache` → 
`_delete_ml_cache`) **on the same thread**, issuing a second blocking RPC that 
deadlocks the channel until the process/test timeout (faulthandler dump 
confirmed the re-entrant stack). The nested call is [...]
    
    ### Does this PR introduce any user-facing change?
    No.
    
    ### How was this patch tested?
    The underlying hang is a rare, timing-dependent flake (observed ~once in 
two months) that cannot be reproduced on demand, so this **cannot be proven to 
eliminate it** — it is a no-regression safety net plus a diagnostic. In normal 
operation no ML-cache RPC is in flight when another is issued, so behavior is 
unchanged. Verified on a fork by building Spark Connect and running 
`test_parity_clustering` **15×**, each run actually executing (~30s, not 
skipped) — all 15 passed.
    
    - ❌ Before (450s hang, scheduled `Build / Non-ANSI (branch-4.x, ...)`, 
module `pyspark-ml-connect`): 
https://github.com/apache/spark/actions/runs/28004040195
    - ✅ After (this fix, `test_parity_clustering` ×15 actually executing, all 
green): https://github.com/HyukjinKwon/spark/actions/runs/28075822467
    
    ### Was this patch authored or co-authored using generative AI tooling?
    Yes, Generated-by: Claude Code
    
    This pull request and its description were written by Isaac.
    
    Closes #56725 from HyukjinKwon/SPARK-57655.
    
    Authored-by: Hyukjin Kwon <[email protected]>
    Signed-off-by: Hyukjin Kwon <[email protected]>
---
 python/pyspark/sql/connect/client/core.py | 44 +++++++++++++++++++++++++++++--
 1 file changed, 42 insertions(+), 2 deletions(-)

diff --git a/python/pyspark/sql/connect/client/core.py 
b/python/pyspark/sql/connect/client/core.py
index 6e0d4cbcf1ef..db6067f25e09 100644
--- a/python/pyspark/sql/connect/client/core.py
+++ b/python/pyspark/sql/connect/client/core.py
@@ -703,6 +703,14 @@ class SparkConnectClient(object):
     Conceptually the remote spark session that communicates with the server
     """
 
+    # Thread id currently executing a best-effort ML-cache RPC (clean_cache / 
delete), or None.
+    # Used to detect re-entrant ML-cache RPCs on the same thread: a CPython 
finalizer
+    # (RemoteModelRef.__del__ -> del_remote_cache -> _delete_ml_cache) can 
fire while the GIL is
+    # released inside a blocking ML-cache RPC, issuing a second blocking RPC 
on the same thread
+    # that deadlocks the gRPC channel and hangs until the test/process 
timeout. See the guards in
+    # _cleanup_ml_cache / _delete_ml_cache.
+    _ml_cache_rpc_thread: Optional[int] = None
+
     def __init__(
         self,
         connection: Union[str, ChannelBuilder],
@@ -2397,12 +2405,31 @@ class SparkConnectClient(object):
         # try best to delete the cache
         try:
             if len(cache_ids) > 0:
+                # Re-entrancy guard: this is reachable from a RemoteModelRef 
finalizer
+                # (__del__ -> del_remote_cache), which CPython may run on this 
thread while the
+                # GIL is released inside another in-flight ML-cache RPC (e.g. 
_cleanup_ml_cache's
+                # blocking call). Issuing a second blocking RPC re-entrantly 
can deadlock the gRPC
+                # channel and hang until the test/process timeout. The nested 
delete is redundant
+                # (the in-flight cleanup/delete is already releasing 
server-side state, and the
+                # server evicts on session end), so skip it and log so a 
recurrence in scheduled
+                # jobs is visible instead of a silent multi-minute hang.
+                if self._ml_cache_rpc_thread == threading.get_ident():
+                    logger.warning(
+                        "Skipping re-entrant ML cache delete of %s object 
ref(s) while another "
+                        "ML-cache RPC is in flight on this thread (avoids a 
re-entrant gRPC hang).",
+                        len(cache_ids),
+                    )
+                    return []
                 command = pb2.Command()
                 command.ml_command.delete.obj_refs.extend(
                     [pb2.ObjectRef(id=cache_id) for cache_id in cache_ids]
                 )
                 command.ml_command.delete.evict_only = evict_only
-                _, properties, _ = self.execute_command(command)
+                self._ml_cache_rpc_thread = threading.get_ident()
+                try:
+                    _, properties, _ = self.execute_command(command)
+                finally:
+                    self._ml_cache_rpc_thread = None
 
                 assert properties is not None
 
@@ -2435,9 +2462,22 @@ class SparkConnectClient(object):
 
     def _cleanup_ml_cache(self) -> None:
         try:
+            # See _delete_ml_cache for the re-entrancy rationale. If a 
finalizer-driven ML-cache
+            # RPC is already in flight on this thread, skip this nested 
cleanup rather than risk a
+            # re-entrant gRPC hang; the in-flight RPC plus server-side session 
eviction cover it.
+            if self._ml_cache_rpc_thread == threading.get_ident():
+                logger.warning(
+                    "Skipping re-entrant ML cache cleanup while another 
ML-cache RPC is in flight "
+                    "on this thread (avoids a re-entrant gRPC hang)."
+                )
+                return
             command = pb2.Command()
             command.ml_command.clean_cache.SetInParent()
-            self.execute_command(command)
+            self._ml_cache_rpc_thread = threading.get_ident()
+            try:
+                self.execute_command(command)
+            finally:
+                self._ml_cache_rpc_thread = None
         except Exception:
             pass
 


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

Reply via email to