diff --git a/docs/configuration.md b/docs/configuration.md index 3eeee90d0778c..4999b08908dec 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -3561,6 +3561,30 @@ They are typically set via the config file and command-line options with `--conf Sets the maximum inbound message size for the gRPC requests. Requests with a larger payload will fail. 3.4.0 + + spark.connect.grpc.keepAlive.enabled + + true + + Whether the server sends gRPC/HTTP2 keepalive PINGs to detect and terminate silently-dead client connections. Can be turned off as an escape hatch, e.g. if it interacts badly with a particular network path, or a server environment is prone to stalls (long GC pauses, etc.) long enough to trip false-positive disconnects. + 4.3.0 + + + spark.connect.grpc.keepAlive.time + + 60s + + Sets the time the server waits for the connection to be idle before sending a gRPC/HTTP2 keepalive PING, to detect and terminate a silently-dead connection (e.g. after a NAT gateway or load balancer drops an idle connection mapping without closing the socket). The server separately tolerates client-sent keepalive PINGs no more often than every 10s regardless of this setting; a client configured with grpc_keepalive_time_ms below that floor will have its connection torn down as "too_many_pings". + 4.3.0 + + + spark.connect.grpc.keepAlive.timeout + + 20s + + Sets how long the server waits for a keepalive PING ack before considering the connection dead. + 4.3.0 + spark.connect.extensions.relation.classes diff --git a/python/pyspark/sql/connect/client/core.py b/python/pyspark/sql/connect/client/core.py index db6067f25e096..1e81e780d167e 100644 --- a/python/pyspark/sql/connect/client/core.py +++ b/python/pyspark/sql/connect/client/core.py @@ -212,9 +212,23 @@ class ChannelBuilder: PARAM_USER_ID = "user_id" PARAM_USER_AGENT = "user_agent" PARAM_SESSION_ID = "session_id" + PARAM_GRPC_KEEPALIVE_ENABLED = "grpc_keepalive_enabled" + PARAM_GRPC_KEEPALIVE_TIME_MS = "grpc_keepalive_time_ms" + PARAM_GRPC_KEEPALIVE_TIMEOUT_MS = "grpc_keepalive_timeout_ms" + PARAM_GRPC_KEEPALIVE_WITHOUT_CALLS = "grpc_keepalive_without_calls" GRPC_MAX_MESSAGE_LENGTH_DEFAULT = 128 * 1024 * 1024 + # Detects a silently-dead connection (e.g. a NAT gateway/load balancer dropping an idle + # connection mapping without sending a TCP RST/FIN) via gRPC/HTTP2 keepalive PINGs, so a + # blocked RPC (such as streaming query awaitTermination()) surfaces as UNAVAILABLE instead + # of hanging forever. Mirrors the JVM client's defaults (SparkConnectClient.scala). See + # SPARK-58094. + GRPC_DEFAULT_KEEPALIVE_ENABLED = True + GRPC_DEFAULT_KEEPALIVE_TIME_MS = 60 * 1000 + GRPC_DEFAULT_KEEPALIVE_TIMEOUT_MS = 20 * 1000 + GRPC_DEFAULT_KEEPALIVE_WITHOUT_CALLS = True + GRPC_DEFAULT_OPTIONS = [ ("grpc.max_send_message_length", GRPC_MAX_MESSAGE_LENGTH_DEFAULT), ("grpc.max_receive_message_length", GRPC_MAX_MESSAGE_LENGTH_DEFAULT), @@ -281,7 +295,7 @@ def host(self) -> str: raise PySparkNotImplementedError def _insecure_channel(self, target: Any, **kwargs: Any) -> grpc.Channel: - channel = grpc.insecure_channel(target, options=self._channel_options, **kwargs) + channel = grpc.insecure_channel(target, options=self._effective_channel_options(), **kwargs) if len(self._interceptors) > 0: logger.debug(f"Applying interceptors ({self._interceptors})") @@ -289,7 +303,9 @@ def _insecure_channel(self, target: Any, **kwargs: Any) -> grpc.Channel: return channel def _secure_channel(self, target: Any, credentials: Any, **kwargs: Any) -> grpc.Channel: - channel = grpc.secure_channel(target, credentials, options=self._channel_options, **kwargs) + channel = grpc.secure_channel( + target, credentials, options=self._effective_channel_options(), **kwargs + ) if len(self._interceptors) > 0: logger.debug(f"Applying interceptors ({self._interceptors})") @@ -311,6 +327,74 @@ def token(self) -> Optional[str]: ChannelBuilder.PARAM_TOKEN, os.environ.get("SPARK_CONNECT_AUTHENTICATE_TOKEN") ) + @property + def keepalive_enabled(self) -> bool: + """ + Whether the client sends gRPC/HTTP2 keepalive PINGs to detect a silently-dead + connection. Enabled by default; can be turned off as an escape hatch, e.g. if it + interacts badly with a particular network path, or a client environment is prone to + stalls long enough to trip false-positive disconnects. + """ + return ( + self.getDefault( + ChannelBuilder.PARAM_GRPC_KEEPALIVE_ENABLED, + str(ChannelBuilder.GRPC_DEFAULT_KEEPALIVE_ENABLED), + ).lower() + == "true" + ) + + @property + def keepalive_time_ms(self) -> int: + """Idle time (in milliseconds) before sending a gRPC/HTTP2 keepalive PING.""" + return int( + self.getDefault( + ChannelBuilder.PARAM_GRPC_KEEPALIVE_TIME_MS, + ChannelBuilder.GRPC_DEFAULT_KEEPALIVE_TIME_MS, + ) + ) + + @property + def keepalive_timeout_ms(self) -> int: + """Time (in milliseconds) to wait for a keepalive PING ack before failing.""" + return int( + self.getDefault( + ChannelBuilder.PARAM_GRPC_KEEPALIVE_TIMEOUT_MS, + ChannelBuilder.GRPC_DEFAULT_KEEPALIVE_TIMEOUT_MS, + ) + ) + + @property + def keepalive_without_calls(self) -> bool: + """Whether to keep sending keepalive PINGs when there are no in-flight RPCs.""" + return ( + self.getDefault( + ChannelBuilder.PARAM_GRPC_KEEPALIVE_WITHOUT_CALLS, + str(ChannelBuilder.GRPC_DEFAULT_KEEPALIVE_WITHOUT_CALLS), + ).lower() + == "true" + ) + + def _effective_channel_options(self) -> List[Tuple[str, Any]]: + """ + Returns ``self._channel_options`` with the keepalive options + (:attr:`keepalive_enabled`/:attr:`keepalive_time_ms`/:attr:`keepalive_timeout_ms`/ + :attr:`keepalive_without_calls`) applied, unless a caller already set one of those + specific keys explicitly via ``channelOptions``/:meth:`setChannelOption`, in which case + the explicit value wins. + """ + options = list(self._channel_options) + if not self.keepalive_enabled: + return options + existing_keys = {k for k, _ in options} + for key, value in ( + ("grpc.keepalive_time_ms", self.keepalive_time_ms), + ("grpc.keepalive_timeout_ms", self.keepalive_timeout_ms), + ("grpc.keepalive_permit_without_calls", 1 if self.keepalive_without_calls else 0), + ): + if key not in existing_keys: + options.append((key, value)) + return options + def metadata(self) -> Iterable[Tuple[str, str]]: """ Builds the GRPC specific metadata list to be injected into the request. All @@ -330,6 +414,10 @@ def metadata(self) -> Iterable[Tuple[str, str]]: ChannelBuilder.PARAM_USER_ID, ChannelBuilder.PARAM_USER_AGENT, ChannelBuilder.PARAM_SESSION_ID, + ChannelBuilder.PARAM_GRPC_KEEPALIVE_ENABLED, + ChannelBuilder.PARAM_GRPC_KEEPALIVE_TIME_MS, + ChannelBuilder.PARAM_GRPC_KEEPALIVE_TIMEOUT_MS, + ChannelBuilder.PARAM_GRPC_KEEPALIVE_WITHOUT_CALLS, ] ] @@ -399,6 +487,10 @@ class DefaultChannelBuilder(ChannelBuilder): >>> cb = DefaultChannelBuilder("sc://localhost/;use_ssl=true;token=aaa") ... cb.secure True + + >>> cb = DefaultChannelBuilder("sc://localhost/;grpc_keepalive_time_ms=30000") + ... cb.keepalive_time_ms + 30000 """ @staticmethod diff --git a/python/pyspark/sql/tests/connect/test_connect_channel.py b/python/pyspark/sql/tests/connect/test_connect_channel.py index 21390d671ddad..66a8f0d8abc87 100644 --- a/python/pyspark/sql/tests/connect/test_connect_channel.py +++ b/python/pyspark/sql/tests/connect/test_connect_channel.py @@ -129,6 +129,79 @@ def test_metadata_with_session_id(self): chan = DefaultChannelBuilder("sc://host/") self.assertIsNone(chan.session_id) + def test_keepalive_defaults(self): + # SPARK-58094 + chan = DefaultChannelBuilder("sc://host") + self.assertTrue(chan.keepalive_enabled) + self.assertEqual(60 * 1000, chan.keepalive_time_ms) + self.assertEqual(20 * 1000, chan.keepalive_timeout_ms) + self.assertTrue(chan.keepalive_without_calls) + + options = dict(chan._effective_channel_options()) + self.assertEqual(60 * 1000, options["grpc.keepalive_time_ms"]) + self.assertEqual(20 * 1000, options["grpc.keepalive_timeout_ms"]) + self.assertEqual(1, options["grpc.keepalive_permit_without_calls"]) + + def test_keepalive_connection_string_overrides(self): + # SPARK-58094 + chan = DefaultChannelBuilder( + "sc://host/;grpc_keepalive_enabled=false;grpc_keepalive_time_ms=12345;" + "grpc_keepalive_timeout_ms=6789;grpc_keepalive_without_calls=false" + ) + self.assertFalse(chan.keepalive_enabled) + self.assertEqual(12345, chan.keepalive_time_ms) + self.assertEqual(6789, chan.keepalive_timeout_ms) + self.assertFalse(chan.keepalive_without_calls) + + # Disabled entirely: none of the keepalive options are applied to the channel. + options = dict(chan._effective_channel_options()) + self.assertNotIn("grpc.keepalive_time_ms", options) + self.assertNotIn("grpc.keepalive_timeout_ms", options) + self.assertNotIn("grpc.keepalive_permit_without_calls", options) + + def test_keepalive_enabled_with_custom_values(self): + # SPARK-58094 + chan = DefaultChannelBuilder( + "sc://host/;grpc_keepalive_enabled=true;grpc_keepalive_time_ms=1000;" + "grpc_keepalive_timeout_ms=500;grpc_keepalive_without_calls=false" + ) + self.assertTrue(chan.keepalive_enabled) + options = dict(chan._effective_channel_options()) + self.assertEqual(1000, options["grpc.keepalive_time_ms"]) + self.assertEqual(500, options["grpc.keepalive_timeout_ms"]) + self.assertEqual(0, options["grpc.keepalive_permit_without_calls"]) + + def test_keepalive_explicit_channel_option_wins(self): + # SPARK-58094: an explicitly-provided channel option for one of the keepalive keys is + # not overwritten/duplicated by the default-derived value. + chan = DefaultChannelBuilder("sc://host", [("grpc.keepalive_time_ms", 999)]) + options = chan._effective_channel_options() + self.assertEqual( + [k for k, _ in options].count("grpc.keepalive_time_ms"), + 1, + "only one occurrence, no duplicate", + ) + self.assertEqual( + next(v for k, v in options if k == "grpc.keepalive_time_ms"), + 999, + "explicit value is not overwritten by the default", + ) + # The other keepalive keys are still applied normally. + self.assertIn("grpc.keepalive_timeout_ms", dict(options)) + + def test_keepalive_params_excluded_from_metadata(self): + # SPARK-58094 + chan = DefaultChannelBuilder( + "sc://host/;grpc_keepalive_enabled=false;grpc_keepalive_time_ms=1000;" + "grpc_keepalive_timeout_ms=500;grpc_keepalive_without_calls=false;param1=abc" + ) + md = dict(chan.metadata()) + self.assertNotIn(ChannelBuilder.PARAM_GRPC_KEEPALIVE_ENABLED, md) + self.assertNotIn(ChannelBuilder.PARAM_GRPC_KEEPALIVE_TIME_MS, md) + self.assertNotIn(ChannelBuilder.PARAM_GRPC_KEEPALIVE_TIMEOUT_MS, md) + self.assertNotIn(ChannelBuilder.PARAM_GRPC_KEEPALIVE_WITHOUT_CALLS, md) + self.assertIn("param1", md) + def test_channel_options(self): # SPARK-47694 chan = DefaultChannelBuilder( diff --git a/sql/connect/client/jvm/src/test/scala/org/apache/spark/sql/connect/client/SparkConnectClientBuilderParseTestSuite.scala b/sql/connect/client/jvm/src/test/scala/org/apache/spark/sql/connect/client/SparkConnectClientBuilderParseTestSuite.scala index ad0f880e8f57a..b9b3754252a33 100644 --- a/sql/connect/client/jvm/src/test/scala/org/apache/spark/sql/connect/client/SparkConnectClientBuilderParseTestSuite.scala +++ b/sql/connect/client/jvm/src/test/scala/org/apache/spark/sql/connect/client/SparkConnectClientBuilderParseTestSuite.scala @@ -18,6 +18,7 @@ package org.apache.spark.sql.connect.client import java.util.UUID +import org.apache.spark.sql.connect.common.config.ConnectCommon import org.apache.spark.sql.connect.test.ConnectFunSuite /** @@ -49,6 +50,15 @@ class SparkConnectClientBuilderParseTestSuite extends ConnectFunSuite { argumentTest("user_name", "alice", _.userName.get) argumentTest("user_agent", "robert", _.userAgent.split(" ")(0)) argumentTest("session_id", UUID.randomUUID().toString, _.sessionId.get) + argumentTest("grpc_keepalive_enabled", "false", _.grpcKeepAliveEnabled.toString) + argumentTest("grpc_keepalive_time_ms", "12345", _.grpcKeepAliveTimeMs.toString) + argumentTest("grpc_keepalive_timeout_ms", "6789", _.grpcKeepAliveTimeoutMs.toString) + argumentTest("grpc_keepalive_without_calls", "false", _.grpcKeepAliveWithoutCalls.toString) + + test("Argument - grpc_keepalive_enabled explicit true") { + val builder = build("--grpc_keepalive_enabled", "true") + assert(builder.grpcKeepAliveEnabled) + } test("Argument - remote") { val builder = @@ -61,6 +71,41 @@ class SparkConnectClientBuilderParseTestSuite extends ConnectFunSuite { assert(builder.sessionId.isEmpty) } + test("Argument - remote with keepalive params (disabled)") { + val builder = build( + "--remote", + "sc://srv.apache.org/;grpc_keepalive_enabled=false;grpc_keepalive_time_ms=15000;" + + "grpc_keepalive_timeout_ms=5000;grpc_keepalive_without_calls=false") + assert(!builder.grpcKeepAliveEnabled) + assert(builder.grpcKeepAliveTimeMs === 15000L) + assert(builder.grpcKeepAliveTimeoutMs === 5000L) + assert(!builder.grpcKeepAliveWithoutCalls) + } + + test("Argument - remote with keepalive params (explicitly enabled)") { + val builder = build( + "--remote", + "sc://srv.apache.org/;grpc_keepalive_enabled=true;grpc_keepalive_time_ms=15000;" + + "grpc_keepalive_timeout_ms=5000;grpc_keepalive_without_calls=true") + assert(builder.grpcKeepAliveEnabled) + assert(builder.grpcKeepAliveTimeMs === 15000L) + assert(builder.grpcKeepAliveTimeoutMs === 5000L) + assert(builder.grpcKeepAliveWithoutCalls) + } + + test("keepalive defaults apply when unset") { + val builder = build("--host", "localhost") + assert( + builder.configuration.grpcKeepAliveEnabled === ConnectCommon.CONNECT_GRPC_KEEPALIVE_ENABLED) + assert( + builder.configuration.grpcKeepAliveTimeMs === + ConnectCommon.CONNECT_GRPC_KEEPALIVE_TIME_SECONDS * 1000) + assert( + builder.configuration.grpcKeepAliveTimeoutMs === + ConnectCommon.CONNECT_GRPC_KEEPALIVE_TIMEOUT_SECONDS * 1000) + assert(builder.configuration.grpcKeepAliveWithoutCalls) + } + test("Argument - use_ssl") { val builder = build("--use_ssl") assert(builder.sslEnabled) 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 d270b0544097e..fd5be9bb555ee 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 @@ -898,6 +898,110 @@ class SparkConnectClientSuite extends ConnectFunSuite { } } + test( + "SPARK-58094: gRPC keepalive surfaces a bounded failure on a silently dropped " + + "connection instead of hanging forever") { + val requestReceived = new CountDownLatch(1) + val serverLatch = new CountDownLatch(1) + val slowService = new DummySparkConnectService { + override def analyzePlan( + request: AnalyzePlanRequest, + responseObserver: StreamObserver[AnalyzePlanResponse]): Unit = { + requestReceived.countDown() + // Blocks far longer than the test's keepalive settings, standing in for a query that + // is still genuinely running server-side while the client's connection goes dark. + serverLatch.await(30, TimeUnit.SECONDS) + super.analyzePlan(request, responseObserver) + } + } + val keepAliveMs = 300L + server = NettyServerBuilder + .forPort(0) + .addService(slowService) + // The server must permit client PINGs at least as frequently as the client's own + // keepAliveTime, or it will tear down the connection as "too_many_pings" even when + // healthy. In production (SparkConnectService.scala) this floor is a fixed constant + // decoupled from any per-connection setting; here it's simply set to match the test + // client's cadence. + .permitKeepAliveTime(keepAliveMs, TimeUnit.MILLISECONDS) + .permitKeepAliveWithoutCalls(true) + .build() + .start() + service = slowService + val relay = new FreezableTcpRelay(server.getPort) + try { + client = SparkConnectClient + .builder() + .connectionString(s"sc://localhost:${relay.port}") + .grpcKeepAliveEnabled(true) + .grpcKeepAliveTimeMs(keepAliveMs) + .grpcKeepAliveTimeoutMs(keepAliveMs) + .retryPolicy(RetryPolicy(maxRetries = Some(0), canRetry = _ => false, name = "NoRetry")) + .build() + + val resultPromise = scala.concurrent.Promise[Unit]() + val callThread = new Thread(() => { + try { + client.analyze(proto.AnalyzePlanRequest.newBuilder().setSessionId("abc123").build()) + resultPromise.success(()) + } catch { + case t: Throwable => resultPromise.failure(t) + } + }) + callThread.setDaemon(true) + callThread.start() + + assert(requestReceived.await(10, TimeUnit.SECONDS), "server never received the request") + // Freeze the relay without closing either socket, simulating a NAT gateway/load balancer + // silently dropping an idle connection mapping (no TCP RST/FIN to either endpoint). + relay.freeze() + + // Bounded by Await's timeout: without the keepalive fix this never completes and the + // test would fail with a TimeoutException instead of surfacing UNAVAILABLE. + // scalastyle:off awaitresult + val ex = intercept[SparkException] { + scala.concurrent.Await.result(resultPromise.future, FiniteDuration(15, TimeUnit.SECONDS)) + } + // scalastyle:on awaitresult + // A keepalive-triggered UNAVAILABLE carries no wrapped cause (same as DEADLINE_EXCEEDED, + // see GrpcExceptionConverter.toThrowable), so the status code/description is only in the + // message. + assert(ex.getMessage.contains("UNAVAILABLE")) + assert(ex.getMessage.contains("Keepalive failed")) + } finally { + serverLatch.countDown() + relay.close() + } + } + + test("SPARK-58094: keepalive does not disrupt a healthy long-lived connection") { + val keepAliveMs = 300L + server = NettyServerBuilder + .forPort(0) + .addService(new DummySparkConnectService) + .permitKeepAliveTime(keepAliveMs, TimeUnit.MILLISECONDS) + .permitKeepAliveWithoutCalls(true) + .build() + .start() + client = SparkConnectClient + .builder() + .connectionString(s"sc://localhost:${server.getPort}") + .grpcKeepAliveEnabled(true) + .grpcKeepAliveTimeMs(keepAliveMs) + .grpcKeepAliveTimeoutMs(keepAliveMs) + .build() + + // Several calls spaced out well beyond the keepalive interval: if permitKeepAliveTime were + // mistuned (e.g. left at the gRPC default of 5 minutes while the client pings every 300ms), + // the server would tear the connection down as "too_many_pings" and these would fail. + for (i <- 1 to 3) { + val response = + client.analyze(proto.AnalyzePlanRequest.newBuilder().setSessionId(s"abc$i").build()) + assert(response.getSessionId === s"abc$i") + Thread.sleep(keepAliveMs * 2) + } + } + test("analyzePlan with short deadline fires, then succeeds after disabling deadlines") { val latch = new CountDownLatch(1) val slowService = new DummySparkConnectService { @@ -1067,6 +1171,77 @@ class SparkConnectClientSuite extends ConnectFunSuite { } } +/** + * A minimal bidirectional TCP relay that can be "frozen" mid-connection without closing either + * side's socket -- simulating a NAT gateway/load balancer/corporate proxy that silently drops an + * idle connection's mapping (no TCP RST/FIN sent to either endpoint). While frozen, no bytes are + * read from or written to either socket, so gRPC/HTTP2 keepalive PING frames also stop flowing, + * exactly as they would over a genuinely dead network path. + */ +private class FreezableTcpRelay(targetPort: Int) { + private val listener = new java.net.ServerSocket(0) + @volatile private var frozen = false + private val sockets = mutable.ListBuffer[java.net.Socket]() + + private val acceptThread = new Thread(() => { + try { + while (true) { + val clientSocket = listener.accept() + val serverSocket = new java.net.Socket("127.0.0.1", targetPort) + sockets.synchronized { + sockets += clientSocket + sockets += serverSocket + } + pump(clientSocket, serverSocket) + pump(serverSocket, clientSocket) + } + } catch { + case _: java.io.IOException => // listener closed, relay shutting down + } + }) + acceptThread.setDaemon(true) + acceptThread.start() + + def port: Int = listener.getLocalPort + + // Once frozen, bytes read from either socket are dropped rather than forwarded: no data + // (including keepalive PING/PONG frames) reaches the other side, but neither socket is + // closed -- the same observable behavior as a middlebox silently black-holing the connection. + def freeze(): Unit = frozen = true + + private def pump(src: java.net.Socket, dst: java.net.Socket): Unit = { + val t = new Thread(() => { + try { + val in = src.getInputStream + val out = dst.getOutputStream + val buf = new Array[Byte](8192) + var n = 0 + while ({ n = in.read(buf); n != -1 }) { + if (!frozen) { + out.write(buf, 0, n) + out.flush() + } + } + } catch { + case _: java.io.IOException => // socket closed, nothing left to pump + } + }) + t.setDaemon(true) + t.start() + } + + def close(): Unit = { + listener.close() + sockets.synchronized(sockets.foreach { s => + try { + s.close() + } catch { + case _: java.io.IOException => + } + }) + } +} + class DummySparkConnectService() extends SparkConnectServiceGrpc.SparkConnectServiceImplBase { private var inputPlan: proto.Plan = _ 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 7bd7d0e4e9790..8f449207b7968 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 @@ -20,7 +20,7 @@ package org.apache.spark.sql.connect.client import java.net.URI import java.nio.charset.StandardCharsets.UTF_8 import java.util.{Base64, Locale, UUID} -import java.util.concurrent.Executor +import java.util.concurrent.{Executor, TimeUnit} import scala.collection.mutable import scala.jdk.CollectionConverters._ @@ -816,6 +816,10 @@ object SparkConnectClient { val PARAM_USER_AGENT = "user_agent" val PARAM_SESSION_ID = "session_id" val PARAM_GRPC_MAX_MESSAGE_SIZE = "grpc_max_message_size" + val PARAM_GRPC_KEEPALIVE_ENABLED = "grpc_keepalive_enabled" + val PARAM_GRPC_KEEPALIVE_TIME_MS = "grpc_keepalive_time_ms" + val PARAM_GRPC_KEEPALIVE_TIMEOUT_MS = "grpc_keepalive_timeout_ms" + val PARAM_GRPC_KEEPALIVE_WITHOUT_CALLS = "grpc_keepalive_without_calls" } private def verifyURI(uri: URI): Unit = { @@ -878,6 +882,48 @@ object SparkConnectClient { def grpcMaxRecursionLimit: Int = _configuration.grpcMaxRecursionLimit + /** + * Whether the client sends gRPC/HTTP2 keepalive PINGs to detect a silently-dead connection + * (see `grpcKeepAliveTimeMs`/`grpcKeepAliveTimeoutMs`). Enabled by default; can be turned off + * as an escape hatch, e.g. if it interacts badly with a particular network path, or a client + * environment is prone to stalls long enough to trip false-positive disconnects. + */ + def grpcKeepAliveEnabled(enabled: Boolean): Builder = { + _configuration = _configuration.copy(grpcKeepAliveEnabled = enabled) + this + } + + def grpcKeepAliveEnabled: Boolean = _configuration.grpcKeepAliveEnabled + + def grpcKeepAliveTimeMs(timeMs: Long): Builder = { + _configuration = _configuration.copy(grpcKeepAliveTimeMs = timeMs) + this + } + + def grpcKeepAliveTimeMs: Long = _configuration.grpcKeepAliveTimeMs + + def grpcKeepAliveTimeoutMs(timeoutMs: Long): Builder = { + _configuration = _configuration.copy(grpcKeepAliveTimeoutMs = timeoutMs) + this + } + + def grpcKeepAliveTimeoutMs: Long = _configuration.grpcKeepAliveTimeoutMs + + /** + * Whether to keep sending keepalive PINGs when there are no in-flight RPCs on the channel. + * Defaults to true, so a long-idle connection (e.g. a parked/unused session) is still + * monitored and not just calls that are actively blocked. Set to false to avoid the resulting + * steady background PING traffic for deployments with very large numbers of mostly-idle + * connections, at the cost of only detecting a dead connection once the next RPC is attempted + * on it. + */ + def grpcKeepAliveWithoutCalls(enabled: Boolean): Builder = { + _configuration = _configuration.copy(grpcKeepAliveWithoutCalls = enabled) + this + } + + def grpcKeepAliveWithoutCalls: Boolean = _configuration.grpcKeepAliveWithoutCalls + def option(key: String, value: String): Builder = { _configuration = _configuration.copy(metadata = _configuration.metadata + ((key, value))) this @@ -905,6 +951,11 @@ object SparkConnectClient { if (java.lang.Boolean.valueOf(value)) enableSsl() else disableSsl() case URIParams.PARAM_SESSION_ID => sessionId(value) case URIParams.PARAM_GRPC_MAX_MESSAGE_SIZE => grpcMaxMessageSize(value.toInt) + case URIParams.PARAM_GRPC_KEEPALIVE_ENABLED => grpcKeepAliveEnabled(value.toBoolean) + case URIParams.PARAM_GRPC_KEEPALIVE_TIME_MS => grpcKeepAliveTimeMs(value.toLong) + case URIParams.PARAM_GRPC_KEEPALIVE_TIMEOUT_MS => grpcKeepAliveTimeoutMs(value.toLong) + case URIParams.PARAM_GRPC_KEEPALIVE_WITHOUT_CALLS => + grpcKeepAliveWithoutCalls(value.toBoolean) case _ => option(key, value) } } @@ -1048,6 +1099,10 @@ object SparkConnectClient { sessionId: Option[String] = None, grpcMaxMessageSize: Int = ConnectCommon.CONNECT_GRPC_MAX_MESSAGE_SIZE, grpcMaxRecursionLimit: Int = ConnectCommon.CONNECT_GRPC_MARSHALLER_RECURSION_LIMIT, + grpcKeepAliveEnabled: Boolean = ConnectCommon.CONNECT_GRPC_KEEPALIVE_ENABLED, + grpcKeepAliveTimeMs: Long = ConnectCommon.CONNECT_GRPC_KEEPALIVE_TIME_SECONDS * 1000, + grpcKeepAliveTimeoutMs: Long = ConnectCommon.CONNECT_GRPC_KEEPALIVE_TIMEOUT_SECONDS * 1000, + grpcKeepAliveWithoutCalls: Boolean = ConnectCommon.CONNECT_GRPC_KEEPALIVE_WITHOUT_CALLS, allowArrowBatchChunking: Boolean = true, preferredArrowChunkSize: Option[Int] = None) { @@ -1098,6 +1153,18 @@ object SparkConnectClient { interceptors.foreach(channelBuilder.intercept(_)) channelBuilder.maxInboundMessageSize(grpcMaxMessageSize) + if (grpcKeepAliveEnabled) { + // Detects a silently-dead connection (e.g. a NAT gateway/load balancer dropping an idle + // connection mapping without sending a TCP RST/FIN) so a blocked RPC (such as streaming + // query awaitTermination()) surfaces as UNAVAILABLE instead of hanging forever. Note: a + // stall on either end longer than keepAliveTime + keepAliveTimeout (e.g. a long JVM GC + // pause) can also cause an otherwise-healthy connection to be dropped -- raise both + // values (or disable via grpcKeepAliveEnabled) in environments prone to long GC pauses + // or other event-loop stalls. + channelBuilder.keepAliveTime(grpcKeepAliveTimeMs, TimeUnit.MILLISECONDS) + channelBuilder.keepAliveTimeout(grpcKeepAliveTimeoutMs, TimeUnit.MILLISECONDS) + channelBuilder.keepAliveWithoutCalls(grpcKeepAliveWithoutCalls) + } channelBuilder.build() } diff --git a/sql/connect/common/src/main/scala/org/apache/spark/sql/connect/client/SparkConnectClientParser.scala b/sql/connect/common/src/main/scala/org/apache/spark/sql/connect/client/SparkConnectClientParser.scala index 7e137a6a3e051..629b6d237e5c4 100644 --- a/sql/connect/common/src/main/scala/org/apache/spark/sql/connect/client/SparkConnectClientParser.scala +++ b/sql/connect/common/src/main/scala/org/apache/spark/sql/connect/client/SparkConnectClientParser.scala @@ -42,6 +42,10 @@ private[sql] object SparkConnectClientParser { | --user_agent USER_AGENT The User-Agent Client information (only intended for logging purposes by the server). | --session_id SESSION_ID Session Id of the user connecting. | --grpc_max_message_size SIZE Maximum message size allowed for gRPC messages in bytes. + | --grpc_keepalive_enabled BOOL Whether to enable gRPC/HTTP2 keepalive PINGs. + | --grpc_keepalive_time_ms MS Idle time before sending a gRPC/HTTP2 keepalive PING. + | --grpc_keepalive_timeout_ms MS Time to wait for a keepalive PING ack before failing. + | --grpc_keepalive_without_calls BOOL Whether to keep sending keepalive PINGs when idle. | --option KEY=VALUE Key-value pair that is used to further configure the session. """.stripMargin // scalastyle:on line.size.limit @@ -92,6 +96,18 @@ private[sql] object SparkConnectClientParser { case "--grpc_max_message_size" :: tail => val (value, remainder) = extract("--grpc_max_message_size", tail) parse(remainder, builder.grpcMaxMessageSize(value.toInt)) + case "--grpc_keepalive_enabled" :: tail => + val (value, remainder) = extract("--grpc_keepalive_enabled", tail) + parse(remainder, builder.grpcKeepAliveEnabled(value.toBoolean)) + case "--grpc_keepalive_time_ms" :: tail => + val (value, remainder) = extract("--grpc_keepalive_time_ms", tail) + parse(remainder, builder.grpcKeepAliveTimeMs(value.toLong)) + case "--grpc_keepalive_timeout_ms" :: tail => + val (value, remainder) = extract("--grpc_keepalive_timeout_ms", tail) + parse(remainder, builder.grpcKeepAliveTimeoutMs(value.toLong)) + case "--grpc_keepalive_without_calls" :: tail => + val (value, remainder) = extract("--grpc_keepalive_without_calls", tail) + parse(remainder, builder.grpcKeepAliveWithoutCalls(value.toBoolean)) case unsupported :: _ => throw new IllegalArgumentException(s"$unsupported is an unsupported argument.") } diff --git a/sql/connect/common/src/main/scala/org/apache/spark/sql/connect/common/config/ConnectCommon.scala b/sql/connect/common/src/main/scala/org/apache/spark/sql/connect/common/config/ConnectCommon.scala index e244fd13595b2..ba2aa09435d42 100644 --- a/sql/connect/common/src/main/scala/org/apache/spark/sql/connect/common/config/ConnectCommon.scala +++ b/sql/connect/common/src/main/scala/org/apache/spark/sql/connect/common/config/ConnectCommon.scala @@ -21,4 +21,13 @@ private[sql] object ConnectCommon { val CONNECT_GRPC_PORT_MAX_RETRIES: Int = 0 val CONNECT_GRPC_MAX_MESSAGE_SIZE: Int = 128 * 1024 * 1024 val CONNECT_GRPC_MARSHALLER_RECURSION_LIMIT: Int = 1024 + // Detects a silently-dead connection (e.g. a NAT gateway/load balancer dropping an idle + // connection mapping without sending a TCP RST/FIN) via gRPC/HTTP2 keepalive PINGs, so a + // blocked RPC surfaces as UNAVAILABLE instead of hanging forever. Guarded by + // CONNECT_GRPC_KEEPALIVE_ENABLED so it can be turned off entirely as an escape hatch (e.g. if + // it interacts badly with a particular network path, or with long GC pauses). + val CONNECT_GRPC_KEEPALIVE_ENABLED: Boolean = true + val CONNECT_GRPC_KEEPALIVE_TIME_SECONDS: Long = 60 + val CONNECT_GRPC_KEEPALIVE_TIMEOUT_SECONDS: Long = 20 + val CONNECT_GRPC_KEEPALIVE_WITHOUT_CALLS: Boolean = true } diff --git a/sql/connect/docs/client-connection-string.md b/sql/connect/docs/client-connection-string.md index f78a91f45f841..7d246434ddd37 100644 --- a/sql/connect/docs/client-connection-string.md +++ b/sql/connect/docs/client-connection-string.md @@ -109,6 +109,42 @@ sc://host:port/;param1=value;param2=value Default:
 128 * 1024 * 1024
grpc_max_message_size=134217728
+ + grpc_keepalive_enabled + Boolean + Whether the client sends gRPC/HTTP2 keepalive PINGs to detect a silently-dead + connection (e.g. a NAT gateway or load balancer dropping an idle connection mapping + without closing the socket), so a blocked call fails with an error instead of hanging + forever. Can be turned off as an escape hatch, e.g. in environments prone to long stalls + (GC pauses, etc.) that could otherwise trip a false-positive disconnect.
+ Default:
true
+
grpc_keepalive_enabled=false
+ + + grpc_keepalive_time_ms + Numeric + Idle time (in milliseconds) before the client sends a keepalive PING. A Spark Connect + server tolerates client PINGs no more often than every 10s; setting this below that floor + will get the connection torn down as "too_many_pings".
+ Default:
60000
+
grpc_keepalive_time_ms=30000
+ + + grpc_keepalive_timeout_ms + Numeric + Time (in milliseconds) the client waits for a keepalive PING ack before considering + the connection dead.
+ Default:
20000
+
grpc_keepalive_timeout_ms=10000
+ + + grpc_keepalive_without_calls + Boolean + Whether to keep sending keepalive PINGs when there are no in-flight RPCs on the + connection.
+ Default:
true
+
grpc_keepalive_without_calls=false
+ ## Examples @@ -132,6 +168,17 @@ server_url = "sc://myhost.com:443/;use_ssl=true" server_url = "sc://myhost.com:443/;use_ssl=true;token=ABCDEFG" ``` +The next example tunes the gRPC keepalive settings, e.g. to detect a dead connection faster +than the 60s/20s default, or to disable it entirely. + +```python +server_url = "sc://myhost.com:443/;grpc_keepalive_time_ms=30000;grpc_keepalive_timeout_ms=10000" +``` + +```python +server_url = "sc://myhost.com:443/;grpc_keepalive_enabled=false" +``` + ### Invalid Examples As mentioned above, Spark Connect uses a regular GRPC client and the server path diff --git a/sql/connect/server/src/main/scala/org/apache/spark/sql/connect/config/Connect.scala b/sql/connect/server/src/main/scala/org/apache/spark/sql/connect/config/Connect.scala index e2d496239d290..5760d97076d86 100644 --- a/sql/connect/server/src/main/scala/org/apache/spark/sql/connect/config/Connect.scala +++ b/sql/connect/server/src/main/scala/org/apache/spark/sql/connect/config/Connect.scala @@ -87,6 +87,45 @@ object Connect { .intConf .createWithDefault(ConnectCommon.CONNECT_GRPC_MARSHALLER_RECURSION_LIMIT) + val CONNECT_GRPC_KEEPALIVE_ENABLED = + buildStaticConf("spark.connect.grpc.keepAlive.enabled") + .doc( + "Whether the server sends gRPC/HTTP2 keepalive PINGs to detect and terminate " + + "silently-dead client connections (see spark.connect.grpc.keepAlive.time / " + + ".timeout). Enabled by default; can be turned off as an escape hatch, e.g. if it " + + "interacts badly with a particular network path, or a server environment is prone " + + "to stalls (long GC pauses, etc.) long enough to trip false-positive disconnects.") + .version("4.3.0") + .booleanConf + .createWithDefault(ConnectCommon.CONNECT_GRPC_KEEPALIVE_ENABLED) + + val CONNECT_GRPC_KEEPALIVE_TIME = + buildStaticConf("spark.connect.grpc.keepAlive.time") + .doc( + "Sets the time the server waits for the connection to be idle before sending a " + + "gRPC/HTTP2 keepalive PING, to detect and terminate a silently-dead connection " + + "(e.g. after a NAT gateway or load balancer drops an idle connection mapping " + + "without closing the socket). Also used as the minimum interval the server permits " + + "for keepalive PINGs sent by clients. Combined with " + + "spark.connect.grpc.keepAlive.timeout, this bounds how long a connection can be idle " + + "before either side may declare it dead; a stall on either end (e.g. a long JVM GC " + + "pause) longer than that combined window can cause an otherwise-healthy connection " + + "to be dropped, so raise both values in environments prone to long GC pauses or " + + "other event-loop stalls.") + .version("4.3.0") + .timeConf(TimeUnit.SECONDS) + .createWithDefault(ConnectCommon.CONNECT_GRPC_KEEPALIVE_TIME_SECONDS) + + val CONNECT_GRPC_KEEPALIVE_TIMEOUT = + buildStaticConf("spark.connect.grpc.keepAlive.timeout") + .doc( + "Sets how long the server waits for a keepalive PING ack before considering the " + + "connection dead. See spark.connect.grpc.keepAlive.time for the combined-window " + + "caveat about long GC pauses or other stalls.") + .version("4.3.0") + .timeConf(TimeUnit.SECONDS) + .createWithDefault(ConnectCommon.CONNECT_GRPC_KEEPALIVE_TIMEOUT_SECONDS) + val CONNECT_SESSION_MANAGER_DEFAULT_SESSION_TIMEOUT = buildStaticConf("spark.connect.session.manager.defaultSessionTimeout") .internal() diff --git a/sql/connect/server/src/main/scala/org/apache/spark/sql/connect/service/SparkConnectService.scala b/sql/connect/server/src/main/scala/org/apache/spark/sql/connect/service/SparkConnectService.scala index f2dd9c1b1cef6..1a15ecdaa2a85 100644 --- a/sql/connect/server/src/main/scala/org/apache/spark/sql/connect/service/SparkConnectService.scala +++ b/sql/connect/server/src/main/scala/org/apache/spark/sql/connect/service/SparkConnectService.scala @@ -41,7 +41,7 @@ import org.apache.spark.scheduler.{LiveListenerBus, SparkListenerEvent} import org.apache.spark.sql.SparkSession import org.apache.spark.sql.classic.ClassicConversions._ import org.apache.spark.sql.connect.IllegalStateErrors -import org.apache.spark.sql.connect.config.Connect.{getAuthenticateToken, CONNECT_GRPC_BINDING_ADDRESS, CONNECT_GRPC_BINDING_PORT, CONNECT_GRPC_MARSHALLER_RECURSION_LIMIT, CONNECT_GRPC_MAX_INBOUND_MESSAGE_SIZE, CONNECT_GRPC_PORT_MAX_RETRIES} +import org.apache.spark.sql.connect.config.Connect.{getAuthenticateToken, CONNECT_GRPC_BINDING_ADDRESS, CONNECT_GRPC_BINDING_PORT, CONNECT_GRPC_KEEPALIVE_ENABLED, CONNECT_GRPC_KEEPALIVE_TIME, CONNECT_GRPC_KEEPALIVE_TIMEOUT, CONNECT_GRPC_MARSHALLER_RECURSION_LIMIT, CONNECT_GRPC_MAX_INBOUND_MESSAGE_SIZE, CONNECT_GRPC_PORT_MAX_RETRIES} import org.apache.spark.sql.connect.execution.ConnectProgressExecutionListener import org.apache.spark.sql.connect.ui.{SparkConnectServerAppStatusStore, SparkConnectServerListener, SparkConnectServerTab} import org.apache.spark.sql.connect.utils.ErrorUtils @@ -314,6 +314,14 @@ class SparkConnectService(debug: Boolean) extends AsyncService with BindableServ */ object SparkConnectService extends Logging { + // Floor for permitKeepAliveTime: the minimum interval the server tolerates between a client's + // keepalive PINGs before striking the connection as "too_many_pings" (gRFC A8). This is + // independent of the server's own keepAlive.time (its probe interval for detecting dead + // clients) and must stay well below any supported client grpc_keepalive_time_ms, not derived + // from it -- otherwise a faster client (or an operator raising keepAlive.time) gets its + // healthy connection torn down. 10s leaves a wide margin below the 60s client/server default. + private[connect] val GRPC_KEEPALIVE_PERMIT_TIME_SECONDS: Long = 10 + private[connect] var server: Server = _ private[connect] var bindingAddress: InetSocketAddress = _ @@ -408,7 +416,19 @@ object SparkConnectService extends Logging { case _ => NettyServerBuilder.forPort(port) } sb.maxInboundMessageSize(SparkEnv.get.conf.get(CONNECT_GRPC_MAX_INBOUND_MESSAGE_SIZE).toInt) - .addService(sparkConnectService) + if (SparkEnv.get.conf.get(CONNECT_GRPC_KEEPALIVE_ENABLED)) { + val keepAliveTimeSeconds = SparkEnv.get.conf.get(CONNECT_GRPC_KEEPALIVE_TIME) + // Detects a silently-dead client connection (e.g. a NAT gateway/load balancer dropping + // an idle connection mapping without sending a TCP RST/FIN) via gRPC/HTTP2 keepalive + // PINGs. + sb.keepAliveTime(keepAliveTimeSeconds, TimeUnit.SECONDS) + sb.keepAliveTimeout( + SparkEnv.get.conf.get(CONNECT_GRPC_KEEPALIVE_TIMEOUT), + TimeUnit.SECONDS) + sb.permitKeepAliveTime(GRPC_KEEPALIVE_PERMIT_TIME_SECONDS, TimeUnit.SECONDS) + sb.permitKeepAliveWithoutCalls(true) + } + sb.addService(sparkConnectService) getAuthenticateToken.foreach { token => sb.intercept(new PreSharedKeyAuthenticationInterceptor(token)) 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 d4ecbca28e9dc..c935370f0643f 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 @@ -54,13 +54,21 @@ trait SparkConnectServerTest extends SharedSparkSession { val allocator = new RootAllocator() + // Additional server-side confs to set (via withSparkEnvConfs) before starting the real + // service. Override in subclasses that need to exercise non-default Connect server behavior + // (e.g. tuning gRPC keepalive) through the actual production startGRPCService() wiring, + // rather than only through hand-built test doubles. + protected def extraServerConfs: Seq[(String, String)] = Seq.empty + override def beforeAll(): Unit = { super.beforeAll() // Other suites using mocks leave a mess in the global executionManager, // shut it down so that it's cleared before starting server. SparkConnectService.executionManager.shutdown() // Start the real service. - withSparkEnvConfs((Connect.CONNECT_GRPC_BINDING_PORT.key, serverPort.toString)) { + withSparkEnvConfs( + (Seq( + (Connect.CONNECT_GRPC_BINDING_PORT.key, serverPort.toString)) ++ extraServerConfs): _*) { SparkConnectService.start(spark.sparkContext) } } diff --git a/sql/connect/server/src/test/scala/org/apache/spark/sql/connect/service/SparkConnectServiceKeepAliveSuite.scala b/sql/connect/server/src/test/scala/org/apache/spark/sql/connect/service/SparkConnectServiceKeepAliveSuite.scala new file mode 100644 index 0000000000000..5d28b32658d1a --- /dev/null +++ b/sql/connect/server/src/test/scala/org/apache/spark/sql/connect/service/SparkConnectServiceKeepAliveSuite.scala @@ -0,0 +1,323 @@ +/* + * 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.service + +import java.util.UUID +import java.util.concurrent.TimeUnit + +import scala.collection.mutable +import scala.concurrent.duration.FiniteDuration + +import org.scalatest.concurrent.Eventually + +import org.apache.spark.SparkException +import org.apache.spark.sql.connect.SparkConnectServerTest +import org.apache.spark.sql.connect.client.{RetryPolicy, SparkConnectClient} +import org.apache.spark.sql.connect.config.Connect + +/** + * End-to-end test that the *real* `SparkConnectService.startGRPCService()` wiring actually + * applies the configured gRPC keepalive settings (SPARK-58094). + * + * Unlike the tests in `SparkConnectClientSuite` (sql/connect/client/jvm), which hand-build their + * own `NettyServerBuilder` that only mirrors the production server construction, this suite + * starts the real `SparkConnectService` (via `SparkConnectServerTest`) with the keepalive confs + * tuned to short test values, puts a freezable TCP relay in front of it, and confirms a blocked + * client call fails with a keepalive-triggered `UNAVAILABLE` within a bounded time -- so a future + * refactor of `startGRPCService()` that silently drops the keepalive wiring would be caught here, + * not just in the client-side unit tests. + */ +class SparkConnectServiceKeepAliveSuite extends SparkConnectServerTest { + + // CONNECT_GRPC_KEEPALIVE_TIME/TIMEOUT are read via timeConf(TimeUnit.SECONDS), so anything + // sub-second (e.g. "300ms") truncates to 0 whole seconds and gRPC rejects it -- use the + // smallest whole-second value instead. + private val keepAliveMs = 1000L + + override protected def extraServerConfs: Seq[(String, String)] = Seq( + Connect.CONNECT_GRPC_KEEPALIVE_ENABLED.key -> "true", + Connect.CONNECT_GRPC_KEEPALIVE_TIME.key -> "1s", + Connect.CONNECT_GRPC_KEEPALIVE_TIMEOUT.key -> "1s") + + test("SPARK-58094: real SparkConnectService applies configured keepalive end-to-end") { + val serverSession = + SparkConnectService + .getOrCreateIsolatedSession(defaultUserId, defaultSessionId, None) + .session + serverSession.udf.register( + "sleep", + (ms: Int) => { + Thread.sleep(ms.toLong) + ms + }) + + val relay = new FreezableTcpRelay(serverPort) + try { + val client = SparkConnectClient + .builder() + .port(relay.port) + .sessionId(defaultSessionId) + .userId(defaultUserId) + .enableReattachableExecute() + .grpcKeepAliveEnabled(true) + .grpcKeepAliveTimeMs(keepAliveMs) + .grpcKeepAliveTimeoutMs(keepAliveMs) + .retryPolicy(RetryPolicy(maxRetries = Some(0), canRetry = _ => false, name = "NoRetry")) + .build() + try { + val operationId = UUID.randomUUID().toString + val iter = client.execute(buildPlan("select sleep(30000) as s"), Some(operationId)) + + // Wait for the query to genuinely start server-side before freezing, mirroring the + // manual repro's timing lesson: freezing too early (before the connection is actually + // established/in-flight) doesn't exercise the "dies while blocked" scenario. + Eventually.eventually(timeout(eventuallyTimeout)) { + assert(SparkConnectService.executionManager.listExecuteHolders.length == 1) + } + relay.freeze() + + // scalastyle:off awaitresult + val ex = intercept[SparkException] { + scala.concurrent.Await.result( + scala.concurrent.Future { + while (iter.hasNext) iter.next() + }(scala.concurrent.ExecutionContext.global), + FiniteDuration(15, TimeUnit.SECONDS)) + } + // scalastyle:on awaitresult + // A keepalive-triggered UNAVAILABLE carries no wrapped cause (same as + // DEADLINE_EXCEEDED, see GrpcExceptionConverter.toThrowable), so the status + // code/description is only in the message. + assert(ex.getMessage.contains("UNAVAILABLE")) + assert(ex.getMessage.contains("Keepalive failed")) + } finally { + client.shutdown() + } + } finally { + relay.close() + } + } + + test( + "SPARK-58094: keepalive does not disrupt a healthy long-lived connection through the " + + "real server") { + // Exercises the scenario flagged in review: a client whose ping cadence (11s) is faster + // than the server's own keepAlive.time (20s), through the real startGRPCService() wiring. + // With permitKeepAliveTime derived from the server's own keepAlive.time (the pre-review + // behavior), this client's cadence would violate that coupled invariant; with today's fixed, + // decoupled GRPC_KEEPALIVE_PERMIT_TIME_SECONDS floor (10s), it's comfortably tolerated. + val clientKeepAliveMs = (SparkConnectService.GRPC_KEEPALIVE_PERMIT_TIME_SECONDS + 1) * 1000 + SparkConnectService.stop() + withSparkEnvConfs( + Connect.CONNECT_GRPC_BINDING_PORT.key -> serverPort.toString, + Connect.CONNECT_GRPC_KEEPALIVE_ENABLED.key -> "true", + Connect.CONNECT_GRPC_KEEPALIVE_TIME.key -> "20s", + Connect.CONNECT_GRPC_KEEPALIVE_TIMEOUT.key -> "5s") { + SparkConnectService.start(spark.sparkContext) + } + try { + val client = SparkConnectClient + .builder() + .port(serverPort) + .sessionId(defaultSessionId) + .userId(defaultUserId) + .grpcKeepAliveEnabled(true) + .grpcKeepAliveTimeMs(clientKeepAliveMs) + .grpcKeepAliveTimeoutMs(clientKeepAliveMs) + .retryPolicy(RetryPolicy(maxRetries = Some(0), canRetry = _ => false, name = "NoRetry")) + .build() + try { + // Several real calls spaced out beyond the client's keepalive interval, so the client's + // own periodic PINGs land against the real server wiring: if permitKeepAliveTime were + // ever re-derived from the server's own keepAlive.time (reintroducing the coupling + // flagged in review) or its wiring silently dropped, this is the exact shape of client + // that would be affected. + for (i <- 1 to 2) { + val operationId = UUID.randomUUID().toString + val iter = client.execute(buildPlan("select 1 as s"), Some(operationId)) + while (iter.hasNext) iter.next() + Thread.sleep(clientKeepAliveMs + 2000) + } + } finally { + client.shutdown() + } + } finally { + SparkConnectService.stop() + withSparkEnvConfs( + Connect.CONNECT_GRPC_BINDING_PORT.key -> serverPort.toString, + Connect.CONNECT_GRPC_KEEPALIVE_ENABLED.key -> "true", + Connect.CONNECT_GRPC_KEEPALIVE_TIME.key -> "1s", + Connect.CONNECT_GRPC_KEEPALIVE_TIMEOUT.key -> "1s") { + SparkConnectService.start(spark.sparkContext) + } + } + } + + test( + "SPARK-58094: disabling spark.connect.grpc.keepAlive.enabled reverts to the pre-fix hang") { + // Restart the real service with keepalive fully disabled (not just given short/aggressive + // timing) to prove the flag genuinely gates the fix rather than only tuning its timing. + SparkConnectService.stop() + withSparkEnvConfs( + Connect.CONNECT_GRPC_BINDING_PORT.key -> serverPort.toString, + Connect.CONNECT_GRPC_KEEPALIVE_ENABLED.key -> "false", + Connect.CONNECT_GRPC_KEEPALIVE_TIME.key -> "1s", + Connect.CONNECT_GRPC_KEEPALIVE_TIMEOUT.key -> "1s") { + SparkConnectService.start(spark.sparkContext) + } + try { + val serverSession = + SparkConnectService + .getOrCreateIsolatedSession(defaultUserId, defaultSessionId, None) + .session + serverSession.udf.register( + "sleep", + (ms: Int) => { + Thread.sleep(ms.toLong) + ms + }) + + val relay = new FreezableTcpRelay(serverPort) + try { + val client = SparkConnectClient + .builder() + .port(relay.port) + .sessionId(defaultSessionId) + .userId(defaultUserId) + .enableReattachableExecute() + .grpcKeepAliveEnabled(false) + .grpcKeepAliveTimeMs(keepAliveMs) + .grpcKeepAliveTimeoutMs(keepAliveMs) + .retryPolicy(RetryPolicy(maxRetries = Some(0), canRetry = _ => false, name = "NoRetry")) + .build() + try { + val operationId = UUID.randomUUID().toString + val iter = client.execute(buildPlan("select sleep(30000) as s"), Some(operationId)) + + Eventually.eventually(timeout(eventuallyTimeout)) { + assert(SparkConnectService.executionManager.listExecuteHolders.length == 1) + } + relay.freeze() + + // With keepalive disabled on both sides, the call must NOT resolve within a window + // well past the 1s/1s that would have triggered detection if it were enabled -- + // confirming the flag genuinely disables the fix, matching the original bug's + // "blocks forever, no exception" symptom, rather than the call just being slow. + // scalastyle:off awaitresult + val stillBlocked = + try { + scala.concurrent.Await.result( + scala.concurrent.Future { + while (iter.hasNext) iter.next() + }(scala.concurrent.ExecutionContext.global), + FiniteDuration(5, TimeUnit.SECONDS)) + false + } catch { + case _: java.util.concurrent.TimeoutException => true + } + // scalastyle:on awaitresult + assert(stillBlocked, "expected the call to remain hung with keepalive disabled") + } finally { + client.shutdown() + } + } finally { + relay.close() + } + } finally { + // Restore the enabled server for afterAll()/subsequent tests in this suite. + SparkConnectService.stop() + withSparkEnvConfs( + Connect.CONNECT_GRPC_BINDING_PORT.key -> serverPort.toString, + Connect.CONNECT_GRPC_KEEPALIVE_ENABLED.key -> "true", + Connect.CONNECT_GRPC_KEEPALIVE_TIME.key -> "1s", + Connect.CONNECT_GRPC_KEEPALIVE_TIMEOUT.key -> "1s") { + SparkConnectService.start(spark.sparkContext) + } + } + } +} + +/** + * A minimal bidirectional TCP relay that can be "frozen" mid-connection without closing either + * side's socket -- simulating a NAT gateway/load balancer/corporate proxy that silently drops an + * idle connection's mapping (no TCP RST/FIN sent to either endpoint). Mirrors the equivalent + * helper in `SparkConnectClientSuite` (sql/connect/client/jvm); duplicated here rather than + * shared across modules since it's a small, self-contained test utility. + */ +private class FreezableTcpRelay(targetPort: Int) { + private val listener = new java.net.ServerSocket(0) + @volatile private var frozen = false + private val sockets = mutable.ListBuffer[java.net.Socket]() + + private val acceptThread = new Thread(() => { + try { + while (true) { + val clientSocket = listener.accept() + val serverSocket = new java.net.Socket("127.0.0.1", targetPort) + sockets.synchronized { + sockets += clientSocket + sockets += serverSocket + } + pump(clientSocket, serverSocket) + pump(serverSocket, clientSocket) + } + } catch { + case _: java.io.IOException => // listener closed, relay shutting down + } + }) + acceptThread.setDaemon(true) + acceptThread.start() + + def port: Int = listener.getLocalPort + + // Once frozen, bytes read from either socket are dropped rather than forwarded: no data + // (including keepalive PING/PONG frames) reaches the other side, but neither socket is + // closed -- the same observable behavior as a middlebox silently black-holing the connection. + def freeze(): Unit = frozen = true + + private def pump(src: java.net.Socket, dst: java.net.Socket): Unit = { + val t = new Thread(() => { + try { + val in = src.getInputStream + val out = dst.getOutputStream + val buf = new Array[Byte](8192) + var n = 0 + while ({ n = in.read(buf); n != -1 }) { + if (!frozen) { + out.write(buf, 0, n) + out.flush() + } + } + } catch { + case _: java.io.IOException => // socket closed, nothing left to pump + } + }) + t.setDaemon(true) + t.start() + } + + def close(): Unit = { + listener.close() + sockets.synchronized(sockets.foreach { s => + try { + s.close() + } catch { + case _: java.io.IOException => + } + }) + } +}