You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@spark.apache.org by ka...@apache.org on 2026/08/26 07:50:38 UTC
(spark) branch branch-4.3 updated: [SPARK-58932][SS] Close the accepted socket in TransformWithStateInPySparkStateServer
This is an automated email from the ASF dual-hosted git repository.
HeartSaVioR pushed a commit to branch branch-4.3
in repository https://gitbox.apache.org/repos/asf/spark.git
The following commit(s) were added to refs/heads/branch-4.3 by this push:
new 818c2c861432 [SPARK-58932][SS] Close the accepted socket in TransformWithStateInPySparkStateServer
818c2c861432 is described below
commit 818c2c8614321a670aed23d6374b7bfa6052e280
Author: Jiwon Park <jp...@outlook.kr>
AuthorDate: Wed Aug 26 16:49:54 2026 +0900
[SPARK-58932][SS] Close the accepted socket in TransformWithStateInPySparkStateServer
### What changes were proposed in this pull request?
Run the request loop of `TransformWithStateInPySparkStateServer.run()` inside `Utils.tryWithResource` so the socket returned by `stateServerSocket.accept()` is always closed.
### Why are the changes needed?
`run()` accepts a connection from the Python worker but never closes it. The task completion listener closes only the listening `ServerSocketChannel`, so the accepted connection's file descriptor is held until the executor exits.
`initStateServer()` binds an ephemeral port per task, so a long-running streaming query leaks one socket per task. Once the ephemeral port range is exhausted, every new connection on that executor fails with `java.net.BindException: Cannot assign requested address`, which also takes down unrelated connections such as the Kafka source.
The loop has several early returns, so the close has to be tied to the scope.
### Does this PR introduce _any_ user-facing change?
No.
### How was this patch tested?
Two tests added to `TransformWithStateInPySparkStateServerSuite`, covering the path where the request loop is never entered and the EOF early-return path. Both fail without the fix. The suite previously had no test calling `run()`.
### Was this patch authored or co-authored using generative AI tooling?
Yes. Generated-by: Claude Opus 5
Closes #58205 from j1wonpark/tws-state-server-socket-leak.
Authored-by: Jiwon Park <jp...@outlook.kr>
Signed-off-by: Jungtaek Lim <ka...@gmail.com>
(cherry picked from commit c5fbdf74b6886bd2c21a5cd4bc5c869e490abe63)
Signed-off-by: Jungtaek Lim <ka...@gmail.com>
---
.../TransformWithStateInPySparkStateServer.scala | 11 ++++++-
...ansformWithStateInPySparkStateServerSuite.scala | 34 ++++++++++++++++++++--
2 files changed, 42 insertions(+), 3 deletions(-)
diff --git a/sql/core/src/main/scala/org/apache/spark/sql/execution/python/streaming/TransformWithStateInPySparkStateServer.scala b/sql/core/src/main/scala/org/apache/spark/sql/execution/python/streaming/TransformWithStateInPySparkStateServer.scala
index 61df2ff1116a..c2cea67061bc 100644
--- a/sql/core/src/main/scala/org/apache/spark/sql/execution/python/streaming/TransformWithStateInPySparkStateServer.scala
+++ b/sql/core/src/main/scala/org/apache/spark/sql/execution/python/streaming/TransformWithStateInPySparkStateServer.scala
@@ -22,7 +22,8 @@ import java.nio.channels.{
Channels,
ClosedByInterruptException,
ClosedChannelException,
- ServerSocketChannel
+ ServerSocketChannel,
+ SocketChannel
}
import java.time.Duration
@@ -45,6 +46,7 @@ import org.apache.spark.sql.execution.streaming.state.StateMessage.KeyAndValuePa
import org.apache.spark.sql.execution.streaming.state.StateMessage.StateResponseWithListGet
import org.apache.spark.sql.streaming.{ListState, MapState, TTLConfig, ValueState}
import org.apache.spark.sql.types.StructType
+import org.apache.spark.util.Utils
/**
* This class is used to handle the state requests from the Python side. It runs on a separate
@@ -157,6 +159,13 @@ class TransformWithStateInPySparkStateServer(
return
}
+ // The task completion listener closes only the listening server socket, and the
+ // request loop has several early returns, so the accepted connection is closed
+ // through tryWithResource.
+ Utils.tryWithResource(listeningSocket)(serveRequests)
+ }
+
+ private def serveRequests(listeningSocket: SocketChannel): Unit = {
// SPARK-51667: We have a pattern of sending messages continuously from one side
// (Python -> JVM, and vice versa) before getting response from other side. Since most
// messages we are sending are small, this triggers the bad combination of Nagle's algorithm
diff --git a/sql/core/src/test/scala/org/apache/spark/sql/execution/python/streaming/TransformWithStateInPySparkStateServerSuite.scala b/sql/core/src/test/scala/org/apache/spark/sql/execution/python/streaming/TransformWithStateInPySparkStateServerSuite.scala
index b1d586d2207b..ea1c8a0a5d8e 100644
--- a/sql/core/src/test/scala/org/apache/spark/sql/execution/python/streaming/TransformWithStateInPySparkStateServerSuite.scala
+++ b/sql/core/src/test/scala/org/apache/spark/sql/execution/python/streaming/TransformWithStateInPySparkStateServerSuite.scala
@@ -17,12 +17,14 @@
package org.apache.spark.sql.execution.python.streaming
import java.io.{DataOutputStream, InterruptedIOException}
-import java.net.InetSocketAddress
+import java.net.{InetSocketAddress, Socket}
+import java.nio.ByteBuffer
import java.nio.channels.{
AsynchronousCloseException,
ClosedByInterruptException,
ClosedChannelException,
- ServerSocketChannel
+ ServerSocketChannel,
+ SocketChannel
}
import java.util.concurrent.atomic.AtomicReference
@@ -120,6 +122,34 @@ class TransformWithStateInPySparkStateServerSuite extends SparkFunSuite with Bef
.thenReturn(Seq(getIntegerRow(1)))
}
+ test("run closes the accepted socket once the request loop ends") {
+ val acceptedSocket = mock(classOf[SocketChannel])
+ when(serverSocket.accept()).thenReturn(acceptedSocket)
+ when(acceptedSocket.socket()).thenReturn(mock(classOf[Socket]))
+ // Ends the request loop right away: this test is about the socket, not the requests.
+ when(acceptedSocket.isConnected).thenReturn(false)
+
+ stateServer.run()
+
+ verify(acceptedSocket).close()
+ }
+
+ test("run closes the accepted socket when the client disconnects") {
+ val acceptedSocket = mock(classOf[SocketChannel])
+ when(serverSocket.accept()).thenReturn(acceptedSocket)
+ when(acceptedSocket.socket()).thenReturn(mock(classOf[Socket]))
+ when(acceptedSocket.isConnected).thenReturn(true)
+ // Channels.newInputStream synchronizes on this before reading.
+ when(acceptedSocket.blockingLock()).thenReturn(new Object)
+ when(acceptedSocket.isBlocking).thenReturn(true)
+ // No bytes ever arrive, so the read hits EOF and the loop returns early.
+ when(acceptedSocket.read(any(classOf[ByteBuffer]))).thenReturn(-1)
+
+ stateServer.run()
+
+ verify(acceptedSocket).close()
+ }
+
test("set handle state") {
val message = StatefulProcessorCall.newBuilder().setSetHandleState(
SetHandleState.newBuilder().setState(HandleState.CREATED).build()).build()
---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@spark.apache.org
For additional commands, e-mail: commits-help@spark.apache.org