This is an automated email from the ASF dual-hosted git repository.
asf-gitbox-commits 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 b7d788125137 [SPARK-56538][CONNECT] Add per-RPC deadlines to Spark
Connect client
b7d788125137 is described below
commit b7d788125137db1b507c59def5701ac605ddd5ca
Author: pranavdev022 <[email protected]>
AuthorDate: Tue Jun 9 13:34:58 2026 -0400
[SPARK-56538][CONNECT] Add per-RPC deadlines to Spark Connect client
### What changes were proposed in this pull request?
Introduce a `RpcDeadlines` configuration class (Scala `case class`, Python
`dataclass`) that assigns per-RPC gRPC deadlines to every Spark Connect client
call. Each field controls the timeout for one RPC type and can be individually
set to `None` to disable.
**Defaults:**
| RPC | Default |
|-----|---------|
| reattachableExecutePlan, reattachExecute | 10 minutes |
| analyzePlan, addArtifacts | 1 hour |
| config, interrupt, releaseSession, artifactStatus, cloneSession,
getStatus, fetchErrorDetails | 10 minutes |
| Non-reattachable ExecutePlan | None (no deadline) |
### Why are the changes needed?
The Spark Connect client currently has no per-RPC timeouts. If a network
connection silently dies (load balancer drops an idle connection, firewall
closes a stale TCP socket, server becomes unreachable), the client hangs
indefinitely with no error or feedback. This is particularly problematic for
long-lived streaming responses on the reattachable execute path, where the
client expects a continuous stream that may go silent without any TCP-level
indication of failure.
Per-RPC deadlines act as a last-resort kill mechanism: if no response
arrives within the deadline window, gRPC raises `DEADLINE_EXCEEDED` on the
client side. On the reattachable path, the client transparently opens a fresh
`ReattachExecute` stream (the server-side operation continues running). On
unary RPCs, the error surfaces to the user with a hint about how to adjust or
disable deadlines.
### Does this PR introduce _any_ user-facing change?
Yes. All existing clients will get default deadlines on upgrade. Any call
that previously hung indefinitely will now fail with `DEADLINE_EXCEEDED` after
the configured timeout, accompanied by an error message explaining how to
configure or disable deadlines via `RpcDeadlines`.
Users can:
- Adjust individual deadlines:
`SparkConnectClient.builder().rpcDeadlines(RpcDeadlines(analyzePlan =
Some(2.hours))).build()`
- Disable all deadlines:
`SparkConnectClient.builder().rpcDeadlines(RpcDeadlines.disabled).build()`
- Python equivalent: `SparkConnectClient(url,
rpc_deadlines=RpcDeadlines(analyze_plan=7200.0))` or
`rpc_deadlines=RpcDeadlines.disabled()`
### How was this patch tested?
Added new tests to verify this feature in `SparkConnectClientSuite`,
`SparkConnectClientRetriesSuite`, `test_client.py`, `test_client_retries.py`.
### Was this patch authored or co-authored using generative AI tooling?
Yes. Co-authored with Claude Code (Anthropic).
Closes #55402 from pranavdev022/SPARK-56538-per-rpc-deadlines.
Authored-by: pranavdev022 <[email protected]>
Signed-off-by: Herman van Hövell <[email protected]>
(cherry picked from commit e94782ca65ebb6ae826fae734b380617a08f32db)
Signed-off-by: Herman van Hövell <[email protected]>
---
python/pyspark/sql/connect/client/artifact.py | 18 +-
python/pyspark/sql/connect/client/core.py | 162 +++++++++++++--
python/pyspark/sql/connect/client/reattach.py | 30 ++-
python/pyspark/sql/connect/client/retries.py | 5 +
.../sql/tests/connect/client/test_client.py | 170 +++++++++++++++-
.../tests/connect/client/test_client_retries.py | 86 ++++++++
.../spark/sql/connect/client/ArtifactSuite.scala | 2 +-
.../client/SparkConnectClientRetriesSuite.scala | 34 ++++
.../connect/client/SparkConnectClientSuite.scala | 221 ++++++++++++++++++++-
.../client/CustomSparkConnectBlockingStub.scala | 39 +++-
.../connect/client/CustomSparkConnectStub.scala | 14 +-
.../ExecutePlanResponseReattachableIterator.scala | 33 ++-
.../connect/client/GrpcExceptionConverter.scala | 38 +++-
.../spark/sql/connect/client/RetryPolicy.scala | 5 +
.../spark/sql/connect/client/RpcDeadlines.scala | 83 ++++++++
.../sql/connect/client/SparkConnectClient.scala | 8 +-
.../sql/connect/client/SparkConnectStubState.scala | 15 +-
.../spark/sql/connect/SparkConnectServerTest.scala | 4 +-
18 files changed, 910 insertions(+), 57 deletions(-)
diff --git a/python/pyspark/sql/connect/client/artifact.py
b/python/pyspark/sql/connect/client/artifact.py
index 277c313eef62..94879171b41f 100644
--- a/python/pyspark/sql/connect/client/artifact.py
+++ b/python/pyspark/sql/connect/client/artifact.py
@@ -169,6 +169,8 @@ class ArtifactManager:
session_id: str,
channel: grpc.Channel,
metadata: Iterable[Tuple[str, str]],
+ add_artifacts_timeout: Optional[float] = None,
+ artifact_status_timeout: Optional[float] = None,
):
self._user_context = proto.UserContext()
if user_id is not None:
@@ -176,6 +178,8 @@ class ArtifactManager:
self._stub = grpc_lib.SparkConnectServiceStub(channel)
self._session_id = session_id
self._metadata = metadata
+ self._add_artifacts_timeout = add_artifacts_timeout
+ self._artifact_status_timeout = artifact_status_timeout
def _parse_artifacts(
self, path_or_uri: str, pyfile: bool, archive: bool, file: bool
@@ -288,7 +292,11 @@ class ArtifactManager:
self, requests: Iterator[proto.AddArtifactsRequest]
) -> proto.AddArtifactsResponse:
"""Separated for the testing purpose."""
- return self._stub.AddArtifacts(requests, metadata=self._metadata)
+ return self._stub.AddArtifacts(
+ requests,
+ metadata=self._metadata,
+ timeout=self._add_artifacts_timeout,
+ )
def _request_add_artifacts(self, requests:
Iterator[proto.AddArtifactsRequest]) -> None:
response: proto.AddArtifactsResponse =
self._retrieve_responses(requests)
@@ -428,7 +436,9 @@ class ArtifactManager:
user_context=self._user_context, session_id=self._session_id,
names=[artifactName]
)
resp: proto.ArtifactStatusesResponse = self._stub.ArtifactStatus(
- request, metadata=self._metadata
+ request,
+ metadata=self._metadata,
+ timeout=self._artifact_status_timeout,
)
status = resp.statuses.get(artifactName)
return status.exists if status is not None else False
@@ -446,7 +456,9 @@ class ArtifactManager:
user_context=self._user_context, session_id=self._session_id,
names=artifact_names
)
resp: proto.ArtifactStatusesResponse = self._stub.ArtifactStatus(
- request, metadata=self._metadata
+ request,
+ metadata=self._metadata,
+ timeout=self._artifact_status_timeout,
)
cached = set()
diff --git a/python/pyspark/sql/connect/client/core.py
b/python/pyspark/sql/connect/client/core.py
index b2e30a8325db..2dd814612a9a 100644
--- a/python/pyspark/sql/connect/client/core.py
+++ b/python/pyspark/sql/connect/client/core.py
@@ -17,10 +17,12 @@
__all__ = [
"ChannelBuilder",
"DefaultChannelBuilder",
+ "RpcDeadlines",
"SparkConnectClient",
]
import atexit
+from dataclasses import dataclass, fields
import pyspark
from pyspark.sql.connect.proto.base_pb2 import FetchErrorDetailsResponse
@@ -107,7 +109,11 @@ from pyspark.sql.pandas.conversion import
_convert_arrow_table_to_pandas
from pyspark.sql.types import DataType, StructType
from pyspark.util import PythonEvalType
from pyspark.storagelevel import StorageLevel
-from pyspark.errors import PySparkValueError, PySparkAssertionError,
PySparkNotImplementedError
+from pyspark.errors import (
+ PySparkAssertionError,
+ PySparkNotImplementedError,
+ PySparkValueError,
+)
from pyspark.sql.connect.shell.progress import Progress, ProgressHandler,
from_proto
if TYPE_CHECKING:
@@ -121,6 +127,65 @@ if TYPE_CHECKING:
PYSPARK_ROOT = os.path.dirname(pyspark.__file__)
+@dataclass(frozen=True)
+class RpcDeadlines:
+ """Per-RPC timeout configuration for :class:`SparkConnectClient`.
+
+ Each field controls the timeout (in seconds as a float) for one gRPC call
type. Set a field to
+ ``None`` to disable the per-RPC timeout for that call. Use
:meth:`RpcDeadlines.disabled` to
+ create an instance with all timeouts disabled.
+
+ Note on ``reattachable_execute_plan`` and ``reattach_execute``: these
timeouts apply to each
+ individual gRPC stream segment, not to the overall query execution
lifetime. When a deadline
+ 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.
+ """
+
+ reattachable_execute_plan: Optional[float] = 10 * 60 # 10 min
+ reattach_execute: Optional[float] = 10 * 60 # 10 min
+ analyze_plan: Optional[float] = 60 * 60 # 1 hour
+ add_artifacts: Optional[float] = 60 * 60 # 1 hour
+ config: Optional[float] = 10 * 60 # 10 min
+ interrupt: Optional[float] = 10 * 60 # 10 min
+ release_session: Optional[float] = 10 * 60 # 10 min
+ artifact_status: Optional[float] = 10 * 60 # 10 min
+ clone_session: Optional[float] = 10 * 60 # 10 min
+ get_status: Optional[float] = 10 * 60 # 10 min
+ fetch_error_details: Optional[float] = 10 * 60 # 10 min
+
+ def __post_init__(self) -> None:
+ for field in fields(self):
+ value = getattr(self, field.name)
+ if value is not None and value <= 0:
+ raise PySparkValueError(
+ message=(
+ f"RpcDeadlines.{field.name} must be a positive number
or None, "
+ f"got {value!r}"
+ ),
+ )
+
+ @classmethod
+ def disabled(cls) -> "RpcDeadlines":
+ """Create an :class:`RpcDeadlines` with all per-RPC timeouts disabled.
+
+ Use this when you want to rely solely on server-side or network-layer
timeouts.
+ """
+ return cls(
+ reattachable_execute_plan=None,
+ reattach_execute=None,
+ analyze_plan=None,
+ add_artifacts=None,
+ config=None,
+ interrupt=None,
+ release_session=None,
+ artifact_status=None,
+ clone_session=None,
+ get_status=None,
+ fetch_error_details=None,
+ )
+
+
def _import_zstandard_if_available() -> Optional[Any]:
"""
Import zstandard if available, otherwise return None.
@@ -648,6 +713,7 @@ class SparkConnectClient(object):
session_hooks: Optional[list["SparkSession.Hook"]] = None,
allow_arrow_batch_chunking: bool = True,
preferred_arrow_chunk_size: Optional[int] = None,
+ rpc_deadlines: Optional[RpcDeadlines] = None,
):
"""
Creates a new SparkSession for the Spark Connect interface.
@@ -695,6 +761,10 @@ class SparkConnectClient(object):
results.
The server will attempt to use this size if it is set and within
the valid range
([1KB, max batch size on server]). Otherwise, the server's maximum
batch size is used.
+ rpc_deadlines : RpcDeadlines, optional
+ Per-RPC gRPC call timeouts in seconds (10 min for most RPCs,
+ 1 hour for analyze/addArtifacts, none for non-reattachable
execute).
+ Use :meth:`RpcDeadlines.disabled` to turn off all deadlines.
"""
self.thread_local = threading.local()
@@ -730,8 +800,17 @@ class SparkConnectClient(object):
self._channel = self._builder.toChannel()
self._closed = False
self._internal_stub = grpc_lib.SparkConnectServiceStub(self._channel)
+ self._rpc_deadlines: RpcDeadlines = (
+ rpc_deadlines if rpc_deadlines is not None else RpcDeadlines()
+ )
+ logger.info("Spark Connect RPC deadlines: %s", self._rpc_deadlines)
self._artifact_manager = ArtifactManager(
- self._user_id, self._session_id, self._channel,
self._builder.metadata()
+ self._user_id,
+ self._session_id,
+ self._channel,
+ self._builder.metadata(),
+ add_artifacts_timeout=self._rpc_deadlines.add_artifacts,
+ artifact_status_timeout=self._rpc_deadlines.artifact_status,
)
self._use_reattachable_execute = use_reattachable_execute
self._allow_arrow_batch_chunking = allow_arrow_batch_chunking
@@ -1451,7 +1530,11 @@ class SparkConnectClient(object):
try:
for attempt in self._retrying():
with attempt:
- resp = self._stub.AnalyzePlan(req,
metadata=self._builder.metadata())
+ resp = self._stub.AnalyzePlan(
+ req,
+ metadata=self._builder.metadata(),
+ timeout=self._rpc_deadlines.analyze_plan,
+ )
self._verify_response_integrity(resp)
return AnalyzeResult.fromProto(resp)
raise SparkConnectException("Invalid state during retry exception
handling.")
@@ -1480,7 +1563,12 @@ class SparkConnectClient(object):
if self._use_reattachable_execute:
# Don't use retryHandler - own retry handling is inside.
generator = ExecutePlanResponseReattachableIterator(
- req, self._stub, self._retrying, self._builder.metadata()
+ req,
+ self._stub,
+ self._retrying,
+ self._builder.metadata(),
+
reattachable_execute_plan_timeout=self._rpc_deadlines.reattachable_execute_plan,
+
reattach_execute_timeout=self._rpc_deadlines.reattach_execute,
)
try:
for b in generator:
@@ -1690,7 +1778,12 @@ class SparkConnectClient(object):
if self._use_reattachable_execute:
# Don't use retryHandler - own retry handling is inside.
generator = ExecutePlanResponseReattachableIterator(
- req, self._stub, self._retrying, self._builder.metadata()
+ req,
+ self._stub,
+ self._retrying,
+ self._builder.metadata(),
+
reattachable_execute_plan_timeout=self._rpc_deadlines.reattachable_execute_plan,
+
reattach_execute_timeout=self._rpc_deadlines.reattach_execute,
)
try:
for b in generator:
@@ -1842,7 +1935,11 @@ class SparkConnectClient(object):
try:
for attempt in self._retrying():
with attempt:
- resp = self._stub.Config(req,
metadata=self._builder.metadata())
+ resp = self._stub.Config(
+ req,
+ metadata=self._builder.metadata(),
+ timeout=self._rpc_deadlines.config,
+ )
self._verify_response_integrity(resp)
return ConfigResult.fromProto(resp)
raise SparkConnectException("Invalid state during retry exception
handling.")
@@ -1884,7 +1981,11 @@ class SparkConnectClient(object):
try:
for attempt in self._retrying():
with attempt:
- resp = self._stub.Interrupt(req,
metadata=self._builder.metadata())
+ resp = self._stub.Interrupt(
+ req,
+ metadata=self._builder.metadata(),
+ timeout=self._rpc_deadlines.interrupt,
+ )
self._verify_response_integrity(resp)
return list(resp.interrupted_ids)
raise SparkConnectException("Invalid state during retry exception
handling.")
@@ -1896,7 +1997,11 @@ class SparkConnectClient(object):
try:
for attempt in self._retrying():
with attempt:
- resp = self._stub.Interrupt(req,
metadata=self._builder.metadata())
+ resp = self._stub.Interrupt(
+ req,
+ metadata=self._builder.metadata(),
+ timeout=self._rpc_deadlines.interrupt,
+ )
self._verify_response_integrity(resp)
return list(resp.interrupted_ids)
raise SparkConnectException("Invalid state during retry exception
handling.")
@@ -1908,7 +2013,11 @@ class SparkConnectClient(object):
try:
for attempt in self._retrying():
with attempt:
- resp = self._stub.Interrupt(req,
metadata=self._builder.metadata())
+ resp = self._stub.Interrupt(
+ req,
+ metadata=self._builder.metadata(),
+ timeout=self._rpc_deadlines.interrupt,
+ )
self._verify_response_integrity(resp)
return list(resp.interrupted_ids)
raise SparkConnectException("Invalid state during retry exception
handling.")
@@ -1924,7 +2033,11 @@ class SparkConnectClient(object):
try:
for attempt in self._retrying():
with attempt:
- resp = self._stub.ReleaseSession(req,
metadata=self._builder.metadata())
+ resp = self._stub.ReleaseSession(
+ req,
+ metadata=self._builder.metadata(),
+ timeout=self._rpc_deadlines.release_session,
+ )
self._verify_response_integrity(resp)
return
raise SparkConnectException("Invalid state during retry exception
handling.")
@@ -1976,7 +2089,11 @@ class SparkConnectClient(object):
try:
for attempt in self._retrying():
with attempt:
- resp = self._stub.GetStatus(req,
metadata=self._builder.metadata())
+ resp = self._stub.GetStatus(
+ req,
+ metadata=self._builder.metadata(),
+ timeout=self._rpc_deadlines.get_status,
+ )
self._verify_response_integrity(resp)
return resp
raise SparkConnectException("Invalid state during retry exception
handling.")
@@ -2101,7 +2218,11 @@ class SparkConnectClient(object):
req.user_context.user_id = self._user_id
self._update_request_with_user_context_extensions(req)
try:
- return self._stub.FetchErrorDetails(req,
metadata=self._builder.metadata())
+ return self._stub.FetchErrorDetails(
+ req,
+ metadata=self._builder.metadata(),
+ timeout=self._rpc_deadlines.fetch_error_details,
+ )
except grpc.RpcError:
return None
@@ -2143,6 +2264,17 @@ class SparkConnectClient(object):
#
https://grpc.github.io/grpc/python/grpc.html#grpc.UnaryUnaryMultiCallable.__call__
error: grpc.Call = cast(grpc.Call, rpc_error)
status_code: grpc.StatusCode = error.code()
+ if status_code == grpc.StatusCode.DEADLINE_EXCEEDED:
+ raise SparkConnectGrpcException(
+ message=(
+ f"{error}: RPC deadline exceeded. "
+ "The client applies per-RPC timeouts to prevent silent
hangs "
+ "on broken connections. Deadlines can be configured via
the "
+ "rpc_deadlines parameter of SparkConnectClient. To disable
all "
+ "deadlines: SparkConnectClient(url,
rpc_deadlines=RpcDeadlines.disabled())."
+ ),
+ grpc_status_code=status_code,
+ ) from None
status: Optional[Status] = rpc_status.from_call(error)
if status:
for d in status.details:
@@ -2474,7 +2606,9 @@ class SparkConnectClient(object):
for attempt in self._retrying():
with attempt:
response: pb2.CloneSessionResponse = self._stub.CloneSession(
- request, metadata=self._builder.metadata()
+ request,
+ metadata=self._builder.metadata(),
+ timeout=self._rpc_deadlines.clone_session,
)
# Assert that the returned session ID matches the requested ID if one
was provided
@@ -2493,6 +2627,8 @@ class SparkConnectClient(object):
connection=new_connection,
user_id=self._user_id,
use_reattachable_execute=self._use_reattachable_execute,
+ session_hooks=self._session_hooks,
+ rpc_deadlines=self._rpc_deadlines,
)
# Ensure the session ID is correctly set from the response
new_client._session_id = response.new_session_id
diff --git a/python/pyspark/sql/connect/client/reattach.py
b/python/pyspark/sql/connect/client/reattach.py
index 5750dd045a91..6babe7a8e428 100644
--- a/python/pyspark/sql/connect/client/reattach.py
+++ b/python/pyspark/sql/connect/client/reattach.py
@@ -72,6 +72,8 @@ class ExecutePlanResponseReattachableIterator(Generator):
stub: grpc_lib.SparkConnectServiceStub,
retrying: Callable[[], Retrying],
metadata: Iterable[Tuple[str, str]],
+ reattachable_execute_plan_timeout: Optional[float] = None,
+ reattach_execute_timeout: Optional[float] = None,
):
self._request = request
self._retrying = retrying
@@ -86,6 +88,8 @@ class ExecutePlanResponseReattachableIterator(Generator):
self._operation_id = str(uuid.uuid4())
self._stub = stub
+ self._reattachable_execute_plan_timeout =
reattachable_execute_plan_timeout
+ self._reattach_execute_timeout = reattach_execute_timeout
request.request_options.append(
pb2.ExecutePlanRequest.RequestOption(
reattach_options=pb2.ReattachOptions(reattachable=True)
@@ -108,7 +112,11 @@ class ExecutePlanResponseReattachableIterator(Generator):
self._metadata = metadata
with disable_gc():
self._iterator: Optional[Iterator[pb2.ExecutePlanResponse]] = iter(
- self._stub.ExecutePlan(self._initial_request,
metadata=metadata)
+ self._stub.ExecutePlan(
+ self._initial_request,
+ metadata=metadata,
+ timeout=self._reattachable_execute_plan_timeout,
+ )
)
# Current item from this iterator.
@@ -256,7 +264,9 @@ class ExecutePlanResponseReattachableIterator(Generator):
# we get a new iterator with ReattachExecute if it was unset.
self._iterator = iter(
self._stub.ReattachExecute(
- self._create_reattach_execute_request(),
metadata=self._metadata
+ self._create_reattach_execute_request(),
+ metadata=self._metadata,
+ timeout=self._reattach_execute_timeout,
)
)
@@ -283,9 +293,23 @@ class ExecutePlanResponseReattachableIterator(Generator):
)
# Try a new ExecutePlan, and throw upstream for retry.
self._iterator = iter(
- self._stub.ExecutePlan(self._initial_request,
metadata=self._metadata)
+ self._stub.ExecutePlan(
+ self._initial_request,
+ metadata=self._metadata,
+ timeout=self._reattachable_execute_plan_timeout,
+ )
)
raise RetryException()
+ elif e.code() == grpc.StatusCode.DEADLINE_EXCEEDED:
+ # The per-RPC deadline fired. The server-side operation is
still alive; clear
+ # the iterator and raise RetryException so the retry loop
opens a fresh
+ # ReattachExecute stream with a new deadline countdown to
resume results.
+ logger.debug(
+ f"Deadline exceeded on stream for operation
{self._operation_id}; "
+ f"will reattach. (last response:
{self._last_returned_response_id})"
+ )
+ self._iterator = None
+ raise RetryException() from e
else:
# Remove the iterator, so that a new one will be created after
retry.
self._iterator = None
diff --git a/python/pyspark/sql/connect/client/retries.py
b/python/pyspark/sql/connect/client/retries.py
index 898d976f2628..86ed004c40fe 100644
--- a/python/pyspark/sql/connect/client/retries.py
+++ b/python/pyspark/sql/connect/client/retries.py
@@ -368,6 +368,11 @@ class DefaultPolicy(RetryPolicy):
# All errors messages containing `RetryInfo` should be retried.
return True
+ # DEADLINE_EXCEEDED on the reattachable execute path is handled
directly in
+ # ExecutePlanResponseReattachableIterator, which converts it to
RetryException so the
+ # server-side operation continues and a fresh ReattachExecute is
issued. We do not retry
+ # other RPCs on deadline: those are non-idempotent or the deadline
signals a genuine
+ # timeout that retrying won't fix. Keep this in sync with
RetryPolicy.scala.
return False
diff --git a/python/pyspark/sql/tests/connect/client/test_client.py
b/python/pyspark/sql/tests/connect/client/test_client.py
index 9b0f59522e25..10580bd52e62 100644
--- a/python/pyspark/sql/tests/connect/client/test_client.py
+++ b/python/pyspark/sql/tests/connect/client/test_client.py
@@ -32,6 +32,7 @@ if should_test_connect:
import pandas as pd
import pyarrow as pa
from pyspark.sql.connect.client import SparkConnectClient,
DefaultChannelBuilder
+ from pyspark.sql.connect.client.core import RpcDeadlines
from pyspark.sql.connect.client.retries import (
Retrying,
DefaultPolicy,
@@ -125,7 +126,6 @@ if should_test_connect:
if req.HasField("release_all"):
self.release_calls += 1
elif req.HasField("release_until"):
- print("increment")
self.release_until_calls += 1
class MockService:
@@ -154,7 +154,7 @@ if should_test_connect:
operation_statuses = self.DEFAULT_OPERATION_STATUSES
self._operation_statuses = {s.operation_id: s for s in
operation_statuses}
- def ExecutePlan(self, req: proto.ExecutePlanRequest, metadata):
+ def ExecutePlan(self, req: proto.ExecutePlanRequest, metadata,
timeout=None):
self.req = req
self.client_user_context_extensions =
list(req.user_context.extensions)
resp = proto.ExecutePlanResponse()
@@ -175,14 +175,14 @@ if should_test_connect:
resp.arrow_batch.row_count = 2
return [resp]
- def Interrupt(self, req: proto.InterruptRequest, metadata):
+ def Interrupt(self, req: proto.InterruptRequest, metadata,
timeout=None):
self.req = req
self.client_user_context_extensions =
list(req.user_context.extensions)
resp = proto.InterruptResponse()
resp.session_id = self._session_id
return resp
- def Config(self, req: proto.ConfigRequest, metadata):
+ def Config(self, req: proto.ConfigRequest, metadata, timeout=None):
self.req = req
self.client_user_context_extensions =
list(req.user_context.extensions)
resp = proto.ConfigResponse()
@@ -197,7 +197,7 @@ if should_test_connect:
pair.value = req.operation.get_with_default.pairs[0].value or
"true"
return resp
- def AnalyzePlan(self, req: proto.AnalyzePlanRequest, metadata):
+ def AnalyzePlan(self, req: proto.AnalyzePlanRequest, metadata,
timeout=None):
self.req = req
self.client_user_context_extensions =
list(req.user_context.extensions)
resp = proto.AnalyzePlanResponse()
@@ -206,7 +206,7 @@ if should_test_connect:
resp.semantic_hash.result = 12345
return resp
- def GetStatus(self, req: proto.GetStatusRequest, metadata):
+ def GetStatus(self, req: proto.GetStatusRequest, metadata,
timeout=None):
self.req = req
self.client_user_context_extensions =
list(req.user_context.extensions)
self.received_custom_server_session_id =
req.client_observed_server_side_session_id
@@ -711,6 +711,111 @@ class SparkConnectClientTestCase(unittest.TestCase):
resp.extensions[0].Unpack(resp_echoed)
self.assertEqual(resp_echoed.value, "request_extension")
+ def
test_analyze_plan_short_deadline_fires_then_succeeds_after_disabling(self):
+ """With a short deadline the call fails; after disabling deadlines it
succeeds."""
+
+ class CapturingMock(MockService):
+ """Captures the timeout passed by the client; raises
DEADLINE_EXCEEDED if set."""
+
+ def __init__(self, session_id):
+ super().__init__(session_id)
+ self.captured_timeout = "not_called"
+
+ def AnalyzePlan(self, req, metadata, timeout=None):
+ self.captured_timeout = timeout
+ if timeout is not None:
+ raise TestException("deadline exceeded",
grpc.StatusCode.DEADLINE_EXCEEDED)
+ return super().AnalyzePlan(req, metadata, timeout=timeout)
+
+ client_with_deadline = SparkConnectClient(
+ "sc://foo/",
+ use_reattachable_execute=False,
+ rpc_deadlines=RpcDeadlines(analyze_plan=0.050),
+ retry_policy=dict(max_retries=0),
+ )
+ mock_with_deadline =
CapturingMock(session_id=client_with_deadline._session_id)
+ client_with_deadline._stub = mock_with_deadline
+ with self.assertRaises(SparkConnectGrpcException) as cm:
+ client_with_deadline._analyze("schema", plan=proto.Plan())
+ self.assertEqual(cm.exception.getGrpcStatusCode(),
grpc.StatusCode.DEADLINE_EXCEEDED)
+ self.assertEqual(mock_with_deadline.captured_timeout, 0.050)
+
+ client_disabled = SparkConnectClient(
+ "sc://foo/",
+ use_reattachable_execute=False,
+ rpc_deadlines=RpcDeadlines.disabled(),
+ retry_policy=dict(max_retries=0),
+ )
+ mock_disabled = CapturingMock(session_id=client_disabled._session_id)
+ client_disabled._stub = mock_disabled
+ client_disabled._analyze("schema", plan=proto.Plan())
+ self.assertIsNone(mock_disabled.captured_timeout)
+
+ def test_each_rpc_receives_configured_deadline(self):
+ """Every RPC that accepts a deadline should forward it as timeout to
the stub."""
+
+ class TimeoutCapturingMock(MockService):
+ """Records the timeout kwarg for each RPC call."""
+
+ def __init__(self, session_id):
+ super().__init__(session_id)
+ self.captured_timeouts = {}
+
+ def AnalyzePlan(self, req, metadata, timeout=None):
+ self.captured_timeouts["AnalyzePlan"] = timeout
+ return super().AnalyzePlan(req, metadata, timeout=timeout)
+
+ def Config(self, req, metadata, timeout=None):
+ self.captured_timeouts["Config"] = timeout
+ return super().Config(req, metadata, timeout=timeout)
+
+ def Interrupt(self, req, metadata, timeout=None):
+ self.captured_timeouts["Interrupt"] = timeout
+ return super().Interrupt(req, metadata, timeout=timeout)
+
+ def ReleaseSession(self, req, metadata, timeout=None):
+ self.captured_timeouts["ReleaseSession"] = timeout
+ resp = proto.ReleaseSessionResponse()
+ resp.session_id = self._session_id
+ return resp
+
+ def GetStatus(self, req, metadata, timeout=None):
+ self.captured_timeouts["GetStatus"] = timeout
+ return super().GetStatus(req, metadata, timeout=timeout)
+
+ deadlines = RpcDeadlines(
+ analyze_plan=11.0,
+ config=22.0,
+ interrupt=33.0,
+ release_session=44.0,
+ get_status=55.0,
+ )
+ client = SparkConnectClient(
+ "sc://foo/",
+ use_reattachable_execute=False,
+ rpc_deadlines=deadlines,
+ retry_policy=dict(max_retries=0),
+ )
+ mock = TimeoutCapturingMock(session_id=client._session_id)
+ client._stub = mock
+
+ client._analyze("schema", plan=proto.Plan())
+ self.assertEqual(mock.captured_timeouts["AnalyzePlan"], 11.0)
+
+ op = proto.ConfigRequest.Operation()
+ op.get.keys.append("spark.sql.shuffle.partitions")
+ client.config(op)
+ self.assertEqual(mock.captured_timeouts["Config"], 22.0)
+
+ client.interrupt_all()
+ self.assertEqual(mock.captured_timeouts["Interrupt"], 33.0)
+
+ client.release_session()
+ self.assertEqual(mock.captured_timeouts["ReleaseSession"], 44.0)
+
+ client._get_operation_statuses()
+ self.assertEqual(mock.captured_timeouts["GetStatus"], 55.0)
+
@unittest.skipIf(not should_test_connect, connect_requirement_message)
class SparkConnectClientReattachTestCase(unittest.TestCase):
@@ -886,6 +991,59 @@ class
SparkConnectClientReattachTestCase(unittest.TestCase):
reattach = ite._create_reattach_execute_request()
self.assertEqual(reattach.client_observed_server_side_session_id,
session_id)
+ def test_deadline_exceeded_triggers_reattach(self):
+ """DEADLINE_EXCEEDED mid-stream on ExecutePlan should trigger a
ReattachExecute."""
+
+ def deadline_exceeded():
+ raise TestException("deadline", grpc.StatusCode.DEADLINE_EXCEEDED)
+
+ stub = self._stub_with(
+ [self.response, deadline_exceeded],
+ [self.response, self.finished],
+ )
+ ite = ExecutePlanResponseReattachableIterator(self.request, stub,
self.retrying, [])
+ for _ in ite:
+ pass
+
+ def check():
+ self.assertEqual(1, stub.execute_calls)
+ self.assertEqual(1, stub.attach_calls)
+ self.assertEqual(1, stub.release_calls)
+
+ eventually(timeout=1, catch_assertions=True)(check)()
+
+ def test_deadline_exceeded_mid_stream_completes_successfully(self):
+ """After a mid-stream DEADLINE_EXCEEDED, reattach resumes and all
responses are collected."""
+
+ response2 = proto.ExecutePlanResponse(response_id="2")
+ response3 = proto.ExecutePlanResponse(response_id="3")
+
+ def deadline_exceeded():
+ raise TestException("deadline", grpc.StatusCode.DEADLINE_EXCEEDED)
+
+ finished = proto.ExecutePlanResponse(
+ result_complete=proto.ExecutePlanResponse.ResultComplete(),
+ response_id="final",
+ )
+ stub = self._stub_with(
+ [self.response, response2, deadline_exceeded],
+ [response3, finished],
+ )
+ collected = []
+ ite = ExecutePlanResponseReattachableIterator(self.request, stub,
self.retrying, [])
+ for r in ite:
+ if not r.HasField("result_complete"):
+ collected.append(r.response_id)
+
+ self.assertEqual(collected, ["1", "2", "3"])
+
+ def check():
+ self.assertEqual(1, stub.execute_calls)
+ self.assertEqual(1, stub.attach_calls)
+ self.assertEqual(1, stub.release_calls)
+
+ eventually(timeout=1, catch_assertions=True)(check)()
+
def test_server_unreachable(self):
# DNS resolution should fail for "foo". This error is a retriable
UNAVAILABLE error.
client = SparkConnectClient(
diff --git a/python/pyspark/sql/tests/connect/client/test_client_retries.py
b/python/pyspark/sql/tests/connect/client/test_client_retries.py
index c509b4db2654..aa8612e1e232 100644
--- a/python/pyspark/sql/tests/connect/client/test_client_retries.py
+++ b/python/pyspark/sql/tests/connect/client/test_client_retries.py
@@ -27,6 +27,7 @@ if should_test_connect:
from google.rpc import status_pb2
from google.rpc import error_details_pb2
from pyspark.sql.connect.client import SparkConnectClient
+ from pyspark.sql.connect.client.core import RpcDeadlines
from pyspark.sql.connect.client.retries import (
Retrying,
DefaultPolicy,
@@ -235,6 +236,91 @@ class SparkConnectClientRetriesTestCase(unittest.TestCase):
]
self.assertListsAlmostEqual(sleep_tracker.times, expected_times,
delta=policy.jitter)
+ def test_deadline_exceeded_not_retryable_by_default_policy(self):
+ client = SparkConnectClient("sc://foo/;token=bar")
+ policy = get_client_policies_map(client).get(DefaultPolicy)
+ self.assertIsNotNone(policy)
+ self.assertFalse(
+ policy.can_retry(TestException("deadline",
code=grpc.StatusCode.DEADLINE_EXCEEDED))
+ )
+
+ def test_deadline_exceeded_not_retried_by_retry_handler(self):
+ client = SparkConnectClient("sc://foo/;token=bar")
+ sleep_tracker = SleepTimeTracker()
+ tries = 0
+ with self.assertRaises(TestException):
+ for attempt in Retrying(client._retry_policies,
sleep=sleep_tracker.sleep):
+ with attempt:
+ tries += 1
+ raise TestException("d",
code=grpc.StatusCode.DEADLINE_EXCEEDED)
+ self.assertEqual(tries, 1)
+ self.assertEqual(len(sleep_tracker.times), 0)
+
+ def test_deadline_exceeded_is_not_retried_for_non_retryable_codes(self):
+ # Sanity check: ABORTED is not retried by DefaultPolicy (unless
matching cluster message)
+ policy = DefaultPolicy()
+ self.assertFalse(
+ policy.can_retry(TestException("some aborted error",
code=grpc.StatusCode.ABORTED))
+ )
+
+ def
test_deadline_exceeded_exception_message_contains_configuration_hint(self):
+ """DEADLINE_EXCEEDED exceptions should carry a hint about how to
adjust timeouts."""
+ from pyspark.errors.exceptions.connect import SparkConnectGrpcException
+
+ client = SparkConnectClient("sc://foo/;token=bar",
retry_policy=dict(max_retries=0))
+ err = TestException("deadline exceeded",
code=grpc.StatusCode.DEADLINE_EXCEEDED)
+ with self.assertRaises(SparkConnectGrpcException) as cm:
+ client._handle_rpc_error(err)
+ self.assertIn("RPC deadline exceeded", str(cm.exception))
+ self.assertIn("RpcDeadlines.disabled()", str(cm.exception))
+ self.assertEqual(cm.exception.getGrpcStatusCode(),
grpc.StatusCode.DEADLINE_EXCEEDED)
+
+ def test_rpc_deadlines_disabled_sets_all_fields_to_none(self):
+ """RpcDeadlines.disabled() should create an instance with every field
set to None."""
+ d = RpcDeadlines.disabled()
+ self.assertIsNone(d.reattachable_execute_plan)
+ self.assertIsNone(d.reattach_execute)
+ self.assertIsNone(d.analyze_plan)
+ self.assertIsNone(d.add_artifacts)
+ self.assertIsNone(d.config)
+ self.assertIsNone(d.interrupt)
+ self.assertIsNone(d.release_session)
+ self.assertIsNone(d.artifact_status)
+ self.assertIsNone(d.clone_session)
+ self.assertIsNone(d.get_status)
+ self.assertIsNone(d.fetch_error_details)
+
+ def test_rpc_deadlines_defaults_are_set(self):
+ """RpcDeadlines() default instance should have documented timeout
values."""
+ d = RpcDeadlines()
+ self.assertEqual(d.reattachable_execute_plan, 10 * 60)
+ self.assertEqual(d.reattach_execute, 10 * 60)
+ self.assertEqual(d.analyze_plan, 60 * 60)
+ self.assertEqual(d.add_artifacts, 60 * 60)
+ self.assertEqual(d.config, 10 * 60)
+ self.assertEqual(d.interrupt, 10 * 60)
+ self.assertEqual(d.release_session, 10 * 60)
+ self.assertEqual(d.artifact_status, 10 * 60)
+ self.assertEqual(d.clone_session, 10 * 60)
+ self.assertEqual(d.get_status, 10 * 60)
+ self.assertEqual(d.fetch_error_details, 10 * 60)
+
+ def test_rpc_deadlines_rejects_non_positive_values(self):
+ """RpcDeadlines should raise ValueError for any non-None field that is
<= 0."""
+ from dataclasses import replace
+
+ with self.assertRaises(ValueError):
+ replace(RpcDeadlines(), analyze_plan=-1.0)
+ with self.assertRaises(ValueError):
+ replace(RpcDeadlines(), config=0)
+ with self.assertRaises(ValueError):
+ replace(RpcDeadlines(), reattachable_execute_plan=-0.001)
+ with self.assertRaises(ValueError):
+ replace(RpcDeadlines(), config=float("-inf"))
+ # None (disabled) and positive values should be accepted without error.
+ replace(RpcDeadlines(), analyze_plan=None)
+ replace(RpcDeadlines(), analyze_plan=0.001)
+
if __name__ == "__main__":
from pyspark.testing import main
diff --git
a/sql/connect/client/jvm/src/test/scala/org/apache/spark/sql/connect/client/ArtifactSuite.scala
b/sql/connect/client/jvm/src/test/scala/org/apache/spark/sql/connect/client/ArtifactSuite.scala
index a6ccf39886e2..bbe3fd16a969 100644
---
a/sql/connect/client/jvm/src/test/scala/org/apache/spark/sql/connect/client/ArtifactSuite.scala
+++
b/sql/connect/client/jvm/src/test/scala/org/apache/spark/sql/connect/client/ArtifactSuite.scala
@@ -57,7 +57,7 @@ class ArtifactSuite extends ConnectFunSuite {
private def createArtifactManager(): Unit = {
channel =
InProcessChannelBuilder.forName(getClass.getName).directExecutor().build()
- state = new SparkConnectStubState(channel, RetryPolicy.defaultPolicies())
+ state = new SparkConnectStubState(channel, Configuration())
bstub = new CustomSparkConnectBlockingStub(channel, state)
stub = new CustomSparkConnectStub(channel, state)
artifactManager = new ArtifactManager(Configuration(), "", bstub, stub)
diff --git
a/sql/connect/client/jvm/src/test/scala/org/apache/spark/sql/connect/client/SparkConnectClientRetriesSuite.scala
b/sql/connect/client/jvm/src/test/scala/org/apache/spark/sql/connect/client/SparkConnectClientRetriesSuite.scala
index 7ea01e34ec88..595f48b9f548 100644
---
a/sql/connect/client/jvm/src/test/scala/org/apache/spark/sql/connect/client/SparkConnectClientRetriesSuite.scala
+++
b/sql/connect/client/jvm/src/test/scala/org/apache/spark/sql/connect/client/SparkConnectClientRetriesSuite.scala
@@ -275,4 +275,38 @@ class SparkConnectClientRetriesSuite extends
ConnectFunSuite with Eventually {
policy.initialBackoff.toMillis * math.pow(policy.backoffMultiplier, i +
2).toLong)
assertLongSequencesAlmostEqual(st.times, expectedSleeps, delta =
policy.jitter.toMillis)
}
+
+ test("DEADLINE_EXCEEDED is not retryable by defaultPolicy") {
+ // DEADLINE_EXCEEDED must not be retried via canRetry. The reattachable
execute path
+ // handles it separately by converting it to RetryException in the
iterator.
+ val exception = new StatusRuntimeException(Status.DEADLINE_EXCEEDED)
+ val canRetry = RetryPolicy.defaultPolicy().canRetry
+ assert(canRetry(exception) == false)
+ }
+
+ test("DEADLINE_EXCEEDED is not retried by retry handler") {
+ // Verify the function is called exactly once and the exception propagates
immediately.
+ val dummyFn = new DummyFn(new
StatusRuntimeException(Status.DEADLINE_EXCEEDED), numFails = 1)
+ val retryPolicies = RetryPolicy.defaultPolicies()
+ val retryHandler = new GrpcRetryHandler(retryPolicies, sleep = _ => {})
+ intercept[StatusRuntimeException] {
+ retryHandler.retry { dummyFn.fn() }
+ }
+ assert(dummyFn.counter == 1)
+ }
+
+ test("RpcDeadlines.disabled creates an instance with all deadlines set to
None") {
+ val disabled = RpcDeadlines.disabled
+ assert(disabled.reattachableExecutePlan.isEmpty)
+ assert(disabled.reattachExecute.isEmpty)
+ assert(disabled.analyzePlan.isEmpty)
+ assert(disabled.addArtifacts.isEmpty)
+ assert(disabled.config.isEmpty)
+ assert(disabled.interrupt.isEmpty)
+ assert(disabled.releaseSession.isEmpty)
+ assert(disabled.artifactStatus.isEmpty)
+ assert(disabled.cloneSession.isEmpty)
+ assert(disabled.getStatus.isEmpty)
+ assert(disabled.fetchErrorDetails.isEmpty)
+ }
}
diff --git
a/sql/connect/client/jvm/src/test/scala/org/apache/spark/sql/connect/client/SparkConnectClientSuite.scala
b/sql/connect/client/jvm/src/test/scala/org/apache/spark/sql/connect/client/SparkConnectClientSuite.scala
index 22feaff1c77f..2f4d95984083 100644
---
a/sql/connect/client/jvm/src/test/scala/org/apache/spark/sql/connect/client/SparkConnectClientSuite.scala
+++
b/sql/connect/client/jvm/src/test/scala/org/apache/spark/sql/connect/client/SparkConnectClientSuite.scala
@@ -18,9 +18,10 @@ package org.apache.spark.sql.connect.client
import java.nio.charset.StandardCharsets.UTF_8
import java.util.{Base64, UUID}
-import java.util.concurrent.TimeUnit
+import java.util.concurrent.{CountDownLatch, TimeUnit}
import scala.collection.mutable
+import scala.concurrent.duration.FiniteDuration
import scala.jdk.CollectionConverters._
import com.google.protobuf.{Any => PAny, StringValue}
@@ -824,6 +825,224 @@ class SparkConnectClientSuite extends ConnectFunSuite {
assert(!headerInterceptor.headers.exists(_.containsKey(key)))
}
}
+
+ test("one-shot RPC deadlines fire on slow server") {
+ val latch = new CountDownLatch(1)
+ val slowService = new DummySparkConnectService {
+ override def analyzePlan(
+ request: AnalyzePlanRequest,
+ responseObserver: StreamObserver[AnalyzePlanResponse]): Unit = {
+ latch.await(250, TimeUnit.MILLISECONDS)
+ super.analyzePlan(request, responseObserver)
+ }
+ override def config(
+ request: proto.ConfigRequest,
+ responseObserver: StreamObserver[proto.ConfigResponse]): Unit = {
+ latch.await(250, TimeUnit.MILLISECONDS)
+ super.config(request, responseObserver)
+ }
+ override def interrupt(
+ request: proto.InterruptRequest,
+ responseObserver: StreamObserver[proto.InterruptResponse]): Unit = {
+ latch.await(250, TimeUnit.MILLISECONDS)
+ super.interrupt(request, responseObserver)
+ }
+ override def releaseSession(
+ request: proto.ReleaseSessionRequest,
+ responseObserver: StreamObserver[proto.ReleaseSessionResponse]):
Unit = {
+ latch.await(250, TimeUnit.MILLISECONDS)
+ responseObserver.onNext(
+ proto.ReleaseSessionResponse
+ .newBuilder()
+ .setSessionId(request.getSessionId)
+ .build())
+ responseObserver.onCompleted()
+ }
+ }
+ server =
NettyServerBuilder.forPort(0).addService(slowService).build().start()
+ service = slowService
+ val d = FiniteDuration(50, TimeUnit.MILLISECONDS)
+ client = SparkConnectClient
+ .builder()
+ .connectionString(s"sc://localhost:${server.getPort}")
+ .rpcDeadlines(
+ RpcDeadlines(
+ analyzePlan = Some(d),
+ config = Some(d),
+ interrupt = Some(d),
+ releaseSession = Some(d)))
+ .retryPolicy(RetryPolicy(maxRetries = Some(0), canRetry = _ => false,
name = "NoRetry"))
+ .build()
+
+ def expectDeadlineExceeded(thunk: => Any): Unit = {
+ val ex = intercept[SparkException] { thunk }
+ assert(ex.getCause.isInstanceOf[StatusRuntimeException])
+ assert(
+ ex.getCause.asInstanceOf[StatusRuntimeException].getStatus.getCode ==
+ Status.Code.DEADLINE_EXCEEDED)
+ }
+
+ try {
+ expectDeadlineExceeded(
+
client.analyze(proto.AnalyzePlanRequest.newBuilder().setSessionId("abc123").build()))
+ val configOp = proto.ConfigRequest.Operation
+ .newBuilder()
+ .setGetOption(
+
proto.ConfigRequest.GetOption.newBuilder().addKeys("spark.sql.shuffle.partitions"))
+ .build()
+ expectDeadlineExceeded(client.config(configOp))
+ expectDeadlineExceeded(client.interruptAll())
+ expectDeadlineExceeded(client.releaseSession())
+ } finally {
+ latch.countDown()
+ }
+ }
+
+ test("analyzePlan with short deadline fires, then succeeds after disabling
deadlines") {
+ val latch = new CountDownLatch(1)
+ val slowService = new DummySparkConnectService {
+ override def analyzePlan(
+ request: AnalyzePlanRequest,
+ responseObserver: StreamObserver[AnalyzePlanResponse]): Unit = {
+ latch.await(250, TimeUnit.MILLISECONDS)
+ super.analyzePlan(request, responseObserver)
+ }
+ }
+ server =
NettyServerBuilder.forPort(0).addService(slowService).build().start()
+ service = slowService
+ val noRetry = RetryPolicy(maxRetries = Some(0), canRetry = _ => false,
name = "NoRetry")
+
+ client = SparkConnectClient
+ .builder()
+ .connectionString(s"sc://localhost:${server.getPort}")
+ .rpcDeadlines(RpcDeadlines(analyzePlan = Some(FiniteDuration(50,
TimeUnit.MILLISECONDS))))
+ .retryPolicy(noRetry)
+ .build()
+ try {
+ val ex = intercept[SparkException] {
+
client.analyze(proto.AnalyzePlanRequest.newBuilder().setSessionId("abc123").build())
+ }
+ assert(
+ ex.getCause.asInstanceOf[StatusRuntimeException].getStatus.getCode ==
+ Status.Code.DEADLINE_EXCEEDED)
+ client.shutdown()
+ } finally {
+ latch.countDown()
+ }
+
+ client = SparkConnectClient
+ .builder()
+ .connectionString(s"sc://localhost:${server.getPort}")
+ .rpcDeadlines(RpcDeadlines.disabled)
+ .retryPolicy(noRetry)
+ .build()
+
client.analyze(proto.AnalyzePlanRequest.newBuilder().setSessionId("abc123").build())
+ }
+
+ test("reattachable execute recovers from mid-stream deadline via
ReattachExecute") {
+ val executeLatch = new CountDownLatch(1)
+ @volatile var reattachCalled = false
+ val midStreamService = new DummySparkConnectService {
+ override def executePlan(
+ request: ExecutePlanRequest,
+ responseObserver: StreamObserver[ExecutePlanResponse]): Unit = {
+ val sessionId = request.getSessionId
+ val operationId =
+ if (request.hasOperationId) request.getOperationId
+ else UUID.randomUUID().toString
+ val serverSideSessionId = "srv-deadline-test"
+ responseObserver.onNext(
+ ExecutePlanResponse
+ .newBuilder()
+ .setSessionId(sessionId)
+ .setServerSideSessionId(serverSideSessionId)
+ .setOperationId(operationId)
+ .setResponseId("r1")
+ .build())
+ executeLatch.await(250, TimeUnit.MILLISECONDS)
+ responseObserver.onCompleted()
+ }
+
+ override def reattachExecute(
+ request: proto.ReattachExecuteRequest,
+ responseObserver: StreamObserver[ExecutePlanResponse]): Unit = {
+ reattachCalled = true
+ val sessionId = request.getSessionId
+ val operationId = request.getOperationId
+ val serverSideSessionId = "srv-deadline-test"
+ responseObserver.onNext(
+ ExecutePlanResponse
+ .newBuilder()
+ .setSessionId(sessionId)
+ .setServerSideSessionId(serverSideSessionId)
+ .setOperationId(operationId)
+ .setResponseId("r2")
+ .build())
+ responseObserver.onNext(
+ ExecutePlanResponse
+ .newBuilder()
+ .setSessionId(sessionId)
+ .setServerSideSessionId(serverSideSessionId)
+ .setOperationId(operationId)
+ .setResponseId("r3")
+
.setResultComplete(proto.ExecutePlanResponse.ResultComplete.newBuilder().build())
+ .build())
+ responseObserver.onCompleted()
+ }
+ }
+ server =
NettyServerBuilder.forPort(0).addService(midStreamService).build().start()
+ service = midStreamService
+ client = SparkConnectClient
+ .builder()
+ .connectionString(s"sc://localhost:${server.getPort}")
+ .rpcDeadlines(
+ RpcDeadlines(reattachableExecutePlan = Some(FiniteDuration(50,
TimeUnit.MILLISECONDS))))
+ .enableReattachableExecute()
+ .build()
+
+ try {
+ val iter = client.execute(buildPlan("select 1"))
+ val reattachableIter =
ExecutePlanResponseReattachableIterator.fromIterator(iter)
+ iter.foreach(_ => ())
+
+ assert(reattachableIter.resultComplete, "iterator should complete after
reattach")
+ assert(reattachCalled, "ReattachExecute should have been called")
+ } finally {
+ executeLatch.countDown()
+ }
+ }
+
+ test("non-reattachable executePlan has no client-side deadline") {
+ val latch = new CountDownLatch(1)
+ val slowService = new DummySparkConnectService {
+ override def executePlan(
+ request: ExecutePlanRequest,
+ responseObserver: StreamObserver[ExecutePlanResponse]): Unit = {
+ latch.await(250, TimeUnit.MILLISECONDS)
+ super.executePlan(request, responseObserver)
+ }
+ }
+ server =
NettyServerBuilder.forPort(0).addService(slowService).build().start()
+ service = slowService
+ client = SparkConnectClient
+ .builder()
+ .connectionString(s"sc://localhost:${server.getPort}")
+ .disableReattachableExecute()
+ .rpcDeadlines(
+ RpcDeadlines(reattachableExecutePlan = Some(FiniteDuration(50,
TimeUnit.MILLISECONDS))))
+ .retryPolicy(RetryPolicy(maxRetries = Some(0), canRetry = _ => false,
name = "NoRetry"))
+ .build()
+ try {
+ val iter = client.execute(buildPlan("select 1"))
+ iter.foreach(_ => ())
+ } finally {
+ latch.countDown()
+ }
+ }
+
+ test("SPARK-56538: RpcDeadlines.disabled has no configured deadlines in
toString") {
+ assert(RpcDeadlines.disabled.toString === "RpcDeadlines(all disabled)")
+ }
}
class DummySparkConnectService() extends
SparkConnectServiceGrpc.SparkConnectServiceImplBase {
diff --git
a/sql/connect/common/src/main/scala/org/apache/spark/sql/connect/client/CustomSparkConnectBlockingStub.scala
b/sql/connect/common/src/main/scala/org/apache/spark/sql/connect/client/CustomSparkConnectBlockingStub.scala
index ad39a3fb29f2..a4406c2a68fd 100644
---
a/sql/connect/common/src/main/scala/org/apache/spark/sql/connect/client/CustomSparkConnectBlockingStub.scala
+++
b/sql/connect/common/src/main/scala/org/apache/spark/sql/connect/client/CustomSparkConnectBlockingStub.scala
@@ -16,9 +16,12 @@
*/
package org.apache.spark.sql.connect.client
+import java.util.concurrent.TimeUnit
+
+import scala.concurrent.duration.FiniteDuration
import scala.jdk.CollectionConverters._
-import io.grpc.ManagedChannel
+import io.grpc.{Deadline, ManagedChannel}
import org.apache.spark.connect.proto._
import org.apache.spark.sql.util.CloseableIterator
@@ -29,11 +32,22 @@ private[connect] class CustomSparkConnectBlockingStub(
private val stub = SparkConnectServiceGrpc.newBlockingStub(channel)
+ // Build a fresh deadline-bound stub on every call so each retry gets a full
budget.
+ // gRPC deadlines are absolute (fixed at stub construction); a shared stub
would let
+ // later attempts inherit an already-elapsed deadline and time out
prematurely.
+ private def withDeadline(
+ d: Option[FiniteDuration]):
SparkConnectServiceGrpc.SparkConnectServiceBlockingStub =
+ d.map(dur => stub.withDeadline(Deadline.after(dur.toMillis,
TimeUnit.MILLISECONDS)))
+ .getOrElse(stub)
+
private val retryHandler = stubState.retryHandler
// GrpcExceptionConverter with a GRPC stub for fetching error details from
server.
private val grpcExceptionConverter = stubState.exceptionConverter
+ // Non-reattachable executePlan intentionally has no deadline: a timeout
here would kill the
+ // server-side execution with no way to recover (there is no ReattachExecute
for this path).
+ // Use reattachable execution for long-running queries that need deadline
protection.
def executePlan(request: ExecutePlanRequest):
CloseableIterator[ExecutePlanResponse] = {
grpcExceptionConverter.convert(
request.getSessionId,
@@ -63,8 +77,13 @@ private[connect] class CustomSparkConnectBlockingStub(
request.getUserContext,
request.getClientType,
stubState.responseValidator.wrapIterator(
- // ExecutePlanResponseReattachableIterator does all retries by
itself, don't wrap it here
- new ExecutePlanResponseReattachableIterator(request, channel,
stubState.retryHandler)))
+ // Reattachable iterator retries internally; omit RetryIterator
wrapper here.
+ new ExecutePlanResponseReattachableIterator(
+ request,
+ channel,
+ stubState.retryHandler,
+ stubState.rpcDeadlines.reattachableExecutePlan,
+ stubState.rpcDeadlines.reattachExecute)))
}
}
@@ -75,7 +94,7 @@ private[connect] class CustomSparkConnectBlockingStub(
request.getClientType) {
retryHandler.retry {
stubState.responseValidator.verifyResponse {
- stub.analyzePlan(request)
+ withDeadline(stubState.rpcDeadlines.analyzePlan).analyzePlan(request)
}
}
}
@@ -88,7 +107,7 @@ private[connect] class CustomSparkConnectBlockingStub(
request.getClientType) {
retryHandler.retry {
stubState.responseValidator.verifyResponse {
- stub.config(request)
+ withDeadline(stubState.rpcDeadlines.config).config(request)
}
}
}
@@ -101,7 +120,7 @@ private[connect] class CustomSparkConnectBlockingStub(
request.getClientType) {
retryHandler.retry {
stubState.responseValidator.verifyResponse {
- stub.interrupt(request)
+ withDeadline(stubState.rpcDeadlines.interrupt).interrupt(request)
}
}
}
@@ -114,7 +133,7 @@ private[connect] class CustomSparkConnectBlockingStub(
request.getClientType) {
retryHandler.retry {
stubState.responseValidator.verifyResponse {
- stub.releaseSession(request)
+
withDeadline(stubState.rpcDeadlines.releaseSession).releaseSession(request)
}
}
}
@@ -127,7 +146,7 @@ private[connect] class CustomSparkConnectBlockingStub(
request.getClientType) {
retryHandler.retry {
stubState.responseValidator.verifyResponse {
- stub.artifactStatus(request)
+
withDeadline(stubState.rpcDeadlines.artifactStatus).artifactStatus(request)
}
}
}
@@ -140,7 +159,7 @@ private[connect] class CustomSparkConnectBlockingStub(
request.getClientType) {
retryHandler.retry {
stubState.responseValidator.verifyResponse {
- stub.cloneSession(request)
+
withDeadline(stubState.rpcDeadlines.cloneSession).cloneSession(request)
}
}
}
@@ -153,7 +172,7 @@ private[connect] class CustomSparkConnectBlockingStub(
request.getClientType) {
retryHandler.retry {
stubState.responseValidator.verifyResponse {
- stub.getStatus(request)
+ withDeadline(stubState.rpcDeadlines.getStatus).getStatus(request)
}
}
}
diff --git
a/sql/connect/common/src/main/scala/org/apache/spark/sql/connect/client/CustomSparkConnectStub.scala
b/sql/connect/common/src/main/scala/org/apache/spark/sql/connect/client/CustomSparkConnectStub.scala
index 187c2842a0bc..4edd84ed5de7 100644
---
a/sql/connect/common/src/main/scala/org/apache/spark/sql/connect/client/CustomSparkConnectStub.scala
+++
b/sql/connect/common/src/main/scala/org/apache/spark/sql/connect/client/CustomSparkConnectStub.scala
@@ -16,7 +16,9 @@
*/
package org.apache.spark.sql.connect.client
-import io.grpc.ManagedChannel
+import java.util.concurrent.TimeUnit
+
+import io.grpc.{Deadline, ManagedChannel}
import io.grpc.stub.StreamObserver
import org.apache.spark.connect.proto.{AddArtifactsRequest,
AddArtifactsResponse, SparkConnectServiceGrpc}
@@ -29,7 +31,15 @@ private[client] class CustomSparkConnectStub(
def addArtifacts(responseObserver: StreamObserver[AddArtifactsResponse])
: StreamObserver[AddArtifactsRequest] = {
+ // Evaluated on every call (including each RetryStreamObserver retry) so
each attempt gets a
+ // fresh Deadline object with a new absolute expiry, not one shared from
the first attempt.
+ def freshStub: SparkConnectServiceGrpc.SparkConnectServiceStub =
+ stubState.rpcDeadlines.addArtifacts
+ .map(d => stub.withDeadline(Deadline.after(d.toMillis,
TimeUnit.MILLISECONDS)))
+ .getOrElse(stub)
stubState.responseValidator.wrapStreamObserver(
- stubState.retryHandler.RetryStreamObserver(responseObserver,
stub.addArtifacts))
+ stubState.retryHandler.RetryStreamObserver(
+ responseObserver,
+ (obs: StreamObserver[AddArtifactsResponse]) =>
freshStub.addArtifacts(obs)))
}
}
diff --git
a/sql/connect/common/src/main/scala/org/apache/spark/sql/connect/client/ExecutePlanResponseReattachableIterator.scala
b/sql/connect/common/src/main/scala/org/apache/spark/sql/connect/client/ExecutePlanResponseReattachableIterator.scala
index 131a2e77cc43..e6428270c20b 100644
---
a/sql/connect/common/src/main/scala/org/apache/spark/sql/connect/client/ExecutePlanResponseReattachableIterator.scala
+++
b/sql/connect/common/src/main/scala/org/apache/spark/sql/connect/client/ExecutePlanResponseReattachableIterator.scala
@@ -17,11 +17,13 @@
package org.apache.spark.sql.connect.client
import java.util.UUID
+import java.util.concurrent.TimeUnit
+import scala.concurrent.duration.FiniteDuration
import scala.jdk.CollectionConverters._
import scala.util.control.NonFatal
-import io.grpc.{ManagedChannel, StatusRuntimeException}
+import io.grpc.{Deadline, ManagedChannel, Status, StatusRuntimeException}
import io.grpc.protobuf.StatusProto
import io.grpc.stub.StreamObserver
@@ -53,7 +55,9 @@ import org.apache.spark.sql.util.WrappedCloseableIterator
class ExecutePlanResponseReattachableIterator(
request: proto.ExecutePlanRequest,
channel: ManagedChannel,
- retryHandler: GrpcRetryHandler)
+ retryHandler: GrpcRetryHandler,
+ reattachableExecutePlanDeadline: Option[FiniteDuration] = None,
+ reattachExecuteDeadline: Option[FiniteDuration] = None)
extends WrappedCloseableIterator[proto.ExecutePlanResponse]
with Logging {
@@ -80,6 +84,12 @@ class ExecutePlanResponseReattachableIterator(
private val rawBlockingStub =
proto.SparkConnectServiceGrpc.newBlockingStub(channel)
private val rawAsyncStub = proto.SparkConnectServiceGrpc.newStub(channel)
+ private def stubWithDeadline(deadline: Option[FiniteDuration])
+ : proto.SparkConnectServiceGrpc.SparkConnectServiceBlockingStub =
+ deadline
+ .map(d => rawBlockingStub.withDeadline(Deadline.after(d.toMillis,
TimeUnit.MILLISECONDS)))
+ .getOrElse(rawBlockingStub)
+
private val initialRequest: proto.ExecutePlanRequest = request
.toBuilder()
.addRequestOptions(
@@ -104,7 +114,7 @@ class ExecutePlanResponseReattachableIterator(
// throw error on first iter.hasNext() or iter.next()
// Visible for testing.
private[connect] var iter:
Option[java.util.Iterator[proto.ExecutePlanResponse]] =
- Some(rawBlockingStub.executePlan(initialRequest))
+
Some(stubWithDeadline(reattachableExecutePlanDeadline).executePlan(initialRequest))
// Server side session ID, used to detect if the server side session
changed. This is set upon
// receiving the first response from the server.
@@ -231,7 +241,9 @@ class ExecutePlanResponseReattachableIterator(
private def callIter[V](iterFun:
java.util.Iterator[proto.ExecutePlanResponse] => V) = {
try {
if (iter.isEmpty) {
- iter =
Some(rawBlockingStub.reattachExecute(createReattachExecuteRequest()))
+ iter = Some(
+ stubWithDeadline(reattachExecuteDeadline)
+ .reattachExecute(createReattachExecuteRequest()))
}
iterFun(iter.get)
} catch {
@@ -248,7 +260,18 @@ class ExecutePlanResponseReattachableIterator(
ex)
}
// Try a new ExecutePlan, and throw upstream for retry.
- iter = Some(rawBlockingStub.executePlan(initialRequest))
+ iter =
Some(stubWithDeadline(reattachableExecutePlanDeadline).executePlan(initialRequest))
+ val error = new RetryException()
+ error.addSuppressed(ex)
+ throw error
+ case ex: StatusRuntimeException if ex.getStatus.getCode ==
Status.Code.DEADLINE_EXCEEDED =>
+ // The per-RPC deadline fired. The server-side operation is still
alive; we clear the
+ // iterator and raise RetryException so the outer retry loop opens a
fresh
+ // ReattachExecute stream (a new per-RPC deadline countdown) to resume
receiving results.
+ logDebug(
+ s"Deadline exceeded on stream for operation $operationId; will
reattach. " +
+ s"(last response: $lastReturnedResponseId)")
+ iter = None
val error = new RetryException()
error.addSuppressed(ex)
throw error
diff --git
a/sql/connect/common/src/main/scala/org/apache/spark/sql/connect/client/GrpcExceptionConverter.scala
b/sql/connect/common/src/main/scala/org/apache/spark/sql/connect/client/GrpcExceptionConverter.scala
index 6e5304b8cc77..b52261e5efde 100644
---
a/sql/connect/common/src/main/scala/org/apache/spark/sql/connect/client/GrpcExceptionConverter.scala
+++
b/sql/connect/common/src/main/scala/org/apache/spark/sql/connect/client/GrpcExceptionConverter.scala
@@ -17,12 +17,14 @@
package org.apache.spark.sql.connect.client
import java.time.DateTimeException
+import java.util.concurrent.TimeUnit
+import scala.concurrent.duration.FiniteDuration
import scala.jdk.CollectionConverters._
import scala.reflect.ClassTag
import com.google.rpc.ErrorInfo
-import io.grpc.{ManagedChannel, StatusRuntimeException}
+import io.grpc.{Deadline, ManagedChannel, Status, StatusRuntimeException}
import io.grpc.protobuf.StatusProto
import org.json4s.{DefaultFormats, Formats}
import org.json4s.jackson.JsonMethods
@@ -49,10 +51,18 @@ import org.apache.spark.util.ArrayImplicits._
* the ErrorInfo is missing, the exception will be constructed based on the
StatusRuntimeException
* itself.
*/
-private[client] class GrpcExceptionConverter(channel: ManagedChannel) extends
Logging {
+private[client] class GrpcExceptionConverter(
+ channel: ManagedChannel,
+ fetchErrorDetailsDeadline: Option[FiniteDuration] = None)
+ extends Logging {
import GrpcExceptionConverter._
- val grpcStub = SparkConnectServiceGrpc.newBlockingStub(channel)
+ private val grpcStub = SparkConnectServiceGrpc.newBlockingStub(channel)
+
+ private def stubWithDeadline:
SparkConnectServiceGrpc.SparkConnectServiceBlockingStub =
+ fetchErrorDetailsDeadline
+ .map(d => grpcStub.withDeadline(Deadline.after(d.toMillis,
TimeUnit.MILLISECONDS)))
+ .getOrElse(grpcStub)
def convert[T](sessionId: String, userContext: UserContext, clientType:
String)(f: => T): T = {
try {
@@ -108,7 +118,7 @@ private[client] class GrpcExceptionConverter(channel:
ManagedChannel) extends Lo
}
try {
- val errorDetailsResponse = grpcStub.fetchErrorDetails(
+ val errorDetailsResponse = stubWithDeadline.fetchErrorDetails(
FetchErrorDetailsRequest
.newBuilder()
.setSessionId(sessionId)
@@ -160,11 +170,25 @@ private[client] class GrpcExceptionConverter(channel:
ManagedChannel) extends Lo
}
// If no ErrorInfo is found, create a SparkException based on the
StatusRuntimeException.
+ val (message, cause) = if (ex.getStatus.getCode ==
Status.Code.DEADLINE_EXCEEDED) {
+ val msg = s"${ex.toString}: RPC deadline exceeded. Deadlines can be
configured via " +
+ "SparkConnectClient.Builder.rpcDeadlines(). To disable all deadlines:
" +
+
"SparkConnectClient.builder().rpcDeadlines(RpcDeadlines.disabled).build()"
+ // For DEADLINE_EXCEEDED, we pass `ex` itself as the cause rather than
`ex.getCause`.
+ // StatusRuntimeException.getCause() returns status.getCause(), which is
always null for
+ // client-side deadline fires (gRPC constructs the status without a
wrapped cause). Using
+ // ex.getCause would produce a SparkException with cause = null, losing
the gRPC status
+ // code and description from the exception chain. Passing ex preserves
full context and
+ // allows callers to programmatically inspect the status code via
getCause().getStatus().
+ (msg, ex)
+ } else {
+ (ex.toString, ex.getCause)
+ }
new SparkException(
- message = ex.toString,
- cause = ex.getCause,
+ message = message,
+ cause = cause,
errorClass = Some("CONNECT_CLIENT_UNEXPECTED_MISSING_SQL_STATE"),
- messageParameters = Map("message" -> ex.toString),
+ messageParameters = Map("message" -> message),
context = Array.empty)
}
}
diff --git
a/sql/connect/common/src/main/scala/org/apache/spark/sql/connect/client/RetryPolicy.scala
b/sql/connect/common/src/main/scala/org/apache/spark/sql/connect/client/RetryPolicy.scala
index 5b5c4b517923..b396ee96a4e5 100644
---
a/sql/connect/common/src/main/scala/org/apache/spark/sql/connect/client/RetryPolicy.scala
+++
b/sql/connect/common/src/main/scala/org/apache/spark/sql/connect/client/RetryPolicy.scala
@@ -169,6 +169,11 @@ object RetryPolicy extends Logging {
return true
}
+ // DEADLINE_EXCEEDED on the reattachable execute path is handled
directly in
+ // ExecutePlanResponseReattachableIterator, which converts it to
RetryException so the
+ // server-side operation continues and a fresh ReattachExecute is
issued. We do not retry
+ // other RPCs on deadline: those are non-idempotent or the deadline
signals a genuine
+ // timeout that retrying won't fix.
false
case _ => false
}
diff --git
a/sql/connect/common/src/main/scala/org/apache/spark/sql/connect/client/RpcDeadlines.scala
b/sql/connect/common/src/main/scala/org/apache/spark/sql/connect/client/RpcDeadlines.scala
new file mode 100644
index 000000000000..0742a206b44c
--- /dev/null
+++
b/sql/connect/common/src/main/scala/org/apache/spark/sql/connect/client/RpcDeadlines.scala
@@ -0,0 +1,83 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.spark.sql.connect.client
+
+import scala.concurrent.duration.{DurationInt, FiniteDuration}
+
+/**
+ * Per-RPC deadline configuration. Each field controls the deadline for one
gRPC call. Set a field
+ * to None to disable the deadline for that call. Use RpcDeadlines.disabled to
create an instance
+ * with all deadlines disabled.
+ *
+ * Note on reattachableExecutePlan and reattachExecute: these deadlines apply
to each individual
+ * gRPC stream segment, not to the overall query execution lifetime. When a
deadline fires, the
+ * server-side operation continues running; the client simply opens a new
ReattachExecute stream
+ * to resume receiving results. This avoids hanging connections while
preserving execution.
+ *
+ * Non-reattachable ExecutePlan has no deadline because a timeout there would
kill the execution
+ * with no recovery path. ReleaseExecute has no deadline (fire-and-forget
cleanup).
+ */
+private[sql] case class RpcDeadlines(
+ reattachableExecutePlan: Option[FiniteDuration] = Some(10.minutes),
+ reattachExecute: Option[FiniteDuration] = Some(10.minutes),
+ analyzePlan: Option[FiniteDuration] = Some(1.hour),
+ addArtifacts: Option[FiniteDuration] = Some(1.hour),
+ config: Option[FiniteDuration] = Some(10.minutes),
+ interrupt: Option[FiniteDuration] = Some(10.minutes),
+ releaseSession: Option[FiniteDuration] = Some(10.minutes),
+ artifactStatus: Option[FiniteDuration] = Some(10.minutes),
+ cloneSession: Option[FiniteDuration] = Some(10.minutes),
+ getStatus: Option[FiniteDuration] = Some(10.minutes),
+ fetchErrorDetails: Option[FiniteDuration] = Some(10.minutes)) {
+
+ // Validate all fields: each must be a positive duration or None.
+ private val namedFields: Seq[(String, Option[FiniteDuration])] =
+ productElementNames.toSeq.zip(
+ productIterator.map(_.asInstanceOf[Option[FiniteDuration]]).toSeq)
+
+ namedFields.foreach { case (name, opt) =>
+ opt.foreach(d =>
+ require(d.toMillis > 0, s"RpcDeadlines.$name must be a positive
duration, got $d"))
+ }
+
+ override def toString: String = {
+ val configured = namedFields.collect { case (name, Some(d)) => s"$name=$d"
}
+ if (configured.isEmpty) "RpcDeadlines(all disabled)"
+ else s"RpcDeadlines(${configured.mkString(", ")})"
+ }
+}
+
+private[sql] object RpcDeadlines {
+
+ /**
+ * Creates an RpcDeadlines with all deadlines disabled (no per-RPC timeout
on any call). Use
+ * this when you want to rely solely on server-side or network-layer
timeouts.
+ */
+ val disabled: RpcDeadlines = RpcDeadlines(
+ reattachableExecutePlan = None,
+ reattachExecute = None,
+ analyzePlan = None,
+ addArtifacts = None,
+ config = None,
+ interrupt = None,
+ releaseSession = None,
+ artifactStatus = None,
+ cloneSession = None,
+ getStatus = None,
+ fetchErrorDetails = None)
+}
diff --git
a/sql/connect/common/src/main/scala/org/apache/spark/sql/connect/client/SparkConnectClient.scala
b/sql/connect/common/src/main/scala/org/apache/spark/sql/connect/client/SparkConnectClient.scala
index d9b9ba35b5e6..cab3540a59b1 100644
---
a/sql/connect/common/src/main/scala/org/apache/spark/sql/connect/client/SparkConnectClient.scala
+++
b/sql/connect/common/src/main/scala/org/apache/spark/sql/connect/client/SparkConnectClient.scala
@@ -54,7 +54,7 @@ private[sql] class SparkConnectClient(
private val userContext: UserContext = configuration.userContext
- private[this] val stubState = new SparkConnectStubState(channel,
configuration.retryPolicies)
+ private[this] val stubState = new SparkConnectStubState(channel,
configuration)
private[this] val bstub =
new CustomSparkConnectBlockingStub(channel, stubState)
private[this] val stub =
@@ -804,6 +804,11 @@ object SparkConnectClient {
retryPolicy(List(policy))
}
+ def rpcDeadlines(deadlines: RpcDeadlines): Builder = {
+ _configuration = _configuration.copy(rpcDeadlines = deadlines)
+ this
+ }
+
private object URIParams {
val PARAM_USER_ID = "user_id"
val PARAM_USE_SSL = "use_ssl"
@@ -1037,6 +1042,7 @@ object SparkConnectClient {
userAgent: String = genUserAgent(
sys.env.getOrElse("SPARK_CONNECT_USER_AGENT", DEFAULT_USER_AGENT)),
retryPolicies: Seq[RetryPolicy] = RetryPolicy.defaultPolicies(),
+ rpcDeadlines: RpcDeadlines = RpcDeadlines(),
useReattachableExecute: Boolean = true,
interceptors: List[ClientInterceptor] = List.empty,
sessionId: Option[String] = None,
diff --git
a/sql/connect/common/src/main/scala/org/apache/spark/sql/connect/client/SparkConnectStubState.scala
b/sql/connect/common/src/main/scala/org/apache/spark/sql/connect/client/SparkConnectStubState.scala
index 2ec9ecad9030..4d2d15073f22 100644
---
a/sql/connect/common/src/main/scala/org/apache/spark/sql/connect/client/SparkConnectStubState.scala
+++
b/sql/connect/common/src/main/scala/org/apache/spark/sql/connect/client/SparkConnectStubState.scala
@@ -26,14 +26,23 @@ import org.apache.spark.internal.Logging
// that the same stub instance is used for all requests from the same client.
In addition,
// this class provides access to the commonly configured retry policy and
exception conversion
// logic.
-class SparkConnectStubState(channel: ManagedChannel, retryPolicies:
Seq[RetryPolicy])
+class SparkConnectStubState(
+ channel: ManagedChannel,
+ val configuration: SparkConnectClient.Configuration)
extends Logging {
+ val rpcDeadlines: RpcDeadlines = configuration.rpcDeadlines
+
+ if (log.isInfoEnabled) {
+ logInfo(s"Spark Connect RPC deadlines: $rpcDeadlines")
+ }
+
// Manages the retry handler logic used by the stubs.
- lazy val retryHandler = new GrpcRetryHandler(retryPolicies)
+ lazy val retryHandler = new GrpcRetryHandler(configuration.retryPolicies)
// Responsible to convert the GRPC Status exceptions into Spark exceptions.
- lazy val exceptionConverter: GrpcExceptionConverter = new
GrpcExceptionConverter(channel)
+ lazy val exceptionConverter: GrpcExceptionConverter =
+ new GrpcExceptionConverter(channel,
configuration.rpcDeadlines.fetchErrorDetails)
// Provides a helper for validating the responses processed by the stub.
lazy val responseValidator = new ResponseValidator()
diff --git
a/sql/connect/server/src/test/scala/org/apache/spark/sql/connect/SparkConnectServerTest.scala
b/sql/connect/server/src/test/scala/org/apache/spark/sql/connect/SparkConnectServerTest.scala
index 748e7623aba3..d4ecbca28e9d 100644
---
a/sql/connect/server/src/test/scala/org/apache/spark/sql/connect/SparkConnectServerTest.scala
+++
b/sql/connect/server/src/test/scala/org/apache/spark/sql/connect/SparkConnectServerTest.scala
@@ -280,9 +280,9 @@ trait SparkConnectServerTest extends SharedSparkSession {
protected def withCustomBlockingStub(
retryPolicies: Seq[RetryPolicy] = RetryPolicy.defaultPolicies())(
f: CustomSparkConnectBlockingStub => Unit): Unit = {
- val conf = SparkConnectClient.Configuration(port = serverPort)
+ val conf = SparkConnectClient.Configuration(port = serverPort,
retryPolicies = retryPolicies)
val channel = conf.createChannel()
- val stubState = new SparkConnectStubState(channel, retryPolicies)
+ val stubState = new SparkConnectStubState(channel, conf)
val bstub = new CustomSparkConnectBlockingStub(channel, stubState)
try f(bstub)
finally {
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]