Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

extends FilterOutputStream(underlying) {

// FilterOutputStream writes an array one byte at a time.
override def write(b: Array[Byte], off: Int, len: Int): Unit = out.write(b, off, len)

override def close(): Unit = flush()
}
30 changes: 26 additions & 4 deletions core/src/main/scala/org/apache/spark/api/python/PythonRunner.scala
Original file line number Diff line number Diff line change
Expand Up @@ -1059,16 +1059,22 @@ private[spark] abstract class BasePythonRunner[IN, OUT](
private[this] var writerIfbhThreadLocalValue: Object = null
private[this] val temp = new Array[Byte](1)
private[this] val bufferStream = new DirectByteBufferOutputStream()
// The writer gets `bufferStream` only through this stream. Arrow writers write through an
// interruptible channel, so a task kill that interrupts this thread closes the stream they
// write to on the killing thread; that must not free `bufferStream` under the writer. It is
// freed only on this thread, by `writeAdditionalInputToPythonWorker()`, once all input is
// sent or when writing fails. See `CloseShieldOutputStream`.
private[this] val writerStream = new CloseShieldOutputStream(bufferStream)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This covers the default path, but PipelinedWriterRunnable still hands the Arrow writers an unshielded new DataOutputStream(bufferStream) (L1318, outside this diff). In pipelined mode (spark.python.udf.pipelined.enabled=true), any task that completes while the pool writer is still serializing (LIMIT/show(), a UDF error, a kill) runs writerFuture.cancel(true) at L554. If the writer is inside an Arrow Channels.newChannel(dataOut) chunk at that moment, Thread.interrupt() runs the channel's interruptor on the task thread, which closes dataOut and frees the direct buffer while the writer is still in buffer.put or grow(). writerFuture.get() returns right after a successful cancel, so nothing orders the two.

#59332 shields this stream on master, but its description doesn't say whether it goes to branch-4.3, where v4.3.0-rc2 ships the same code. If #59332 is not going to branch-4.3, could this PR also use new DataOutputStream(new CloseShieldOutputStream(bufferStream)) at L1318, so that the 4.3 backport covers both paths? That line only closes the buffer use-after-free; waiting for the writer before the later listeners run would stay with #59332.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

#59332 covers the pipelined path on branch-4.3 and branch-4.x too: its description says the pipelined mode was added in 4.3.0 by SPARK-56642, so both branches have the same issue there. So I'd keep PipelinedWriterRunnable in #59332 and leave this PR to the default path, which is the one that also needs branch-4.2 through branch-4.0.

/**
* Buffers data to be written to the Python worker until the socket is
* available for write.
* A best-effort attempt is made to not grow the buffer beyond "spark.buffer.size". See
* `writeAdditionalInputToPythonWorker()` for details.
* `writeBufferedInputToPythonWorker()` for details.
*/
private[this] var buffer: ByteBuffer = _

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed, it now points to writeBufferedInputToPythonWorker().

private[this] var hasInput = true

writer.open(new DataOutputStream(bufferStream))
writer.open(new DataOutputStream(writerStream))
buffer = bufferStream.toByteBuffer

override def read(): Int = {
Expand Down Expand Up @@ -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. `buffer` may be null or
// still point into that memory, so replace it with an empty one: a later call then
// fails fast in `bufferStream` instead of touching the freed memory.
buffer = ByteBuffer.allocate(0)
bufferStream.close()
throw t
}
}

/**
* Reads input data from `writer.inputIterator` into `buffer` and writes the buffer to the
* Python worker if the socket is available for writing.
*/
private def writeAdditionalInputToPythonWorker(): Unit = {
private def writeBufferedInputToPythonWorker(): Unit = {
var acceptsInput = true
while (acceptsInput && (hasInput || buffer.hasRemaining)) {
if (!buffer.hasRemaining && hasInput) {
Expand All @@ -1241,7 +1263,7 @@ private[spark] abstract class BasePythonRunner[IN, OUT](
// Set the `buffer` to null to make it eligible for GC
buffer = null

val dataOut = new DataOutputStream(bufferStream)
val dataOut = new DataOutputStream(writerStream)
// Try not to grow the buffer much beyond `bufferSize`. This is inevitable for large
// input rows.
while (bufferStream.size() < bufferSize && hasInput && !shouldFlush()) {
Expand Down
Original file line number Diff line number Diff line change
@@ -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, and
// a later read fails instead of reusing the freed buffer. Closing the peer first makes a
// regression return -1 rather than wait for output.
accepted.close()
val e = intercept[SparkException](input.read(new Array[Byte](1)))
assert(e.getMessage.contains("closed DirectByteBufferOutputStream"))
} finally {
// Never leave the writer spinning, even if the test failed before releasing it.
resume.set(true)
worker.stop()
accepted.close()
server.close()
}
}
}