Skip to content

Commit 6cb5216

Browse files
j1wonparkHeartSaVioR
authored andcommitted
[SPARK-58932][4.2][SS] Close the accepted socket in TransformWithStateInPySparkStateServer
### What changes were proposed in this pull request? Backport of #58205 to branch-4.2: run the request loop of `TransformWithStateInPySparkStateServer.run()` inside `Utils.tryWithResource` so the accepted socket is always closed. This branch does not have SPARK-58977, so unlike master the `accept()` call is unchanged. ### Why are the changes needed? The accepted socket is never closed, leaking one file descriptor per task until the executor exhausts the ephemeral port range (`java.net.BindException`). See #58205 for details. ### Does this PR introduce _any_ user-facing change? No. ### How was this patch tested? The two tests added in #58205 are included; `TransformWithStateInPySparkStateServerSuite` passes locally on this branch. ### Was this patch authored or co-authored using generative AI tooling? Yes. Generated-by: Claude Opus 5 Closes #58308 from j1wonpark/SPARK-58932-4.2. Authored-by: Jiwon Park <jpark92@outlook.kr> Signed-off-by: Jungtaek Lim <kabhwan.opensource@gmail.com>
1 parent 650761b commit 6cb5216

2 files changed

Lines changed: 40 additions & 2 deletions

File tree

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

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@
1818
package org.apache.spark.sql.execution.python.streaming
1919

2020
import java.io.{BufferedInputStream, BufferedOutputStream, DataInputStream, DataOutputStream, EOFException, InterruptedIOException}
21-
import java.nio.channels.{Channels, ClosedByInterruptException, ServerSocketChannel}
21+
import java.nio.channels.{Channels, ClosedByInterruptException, ServerSocketChannel, SocketChannel}
2222
import java.time.Duration
2323

2424
import scala.collection.mutable
@@ -40,6 +40,7 @@ import org.apache.spark.sql.execution.streaming.state.StateMessage.KeyAndValuePa
4040
import org.apache.spark.sql.execution.streaming.state.StateMessage.StateResponseWithListGet
4141
import org.apache.spark.sql.streaming.{ListState, MapState, TTLConfig, ValueState}
4242
import org.apache.spark.sql.types.StructType
43+
import org.apache.spark.util.Utils
4344

4445
/**
4546
* This class is used to handle the state requests from the Python side. It runs on a separate
@@ -140,6 +141,13 @@ class TransformWithStateInPySparkStateServer(
140141
def run(): Unit = {
141142
val listeningSocket = stateServerSocket.accept()
142143

144+
// The task completion listener closes only the listening server socket, and the
145+
// request loop has several early returns, so the accepted connection is closed
146+
// through tryWithResource.
147+
Utils.tryWithResource(listeningSocket)(serveRequests)
148+
}
149+
150+
private def serveRequests(listeningSocket: SocketChannel): Unit = {
143151
// SPARK-51667: We have a pattern of sending messages continuously from one side
144152
// (Python -> JVM, and vice versa) before getting response from other side. Since most
145153
// 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: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,9 @@
1717
package org.apache.spark.sql.execution.python.streaming
1818

1919
import java.io.DataOutputStream
20-
import java.nio.channels.ServerSocketChannel
20+
import java.net.Socket
21+
import java.nio.ByteBuffer
22+
import java.nio.channels.{ServerSocketChannel, SocketChannel}
2123

2224
import scala.collection.mutable
2325

@@ -110,6 +112,34 @@ class TransformWithStateInPySparkStateServerSuite extends SparkFunSuite with Bef
110112
.thenReturn(Seq(getIntegerRow(1)))
111113
}
112114

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

0 commit comments

Comments
 (0)