viirya commented on code in PR #59565:
URL: https://github.com/apache/spark/pull/59565#discussion_r4318711968


##########
core/src/main/scala/org/apache/spark/api/python/PythonRunner.scala:
##########
@@ -1228,11 +1234,27 @@ private[spark] abstract class BasePythonRunner[IN, OUT](
       }
     }
 
+    private def writeAdditionalInputToPythonWorker(): Unit = {
+      try {
+        writeBufferedInputToPythonWorker()
+      } catch {
+        case t: Throwable =>
+          // Writing failed or the task was killed mid-write, so the close at 
the end of
+          // `writeBufferedInputToPythonWorker()` is never reached. Free the 
buffer here, on the
+          // thread that writes into it, instead of leaving it to GC, and stop 
sending input so
+          // that a later call does not touch it.
+          hasInput = false

Review Comment:
   You're right, the empty `buffer` is the line that matters. I removed 
`hasInput = false`, so a later call now fails fast at `bufferStream.reset()`, 
and reworded the comment. The `ReaderInputStream` test now ends with your later 
read after closing the peer, which fails with a `NullPointerException` without 
the empty `buffer`, returns -1 with `hasInput = false` back, and returns -1 
without the free.



##########
core/src/main/scala/org/apache/spark/api/python/CloseShieldOutputStream.scala:
##########
@@ -0,0 +1,41 @@
+/*
+ * 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.api.python
+
+import java.io.{FilterOutputStream, OutputStream}
+
+/**
+ * An output stream that forwards writes to `underlying` but does not close it 
on [[close()]].
+ *
+ * Python runners serialize their input into a `DirectByteBufferOutputStream`, 
whose `close()`
+ * frees its native buffer. Arrow writers write that stream through 
`Channels.newChannel(...)`,
+ * an interruptible channel: when another thread interrupts the writing thread 
inside a channel
+ * write (e.g. a task kill with `interruptThread = true`), 
`Thread.interrupt()` closes the
+ * channel, and so the wrapped stream, on the interrupting thread while the 
writing thread may
+ * still be copying into the buffer. Handing the writer this stream keeps the 
buffer alive; the
+ * writing thread still stops with a `ClosedByInterruptException`, and the 
buffer is freed only
+ * by its owner.
+ */
+private[python] class CloseShieldOutputStream(underlying: OutputStream)

Review Comment:
   Agreed, #59332 will take this file verbatim, so the two copies are the same 
class again. One caveat for the backports: if #59332 lands first, this PR's 
squashed commit no longer adds the file, so the backports to branch-4.2, 4.1 
and 4.0 have to bring it along. I noted that in the description.



##########
core/src/main/scala/org/apache/spark/api/python/PythonRunner.scala:
##########
@@ -1068,7 +1074,7 @@ private[spark] abstract class BasePythonRunner[IN, OUT](
     private[this] var buffer: ByteBuffer = _

Review Comment:
   Fixed, it now points to `writeBufferedInputToPythonWorker()`.



##########
core/src/test/scala/org/apache/spark/api/python/CloseShieldOutputStreamSuite.scala:
##########
@@ -0,0 +1,147 @@
+/*
+ * 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.api.python
+
+import java.io.{DataOutputStream, FilterOutputStream}
+import java.net.{InetAddress, InetSocketAddress}
+import java.nio.ByteBuffer
+import java.nio.channels.{Channels, ClosedByInterruptException, 
ServerSocketChannel, SocketChannel}
+import java.util.{ArrayList => JArrayList, HashMap => JHashMap}
+import java.util.concurrent.{CountDownLatch, TimeUnit}
+import java.util.concurrent.atomic.AtomicBoolean
+
+import org.apache.spark.{LocalSparkContext, SparkContext, SparkEnv, 
SparkException, SparkFunSuite}
+import org.apache.spark.util.DirectByteBufferOutputStream
+
+class CloseShieldOutputStreamSuite extends SparkFunSuite with 
LocalSparkContext {
+
+  test("SPARK-60305: forwards bulk writes and does not close the underlying 
stream") {
+    var byteWrites = 0
+    val bufferStream = new DirectByteBufferOutputStream() {
+      override def write(b: Int): Unit = {
+        byteWrites += 1
+        super.write(b)
+      }
+    }
+    val shielded = new CloseShieldOutputStream(bufferStream)
+    shielded.write(Array[Byte](1, 2, 3), 1, 2)
+    assert(byteWrites === 0)
+    shielded.write(4)
+    assert(byteWrites === 1)
+    shielded.close()
+    assert(bufferStream.size() === 3)
+    val bytes = new Array[Byte](3)
+    bufferStream.toByteBuffer.get(bytes)
+    assert(bytes.toSeq === Seq[Byte](2, 3, 4))
+    bufferStream.close()
+  }
+
+  test("SPARK-60305: an interrupted channel write does not close the shielded 
buffer") {
+    val bufferStream = new DirectByteBufferOutputStream()
+    val channel =
+      Channels.newChannel(new DataOutputStream(new 
CloseShieldOutputStream(bufferStream)))
+    // With the interrupt flag already set, the channel closes itself, and the 
stream it writes
+    // to, on this thread before it writes.
+    Thread.currentThread().interrupt()
+    try {
+      intercept[ClosedByInterruptException](channel.write(ByteBuffer.wrap(new 
Array[Byte](16))))
+    } finally {
+      Thread.interrupted()
+    }
+    assert(bufferStream.size() === 16)
+    bufferStream.close()
+  }
+
+  test("SPARK-60305: interrupting the task thread inside a channel write keeps 
the " +
+      "ReaderInputStream buffer alive") {
+    sc = new SparkContext("local", "test")
+    val func = SimplePythonFunction(Seq.empty[Byte], new JHashMap[String, 
String](),
+      new JArrayList[String](), "python3", "3", new JArrayList(), null)
+    val runner = new PythonRunner(Seq(ChainedPythonFunctions(Seq(func))), None)
+    val inWrite = new CountDownLatch(1)
+    val resume = new AtomicBoolean(false)
+    @volatile var writeError: Throwable = null
+
+    val server = ServerSocketChannel.open()
+    server.bind(new InetSocketAddress(InetAddress.getLoopbackAddress, 0))
+    val client = SocketChannel.open(server.getLocalAddress)
+    val accepted = server.accept()
+    client.configureBlocking(false)
+    val worker = PythonWorker(client).refresh()
+    try {
+      val writer = new runner.Writer(SparkEnv.get, worker, Iterator.empty, 0, 
null) {
+        // An interrupt closes the stream the writer writes to; with the 
shield, this only
+        // flushes.
+        override def open(dataOut: DataOutputStream): Unit = dataOut.close()
+        override protected def writeCommand(dataOut: DataOutputStream): Unit = 
{}
+        // Writes the way the Arrow inputs do: through 
`Channels.newChannel(dataOut)`, which the
+        // JVM closes, together with `dataOut`, when the writing thread is 
interrupted. The
+        // write blocks until the test has interrupted the thread, then 
forwards to `dataOut`.
+        override def writeNextInputToStream(dataOut: DataOutputStream): 
Boolean = {
+          val channel = Channels.newChannel(new FilterOutputStream(dataOut) {
+            override def write(b: Array[Byte], off: Int, len: Int): Unit = {
+              inWrite.countDown()
+              while (!resume.get()) Thread.onSpinWait()
+              try {
+                out.write(b, off, len)
+              } catch {
+                case t: Throwable =>
+                  writeError = t
+                  throw t
+              }
+            }
+          })
+          channel.write(ByteBuffer.wrap(new Array[Byte](16)))
+          false
+        }
+      }
+      val input = new runner.ReaderInputStream(worker, writer, None, 0, false, 
null)
+      @volatile var error: Throwable = null
+      val taskThread = new Thread(() => {
+        try {
+          input.read(new Array[Byte](1))
+        } catch {
+          case t: Throwable => error = t
+        }
+      })
+      taskThread.start()
+      assert(inWrite.await(1, TimeUnit.MINUTES), s"task thread failed before 
the write: $error")
+      // Like Task.kill(interruptThread = true). This closes the channel and 
`dataOut` on this
+      // thread while the task thread is still inside the write.
+      taskThread.interrupt()
+      resume.set(true)
+      taskThread.join(TimeUnit.MINUTES.toMillis(1))
+      assert(!taskThread.isAlive)
+      // The write into the buffer still succeeds: the buffer was not freed 
under the writer.
+      assert(writeError === null)
+      assert(error.isInstanceOf[ClosedByInterruptException])
+      // The failed write frees the buffer on the task thread instead of 
leaving it to GC.
+      val bufferField = input.getClass.getDeclaredField("bufferStream")

Review Comment:
   Removed the reflection. The later read from item 11 already covers it: it 
fails with the closed error only if the catch freed the buffer, and returns -1 
without the free, so I didn't add a separate write through `dataOut`.



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