vinodkc opened a new pull request, #57202:
URL: https://github.com/apache/spark/pull/57202

   <!--
   Thanks for sending a pull request!  Here are some tips for you:
     1. If this is your first time, please read our contributor guidelines: 
https://spark.apache.org/contributing.html
     2. Ensure you have added or run the appropriate tests for your PR: 
https://spark.apache.org/developer-tools.html
     3. If the PR is unfinished, add '[WIP]' in your PR title, e.g., 
'[WIP][SPARK-XXXX] Your PR title ...'.
     4. Be sure to keep the PR description updated to reflect all changes.
     5. Please write your PR title to summarize what this PR proposes.
     6. If possible, provide a concise example to reproduce the issue for a 
faster review.
     7. If you want to add a new configuration, please read the guideline first 
for naming configurations in
        
'core/src/main/scala/org/apache/spark/internal/config/ConfigEntry.scala'.
     8. If you want to add or modify an error type or message, please read the 
guideline first in
        'common/utils/src/main/resources/error/README.md'.
   -->
   
   ### What changes were proposed in this pull request?
   <!--
   Please clarify what changes you are proposing. The purpose of this section 
is to outline the changes and how this PR fixes the issue. 
   If possible, please consider writing useful notes for better and faster 
reviews in your PR. See the examples below.
     1. If you refactor some codes with changing classes, showing the class 
hierarchy will help reviewers.
     2. If you fix some SQL features, you can provide some references of other 
DBMSes.
     3. If there is design documentation, please add the link.
     4. If there is a discussion in the mailing list, please add the link.
   -->
   This PR configures gRPC/HTTP2 keepalive by default on both sides of Spark 
Connect, for both the JVM and Python (PySpark) clients:
   
   - **Client (JVM)** — `SparkConnectClient.scala`'s 
`Configuration.createChannel()`: sets 
keepAliveTime/keepAliveTimeout/keepAliveWithoutCalls on the gRPC channel 
builder. Configurable via new connection-string params grpc_keepalive_enabled / 
grpc_keepalive_time_ms / grpc_keepalive_timeout_ms / 
grpc_keepalive_without_calls (SparkConnectClientParser.scala) and matching 
Builder.grpcKeepAliveEnabled / grpcKeepAliveTimeMs / grpcKeepAliveTimeoutMs / 
grpcKeepAliveWithoutCalls methods. Defaults: enabled / 60s / 20s / enabled 
(ConnectCommon.CONNECT_GRPC_KEEPALIVE_ENABLED/_TIME_SECONDS/_TIMEOUT_SECONDS).
   - **Client (Python)** — `core.py'`s ChannelBuilder: the same four options 
(grpc_keepalive_enabled/_time_ms/_timeout_ms/_without_calls), applied via a new 
_effective_channel_options() that both _insecure_channel/_secure_channel funnel 
through (so any custom ChannelBuilder subclass gets the fix too, not just 
DefaultChannelBuilder), same defaults as the JVM client. An explicitly-set 
channelOptions/setChannelOption() value for one of these keys is never 
overwritten.
   - **Server** — `SparkConnectService.scala'`s startGRPCService(): sets 
keepAliveTime/keepAliveTimeout/permitKeepAliveTime/permitKeepAliveWithoutCalls(true)
 on the NettyServerBuilder, applied only when enabled. New static confs 
spark.connect.grpc.keepAlive.enabled/.time/.timeout (Connect.scala), same 
enabled/60s/20s defaults. permitKeepAliveTime is set equal to the client's 
default keepAliveTime (60s) — otherwise the server's gRPC-library default 
5-minute permitKeepAliveTime would reject the client's more frequent pings as 
"too_many_pings" and tear down every long-lived healthy connection, not just 
dead ones.
   - Both sides can be fully disabled independently 
(`spark.connect.grpc.keepAlive.enabled=false` server-side, 
`grpc_keepalive_enabled=false`/`Builder.grpcKeepAliveEnabled(false)` 
client-side), as an escape hatch for environments where this default-on 
behavior change is undesirable (e.g. a server/client environment prone to long 
GC pauses or other stalls that could otherwise trip a false-positive 
disconnect).
   - Documentation updated: docs/configuration.md (new server confs) and 
sql/connect/docs/client-connection-string.md (new client connection-string 
params).
   
   This does not change `awaitTermination()`'s (or any other blocking call's) 
semantics — blocking indefinitely while a request is genuinely still in flight 
on a healthy connection is correct. The gap was purely at the transport layer: 
once keepalive detects a connection that has gone silently dark, the channel 
fails with `UNAVAILABLE`, and the existing bounded retry policy 
(RetryPolicy.scala, maxRetries=15, capped exponential backoff) takes over and 
surfaces a real exception, instead of the RPC waiting forever for a response 
that will never arrive.
   
   ### Why are the changes needed?
   <!--
   Please clarify why the changes are needed. For instance,
     1. If you propose a new API, clarify the use case for a new API.
     2. If you fix a bug, you can clarify why it is a bug.
   -->
   `StreamingQuery.awaitTermination()` (and, more generally, any blocking Spark 
Connect RPC) can hang indefinitely and never throw if the network path between 
client and server goes silently dark while the call is blocked — e.g. a NAT 
gateway, load balancer, or corporate proxy silently evicting an idle connection 
mapping without sending a TCP RST/FIN. This is a well-known operational gotcha 
for gRPC deployments behind such middleboxes, and Spark Connect configured no 
keepalive anywhere (client or server) to guard against it.
   
   
   Root cause: with no keepalive configured, neither endpoint has any way to 
detect that the underlying TCP connection has gone silently dark — both sides 
simply continue believing the RPC is still legitimately in flight, forever. The 
streaming query can (and did, in the reported incident) terminate independently 
on the server in the meantime, but there is no live channel left to deliver 
that information back to the client.
   
   Reproduced end-to-end with two genuinely separate JVM processes (a real 
Spark Connect server and a standalone client app) and a small TCP proxy in 
between, frozen mid-RPC with SIGSTOP to simulate a silently-dropped connection 
without either endpoint's socket seeing a close/reset — see "How was this patch 
tested?" below. Pre-fix, the client's main thread parks forever in 
`ArrayBlockingQueue.take()` (via ExecutePlanResponseReattachableIterator); 
post-fix, keepalive detects the dead connection and the client eventually 
surfaces a real, bounded exception.
   
   ### Does this PR introduce _any_ user-facing change?
   <!--
   Note that it means *any* user-facing change including all aspects such as 
new features, bug fixes, or other behavior changes. Documentation-only updates 
are not considered user-facing changes.
   
   If yes, please clarify the previous behavior and the change this PR proposes 
- provide the console output, description and/or an example to show the 
behavior difference if possible.
   If possible, please also clarify if this is a user-facing change compared to 
the released Spark versions or within the unreleased branches such as master.
   If no, write 'No'.
   -->
   Yes.
   
   - New configuration, all optional and defaulted to preserve the fixed 
behavior:
     - **Server** (SQLConf): spark.connect.grpc.keepAlive.enabled (default 
true), spark.connect.grpc.keepAlive.time (default 60s), 
spark.connect.grpc.keepAlive.timeout (default 20s).
     - **Client** (connection-string params, JVM Builder methods, and Python 
ChannelBuilder connection-string params): grpc_keepalive_enabled (default 
true), grpc_keepalive_time_ms (default 60000), grpc_keepalive_timeout_ms 
(default 20000), grpc_keepalive_without_calls (default true).
   - Behavior change (default-on): both the Spark Connect client and server now 
send gRPC/HTTP2 keepalive PINGs periodically. Previously, a Spark Connect 
connection that went silently dark mid-RPC would leave the client blocked 
forever with no exception; now, such a connection is detected within roughly 
keepAliveTime + keepAliveTimeout (60s + 20s by default) and the in-flight call 
fails with UNAVAILABLE: Keepalive failed. The connection is likely gone, after 
which Spark Connect's existing (unmodified) retry policy takes over.
   - This is a behavior change from all previously-released Spark versions 
(keepalive was never configured before); it can be fully reverted via the 
.enabled/grpc_keepalive_enabled flags above for environments where it is 
undesirable.
   
   
   ### How was this patch tested?
   <!--
   If tests were added, say they were added here. Please make sure to add some 
test cases that check the changes thoroughly including negative and positive 
cases if possible.
   If it was tested in a way different from regular unit tests, please clarify 
how you tested step by step, ideally copy and paste-able, so that other 
reviewers can test and check, and descendants can verify in the future.
   If tests were not added, please describe why they were not added and/or why 
it was difficult to add.
   If benchmark tests were added, please run the benchmarks in GitHub Actions 
for the consistent environment, and the instructions could accord to: 
https://spark.apache.org/developer-tools.html#github-workflow-benchmarks.
   -->
   Unit and end-to-end tests :
   
   - `SparkConnectClientBuilderParseTestSuite` (JVM): connection-string/CLI 
parsing of the new keepalive params (including an explicit 
grpc_keepalive_enabled=true case, not just the default and the disabled case), 
and that defaults apply when unset — 20/20.
   - `SparkConnectClientSuite` (JVM), two new tests: a FreezableTcpRelay test 
helper (an in-process byte-forwarding relay that can be frozen without closing 
either socket) sits between a real test client and server; once a blocking RPC 
is in flight, the relay is frozen and the test asserts the client's call fails 
within a bounded time with UNAVAILABLE: Keepalive failed. The connection is 
likely gone instead of hanging past the test's own timeout; a second test 
confirms keepalive does not disrupt a healthy long-lived connection (guards 
against a permitKeepAliveTime mistuning regression) .
   - `SparkConnectServiceKeepAliveSuite` (new, server side): starts the real 
SparkConnectService (not a hand-built test double) with short keepalive confs, 
puts a FreezableTcpRelay in front of it, and confirms both that (a) a blocked 
call fails with the keepalive UNAVAILABLE when keepalive is enabled, and (b) 
disabling spark.connect.grpc.keepAlive.enabled reverts to the pre-fix hang (the 
call stays genuinely blocked well past the window that would have triggered 
detection) .
   - Python `test_connect_channel.py`: defaults, connection-string overrides 
   
   
   Manual end-to-end reproduction, using two genuinely separate JVM/OS 
processes (not an in-process test) plus a small Python TCP proxy in between, to 
faithfully reproduce the client/server RPC boundary and a silently-dropped 
connection:
   
   1. A real Spark Connect server 
   2. A standalone Scala client app running a rate-source streaming query that 
fails via assert_true at a chosen row value (simulating a non-retryable source 
error), with a StreamingQueryListener and a watchdog thread, calling 
query.awaitTermination().
   3. The TCP proxy frozen with SIGSTOP shortly after awaitTermination() was 
called (no TCP RST/FIN sent to either endpoint — the same observable behavior 
as a NAT gateway/load balancer silently evicting an idle connection mapping).
   
   Confirmed:
   
   - Pre-fix: the server terminates the query independently a few seconds later 
(confirming the hang is not an exception-propagation bug), but the client's 
awaitTermination() never returns; a thread dump shows the main thread 
permanently parked in ArrayBlockingQueue.take() via 
ExecutePlanResponseReattachableIterator/GrpcRetryHandler$Retrying.retry, with 
no path out.
   - Post-fix: the same scenario instead surfaces a bounded exception. With 
fast (1s/1s) keepalive settings, a thread dump within ~1-2 minutes shows the 
client has alreadymoved off ArrayBlockingQueue.take() into 
`GrpcRetryHandler$Retrying.waitAfterAttempt` (keepalive detected the dead 
connection); the call eventually throws UNAVAILABLE:Keepalive failed. The 
connection is likely gone (observed consistently at ~630-770s end-to-end, 
governed by the existing, unmodified retry policy's own backoff budget, not by 
this fix). This was verified identically whether 
spark.connect.grpc.keepAlive.enabled/grpc_keepalive_enabled was left at its 
default (true) or set explicitly totrue, and the escape-hatch (enabled=false on 
both sides) was separately confirmed to genuinely revert to the pre-fix hang 
signature rather than just changing its timing.
   - The same manual reproduction was repeated against the Python client 
(grpc.insecure_channel/grpc.secure_channel via ChannelBuilder), confirming both 
the original gap(Python builds its own gRPC channel and initially had none of 
this fix) and the fix once applied: awaitTermination() threw 
SparkConnectGrpcException / UNAVAILABLE:Stream removed (ping timeout) — 
grpcio's equivalent wording for the same keepalive-triggered failure.
   
   A minimal, runnable, standalone reproduction (client + server + proxy 
scripts, independent of this Spark checkout's test suite) is attached to 
SPARK-58094 for reviewers who want to reproduce the issue.
   
   ### Was this patch authored or co-authored using generative AI tooling?
   <!--
   If generative AI tooling has been used in the process of authoring this 
patch, please include the
   phrase: 'Generated-by: ' followed by the name of the tool and its version.
   If no, write 'No'.
   Please refer to the [ASF Generative Tooling 
Guidance](https://www.apache.org/legal/generative-tooling.html) for details.
   -->
   Yes. Generated-by: Claude Code (Sonnet 5).


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