dongjoon-hyun commented on code in PR #59565:
URL: https://github.com/apache/spark/pull/59565#discussion_r4315040671


##########
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:
   **`buffer = ByteBuffer.allocate(0)` on the next line, not `hasInput = 
false`, is what keeps a later call off the freed memory, and if `read()` were 
ever called again after this rethrow, `hasInput = false` would turn a fail-fast 
into an unbounded wait.** Without the empty `buffer`, a later call would hit a 
null `buffer` (set at L1264) after a writer failure, or pass the freed memory 
to the socket after a failed `worker.channel.write(buffer)` (L1281). Every 
`bufferStream` method that call could reach (`reset()`, `size()`, 
`toByteBuffer`, `write`) already fails in `checkNotClosed()`. So without 
`hasInput = false`, a later `read()` fails fast at `bufferStream.reset()` 
(L1262) with "Cannot call methods on a closed DirectByteBufferOutputStream". 
With it, the call takes the end-of-input branch (L1286-L1289): it switches the 
key to `OP_READ` as if the end of input had been sent, which it never was, and 
waits in `select` for output from a worker that is itself waiting for its next 
input. W
 ith the default `spark.python.worker.idleTimeoutSeconds=0`, that lasts until 
the task is killed. Nothing reads again after this exception today (every 
`handleException` case throws, and `PythonForeachWriter`'s `WriterThread` calls 
`hasNext` once), so this is latent, but the comment above and the PR 
description ("the stream stops sending input, so a later call does not touch 
the freed buffer") credit the safety to the wrong line.
   
   Neither line is covered either: test 3 never calls `read()` again, so 
deleting L1246 or L1247 keeps all three tests green. Could we drop `hasInput = 
false` and end test 3 with a read that must fail? Closing the peer first makes 
a regression fail instead of hang:
   
   ```scala
         // A later read fails instead of reusing the freed buffer.
         accepted.close()
         val e2 = intercept[SparkException](input.read(new Array[Byte](1)))
         assert(e2.getMessage.contains("closed DirectByteBufferOutputStream"))
   ```
   
   Without `buffer = ByteBuffer.allocate(0)` this read throws a 
`NullPointerException`, and with `hasInput = false` back it returns -1, so 
either change fails the test. (If you'd rather keep `hasInput = false`, 
`assert(input.read(new Array[Byte](1)) === -1)` after `accepted.close()` covers 
both lines instead.)



##########
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:
   **This copy and #59332's are no longer the same class.** Since a13f656, this 
one takes `underlying: OutputStream` (my item 9), while #59332's copy (L33 at 
820c7ac) still declares `class CloseShieldOutputStream(out: OutputStream) 
extends FilterOutputStream(out)`, with a different scaladoc and without the 
one-byte comment above `write`. If #59332 merges first and this PR "drops its 
copy on rebase", as the description plans, the rename is lost. This PR's commit 
would then also no longer add the file, so its cherry-picks to branch-4.2, 4.1 
and 4.0, which #59332 doesn't reach, wouldn't compile unless the file is 
brought along. Could #59332 take this file verbatim? Then either PR can land 
second cleanly, and "the same class" here and "Both changes add the same 
`CloseShieldOutputStream`" in #59332's description are true again.



##########
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:
   **nit: after this round's rename, the scaladoc of this field still says "See 
`writeAdditionalInputToPythonWorker()` for details." (L1072), but that name is 
now the try/catch wrapper.** The sizing loop it refers to ("Try not to grow the 
buffer much beyond `bufferSize`", L1267) moved to 
`writeBufferedInputToPythonWorker()`. Could it point there instead? The other 
uses of the old name (the `writerStream` comment above and 
`PythonForeachWriter`) are still accurate, since they refer to the entry point 
that `read()` calls.



##########
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:
   **nit: instead of reflection, this can check the free through the stream the 
runner hands the writer.** `DataOutputStream.write(int)` goes through the 
shield to `DirectByteBufferOutputStream.write(int)`, which runs 
`checkNotClosed()`, and neither `DataOutputStream` nor `FilterOutputStream` 
checks its own `closed` flag, so it doesn't matter that the interrupt closed 
that `DataOutputStream`. The write throws only if the catch freed the buffer, 
and it also shows that the writer's own stream now fails instead of writing 
into freed memory:
   
   ```scala
       @volatile var writeError: Throwable = null
       @volatile var inputOut: DataOutputStream = null
   ...
           override def writeNextInputToStream(dataOut: DataOutputStream): 
Boolean = {
             inputOut = dataOut
   ...
         // The failed write frees the buffer on the task thread instead of 
leaving it to GC.
         val e = intercept[SparkException](inputOut.write(0))
         assert(e.getMessage.contains("closed DirectByteBufferOutputStream"))
   ```



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