This is an automated email from the ASF dual-hosted git repository.
zhengruifeng pushed a commit to branch branch-4.x
in repository https://gitbox.apache.org/repos/asf/spark.git
The following commit(s) were added to refs/heads/branch-4.x by this push:
new a18f29613955 [SPARK-58846][CONNECT][PYTHON] Add a client-side deadline
to the RemoveRemoteCachedRelation release RPC
a18f29613955 is described below
commit a18f29613955f989583b91555d478f35eb68edc5
Author: kishor-rkrishnan <[email protected]>
AuthorDate: Mon Aug 24 16:35:30 2026 +0800
[SPARK-58846][CONNECT][PYTHON] Add a client-side deadline to the
RemoveRemoteCachedRelation release RPC
### What changes were proposed in this pull request?
`CachedRemoteRelation.__del__` (`python/pyspark/sql/connect/plan.py`)
releases a server-cached
DataFrame by issuing a blocking, non-reattachable `ExecutePlan` RPC (a
`RemoveRemoteCachedRelation`
command) through a raw `unary_unary` channel, with **no client-side
deadline**. This PR bounds that
call:
- Add a `release_relation` field to `RpcDeadlines`
(`python/pyspark/sql/connect/client/core.py`),
default `60` (seconds), and include it in `RpcDeadlines.disabled()`.
- Pass it as the gRPC `timeout` on the release call in `plan.py`.
- Document in the `RpcDeadlines` docstring why this non-reattachable
`ExecutePlan` call *is* given a
deadline (unlike a query `ExecutePlan`): a timeout here abandons only a
best-effort cache eviction
that the server performs independently, so nothing recoverable is killed.
No wire-protocol change; the change is entirely client-local.
`DefaultPolicy.can_retry` already
returns `False` for `DEADLINE_EXCEEDED` on non-reattachable RPCs, so on
timeout the release call is
not retried — it raises, is caught by the existing `except Exception`,
logged, and the finalizer
proceeds.
### Why are the changes needed?
Without a deadline this finalizer can block forever if the release response
is never delivered. On
the foreachBatch Spark Connect path it runs on the per-batch critical path:
the Python worker
releases the batch DataFrame (firing this finalizer) *before* it writes its
completion signal, so a
blocked release means the signal is never sent and the driver JVM
(`StreamingForeachBatchHelper`)
stays blocked on its per-batch `dataIn.readInt()` — which also has no
timeout. The streaming query
then stalls indefinitely with no error surfaced (observed as multi-hour
hangs), recovering only on
a manual restart. A bounded deadline converts this indefinite hang into a
bounded wait after which
the worker proceeds.
### Does this PR introduce _any_ user-facing change?
Yes — a behavior change, defaulted to preserve safety:
- The `RemoveRemoteCachedRelation` release RPC now has a default
client-side deadline of 60s (via
the new `RpcDeadlines.release_relation`). Previously it had no deadline
and could block forever if
the response was never delivered.
- On timeout, the eviction is abandoned (the existing warning is logged)
and execution continues.
The eviction is best effort and is also performed server-side, so this
does not leak cached state
in normal operation (and on the foreachBatch path the JVM `finally`
evicts the same relation id).
- Configurable/opt-out via `RpcDeadlines(release_relation=<seconds>)` or
`RpcDeadlines.disabled()`.
### How was this patch tested?
- New unit test
`pyspark.sql.tests.connect.client.test_client.SparkConnectClientTestCase.test_remove_cached_relation_uses_release_relation_deadline`:
triggers `CachedRemoteRelation.__del__` against a captured channel and
asserts (a) the configured
`release_relation` deadline is forwarded as the gRPC `timeout`, and (b)
`RpcDeadlines.disabled()`
forwards `None`.
- Existing `test_each_rpc_receives_configured_deadline` still passes,
confirming the `disabled()`
and per-RPC forwarding paths remain consistent with the new field.
- Standalone end-to-end reproduction (no cluster/build): an in-process
`SparkConnectService` whose
`ExecutePlan` never responds, with a real `SparkConnectClient` pointed at
it. With no deadline the
finalizer blocks indefinitely; with a `release_relation` deadline it
returns within the deadline
(returning in exactly one deadline interval — no retry, since
`DEADLINE_EXCEEDED` is not retried
on this path).
### Was this patch authored or co-authored using generative AI tooling?
Co-authored with : Claude Code
Closes #58085 from kishor-rkrishnan/spark-connect-release-relation-deadline.
Lead-authored-by: kishor-rkrishnan
<[email protected]>
Co-authored-by: Kishor Radhakrishnan <[email protected]>
Signed-off-by: Ruifeng Zheng <[email protected]>
(cherry picked from commit 7ce639e04383074f9e2458054a14fcf6331981b1)
Signed-off-by: Ruifeng Zheng <[email protected]>
---
python/pyspark/sql/connect/client/core.py | 10 ++++++
python/pyspark/sql/connect/plan.py | 10 +++++-
.../sql/tests/connect/client/test_client.py | 42 ++++++++++++++++++++++
3 files changed, 61 insertions(+), 1 deletion(-)
diff --git a/python/pyspark/sql/connect/client/core.py
b/python/pyspark/sql/connect/client/core.py
index 1712a9fb41b8..9c036aa6810d 100644
--- a/python/pyspark/sql/connect/client/core.py
+++ b/python/pyspark/sql/connect/client/core.py
@@ -146,6 +146,14 @@ class RpcDeadlines:
fires, the server-side operation continues running; the client opens a new
ReattachExecute
stream to resume receiving results. Non-reattachable ExecutePlan has no
deadline because a
timeout there would kill the execution with no recovery path.
+
+ Note on ``release_relation``: the RemoveRemoteCachedRelation cleanup
command is sent over a
+ blocking, non-reattachable ExecutePlan call issued from
+ :meth:`CachedRemoteRelation.__del__`. Unlike a query ExecutePlan, a
timeout here does not kill
+ any recoverable execution -- it only abandons a best-effort cache eviction
that the server also
+ performs independently -- so this call is given a bounded deadline.
Without it the finalizer
+ can block forever if the release response is never delivered, which (on
the foreachBatch
+ Connect path) stalls the streaming query indefinitely.
"""
reattachable_execute_plan: Optional[float] = 10 * 60 # 10 min
@@ -155,6 +163,7 @@ class RpcDeadlines:
config: Optional[float] = 10 * 60 # 10 min
interrupt: Optional[float] = 10 * 60 # 10 min
release_session: Optional[float] = 10 * 60 # 10 min
+ release_relation: Optional[float] = 60 # 1 min; short: per-batch finalizer
artifact_status: Optional[float] = 10 * 60 # 10 min
clone_session: Optional[float] = 10 * 60 # 10 min
get_status: Optional[float] = 10 * 60 # 10 min
@@ -185,6 +194,7 @@ class RpcDeadlines:
config=None,
interrupt=None,
release_session=None,
+ release_relation=None,
artifact_status=None,
clone_session=None,
get_status=None,
diff --git a/python/pyspark/sql/connect/plan.py
b/python/pyspark/sql/connect/plan.py
index 709071b8acc2..1efd02551da7 100644
--- a/python/pyspark/sql/connect/plan.py
+++ b/python/pyspark/sql/connect/plan.py
@@ -785,7 +785,15 @@ class CachedRemoteRelation(LogicalPlan):
response_deserializer=response_deserializer,
)
metadata =
session.client._execute_plan_metadata(req.operation_id)
- channel(req, metadata=metadata) # type:
ignore[arg-type]
+ # Bound this blocking call with a client-side
deadline. It is issued from a
+ # finalizer with no other timeout at any layer;
without a deadline it can
+ # block forever if the response is never delivered,
which stalls the
+ # foreachBatch Connect handshake (the Python worker
never sends its
+ # completion signal and the driver JVM blocks on the
per-batch read). A
+ # timeout here is non-fatal: the eviction is best
effort and the server
+ # performs it independently, so on timeout we log and
move on.
+ timeout =
session.client._rpc_deadlines.release_relation
+ channel(req, metadata=metadata, timeout=timeout) #
type: ignore[arg-type]
except Exception as e:
logger.warning(f"RemoveRemoteCachedRelation failed with
exception: {e}.")
diff --git a/python/pyspark/sql/tests/connect/client/test_client.py
b/python/pyspark/sql/tests/connect/client/test_client.py
index 5e24bd686517..9a6ac163cc28 100644
--- a/python/pyspark/sql/tests/connect/client/test_client.py
+++ b/python/pyspark/sql/tests/connect/client/test_client.py
@@ -967,6 +967,48 @@ class SparkConnectClientTestCase(unittest.TestCase):
client._get_operation_statuses()
self.assertEqual(mock.captured_timeouts["GetStatus"], 55.0)
+ def test_remove_cached_relation_uses_release_relation_deadline(self):
+ """CachedRemoteRelation.__del__ must bound its release RPC with the
release_relation
+ deadline.
+
+ Regression test: the RemoveRemoteCachedRelation cleanup is a blocking,
non-reattachable
+ ExecutePlan call issued from a finalizer with no other timeout at any
layer. Without a
+ client-side deadline it can hang forever if the response is never
delivered, which on the
+ foreachBatch Connect path stalls the streaming query indefinitely (the
Python worker never
+ sends its completion signal and the driver JVM blocks on the per-batch
read).
+ """
+ from types import SimpleNamespace
+ from unittest.mock import MagicMock
+ from pyspark.sql.connect.plan import CachedRemoteRelation
+
+ def make_relation(deadlines):
+ client = SparkConnectClient(
+ "sc://foo/",
+ use_reattachable_execute=False,
+ rpc_deadlines=deadlines,
+ retry_policy=dict(max_retries=0),
+ )
+ captured = {}
+
+ def fake_call(req, metadata=None, timeout="unset"):
+ captured["timeout"] = timeout
+ return proto.ExecutePlanResponse()
+
+ client._channel = MagicMock()
+ client._channel.unary_unary.return_value = fake_call
+ rel = CachedRemoteRelation("df-id-123",
spark_session=SimpleNamespace(client=client))
+ return rel, captured
+
+ # A configured deadline is forwarded as the gRPC call timeout.
+ rel, captured = make_relation(RpcDeadlines(release_relation=44.0))
+ rel.__del__()
+ self.assertEqual(captured["timeout"], 44.0)
+
+ # Disabled deadlines forward None (opt out; rely on
transport/server-side timeouts).
+ rel, captured = make_relation(RpcDeadlines.disabled())
+ rel.__del__()
+ self.assertIsNone(captured["timeout"])
+
@unittest.skipIf(not should_test_connect, connect_requirement_message)
class SparkConnectClientReattachTestCase(unittest.TestCase):
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]