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]