Skip to content

Commit c5fbdf7

Browse files
j1wonparkHeartSaVioR
authored andcommitted
[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 <jpark92@outlook.kr> Signed-off-by: Jungtaek Lim <kabhwan.opensource@gmail.com>
1 parent 69a108b commit c5fbdf7

2 files changed

Lines changed: 42 additions & 3 deletions

File tree

sql/core/src/main/scala/org/apache/spark/sql/execution/python/streaming/TransformWithStateInPySparkStateServer.scala

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,8 @@ import java.nio.channels.{
2222
Channels,
2323
ClosedByInterruptException,
2424
ClosedChannelException,
25-
ServerSocketChannel
25+
ServerSocketChannel,
26+
SocketChannel
2627
}
2728
import java.time.Duration
2829

@@ -45,6 +46,7 @@ import org.apache.spark.sql.execution.streaming.state.StateMessage.KeyAndValuePa
4546
import org.apache.spark.sql.execution.streaming.state.StateMessage.StateResponseWithListGet
4647
import org.apache.spark.sql.streaming.{ListState, MapState, TTLConfig, ValueState}
4748
import org.apache.spark.sql.types.StructType
49+
import org.apache.spark.util.Utils
4850

4951
/**
5052
* This class is used to handle the state requests from the Python side. It runs on a separate
@@ -157,6 +159,13 @@ class TransformWithStateInPySparkStateServer(
157159
return
158160
}
159161

162+
// The task completion listener closes only the listening server socket, and the
163+
// request loop has several early returns, so the accepted connection is closed
164+
// through tryWithResource.
165+
Utils.tryWithResource(listeningSocket)(serveRequests)
166+
}
167+
168+
private def serveRequests(listeningSocket: SocketChannel): Unit = {
160169
// SPARK-51667: We have a pattern of sending messages continuously from one side
161170
// (Python -> JVM, and vice versa) before getting response from other side. Since most
162171
// messages we are sending are small, this triggers the bad combination of Nagle's algorithm

sql/core/src/test/scala/org/apache/spark/sql/execution/python/streaming/TransformWithStateInPySparkStateServerSuite.scala

Lines changed: 32 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,12 +17,14 @@
1717
package org.apache.spark.sql.execution.python.streaming
1818

1919
import java.io.{DataOutputStream, InterruptedIOException}
20-
import java.net.InetSocketAddress
20+
import java.net.{InetSocketAddress, Socket}
21+
import java.nio.ByteBuffer
2122
import java.nio.channels.{
2223
AsynchronousCloseException,
2324
ClosedByInterruptException,
2425
ClosedChannelException,
25-
ServerSocketChannel
26+
ServerSocketChannel,
27+
SocketChannel
2628
}
2729
import java.util.concurrent.atomic.AtomicReference
2830

@@ -120,6 +122,34 @@ class TransformWithStateInPySparkStateServerSuite extends SparkFunSuite with Bef
120122
.thenReturn(Seq(getIntegerRow(1)))
121123
}
122124

125+
test("run closes the accepted socket once the request loop ends") {
126+
val acceptedSocket = mock(classOf[SocketChannel])
127+
when(serverSocket.accept()).thenReturn(acceptedSocket)
128+
when(acceptedSocket.socket()).thenReturn(mock(classOf[Socket]))
129+
// Ends the request loop right away: this test is about the socket, not the requests.
130+
when(acceptedSocket.isConnected).thenReturn(false)
131+
132+
stateServer.run()
133+
134+
verify(acceptedSocket).close()
135+
}
136+
137+
test("run closes the accepted socket when the client disconnects") {
138+
val acceptedSocket = mock(classOf[SocketChannel])
139+
when(serverSocket.accept()).thenReturn(acceptedSocket)
140+
when(acceptedSocket.socket()).thenReturn(mock(classOf[Socket]))
141+
when(acceptedSocket.isConnected).thenReturn(true)
142+
// Channels.newInputStream synchronizes on this before reading.
143+
when(acceptedSocket.blockingLock()).thenReturn(new Object)
144+
when(acceptedSocket.isBlocking).thenReturn(true)
145+
// No bytes ever arrive, so the read hits EOF and the loop returns early.
146+
when(acceptedSocket.read(any(classOf[ByteBuffer]))).thenReturn(-1)
147+
148+
stateServer.run()
149+
150+
verify(acceptedSocket).close()
151+
}
152+
123153
test("set handle state") {
124154
val message = StatefulProcessorCall.newBuilder().setSetHandleState(
125155
SetHandleState.newBuilder().setState(HandleState.CREATED).build()).build()

0 commit comments

Comments
 (0)