dongjoon-hyun commented on code in PR #59565:
URL: https://github.com/apache/spark/pull/59565#discussion_r4300834721
##########
core/src/test/scala/org/apache/spark/api/python/BasePythonRunnerSuite.scala:
##########
@@ -211,4 +219,102 @@ class BasePythonRunnerSuite extends SparkFunSuite {
assert(BasePythonRunner.readWorkerMetrics(stream) == expectedMetrics)
checkEndMarkers(stream)
}
+
+ test("SPARK-60305: CloseShieldOutputStream forwards writes and does not
close the " +
+ "underlying stream") {
+ val bufferStream = new DirectByteBufferOutputStream()
+ val shielded = new CloseShieldOutputStream(bufferStream)
+ shielded.write(Array[Byte](1, 2, 3), 1, 2)
Review Comment:
**Deleting the `write(b, off, len)` override (CloseShieldOutputStream.scala
L37) keeps both new tests green.** This test only compares the bytes, and
`FilterOutputStream`'s default loop produces the same bytes one `write(int)` at
a time. Without the override, every bulk write into `writerStream` (each Arrow
chunk of up to 8 KiB, pickled rows, even the 4 bytes of `writeInt`) would go
through `DirectByteBufferOutputStream.write(int)`, each call running
`checkNotClosed()`, `ensureCapacity()` and a single-byte `put`. Could we count
the per-byte writes on the delegate? For example:
```scala
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)
```
##########
core/src/main/scala/org/apache/spark/api/python/PythonRunner.scala:
##########
@@ -1059,6 +1059,11 @@ 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 by `bufferStream.close()` below. See
`CloseShieldOutputStream`.
Review Comment:
**On the kill path this PR targets, `bufferStream.close()` below is never
reached, so the direct buffer now stays allocated until GC runs its Cleaner.**
L1272 only runs when `!hasInput && !buffer.hasRemaining`, i.e. after all input
has been sent. When a task is killed with `interruptThread = true` during an
Arrow write, the `ClosedByInterruptException` leaves
`writer.writeNextInputToStream` (L1253) before L1269 and becomes a
`TaskKilledException`. Before this PR, the interrupt close freed the buffer
right away when the Arrow write channel was the first to see the interrupt:
racily when the killing thread closed the channel mid-chunk (the bug fixed
here), but safely when the interrupt landed outside a chunk and the next
chunk's `begin()` closed the channel on the task thread itself. The shield now
makes both closes no-ops. (Kills noticed elsewhere first already relied on GC.)
With the default 64MB `spark.sql.execution.arrow.maxBytesPerBatch`, this
buffer can grow to about 128 MiB for wide rows (`grow()` doubles and `reset()`
never shrinks it), and `Platform.allocateDirectBuffer` allocates outside
`MaxDirectMemorySize`, so nothing forces that GC. Since
`spark.sql.execution.interruptOnCancel` is on by default, each cancelled Arrow
UDF task can hold one until then.
Could we free it on the thread that drives this stream when an exception
escapes `writeAdditionalInputToPythonWorker` (clear `buffer`, call
`bufferStream.close()` and rethrow), like `PipelinedWriterRunnable` does in its
`finally`? A task completion listener wouldn't be safe here, because
`PythonForeachWriter` drives this stream from its own `WriterThread`, which can
outlive the task. Overriding `ReaderInputStream.close()` wouldn't be either,
because an interrupted `ArrowStreamReader` can close its channel, and this
stream with it, on the killing thread. Either way, "It is freed only by
`bufferStream.close()` below" here, and "The buffer is still freed exactly
once, by `ReaderInputStream`'s own `bufferStream.close()` after the last input
is sent" in the description, describe only normal completion.
##########
core/src/test/scala/org/apache/spark/api/python/BasePythonRunnerSuite.scala:
##########
@@ -211,4 +219,102 @@ class BasePythonRunnerSuite extends SparkFunSuite {
assert(BasePythonRunner.readWorkerMetrics(stream) == expectedMetrics)
checkEndMarkers(stream)
}
+
+ test("SPARK-60305: CloseShieldOutputStream forwards writes and does not
close the " +
+ "underlying stream") {
+ val bufferStream = new DirectByteBufferOutputStream()
+ val shielded = new CloseShieldOutputStream(bufferStream)
+ shielded.write(Array[Byte](1, 2, 3), 1, 2)
+ shielded.write(4)
+ 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: interrupting the task thread inside a channel write keeps
the input " +
+ "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 inWrite = new CountDownLatch(1)
+ val resume = new AtomicBoolean(false)
+ @volatile var writeError: Throwable = null
+
+ // Writes the way the Arrow inputs do: through
`Channels.newChannel(dataOut)`, whose writes
+ // 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`.
+ class TestRunner extends BasePythonRunner[Int,
Int](Seq(ChainedPythonFunctions(Seq(func))),
+ PythonEvalType.NON_UDF, Array(Array(0)), None, Map.empty) {
+ override protected def newWriter(env: SparkEnv, worker: PythonWorker,
+ inputIterator: Iterator[Int], partitionIndex: Int, context:
TaskContext): Writer = {
+ new Writer(env, worker, inputIterator, partitionIndex, context) {
+ override def open(dataOut: DataOutputStream): Unit = {}
Review Comment:
**Since `open` is stubbed here, reverting PythonRunner.scala L1076 to
`writer.open(new DataOutputStream(bufferStream))` still passes both tests.**
How about `override def open(dataOut: DataOutputStream): Unit =
dataOut.close()`? With the fix, the close only flushes. With L1076 reverted, it
frees `bufferStream`, and `buffer = bufferStream.toByteBuffer` at L1077 then
fails with "Cannot call methods on a closed DirectByteBufferOutputStream" while
the `ReaderInputStream` is being constructed, with no threads involved.
Related: in this test only `assert(writeError === null)` tells fixed from
unfixed, because `end(true)` in the channel's `finally` throws
`ClosedByInterruptException` either way, so that assertion is the one to keep
if the test is reworked.
##########
core/src/test/scala/org/apache/spark/api/python/BasePythonRunnerSuite.scala:
##########
@@ -211,4 +219,102 @@ class BasePythonRunnerSuite extends SparkFunSuite {
assert(BasePythonRunner.readWorkerMetrics(stream) == expectedMetrics)
checkEndMarkers(stream)
}
+
+ test("SPARK-60305: CloseShieldOutputStream forwards writes and does not
close the " +
+ "underlying stream") {
+ val bufferStream = new DirectByteBufferOutputStream()
+ val shielded = new CloseShieldOutputStream(bufferStream)
+ shielded.write(Array[Byte](1, 2, 3), 1, 2)
+ shielded.write(4)
+ 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: interrupting the task thread inside a channel write keeps
the input " +
+ "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 inWrite = new CountDownLatch(1)
+ val resume = new AtomicBoolean(false)
+ @volatile var writeError: Throwable = null
+
+ // Writes the way the Arrow inputs do: through
`Channels.newChannel(dataOut)`, whose writes
+ // 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`.
+ class TestRunner extends BasePythonRunner[Int,
Int](Seq(ChainedPythonFunctions(Seq(func))),
+ PythonEvalType.NON_UDF, Array(Array(0)), None, Map.empty) {
+ override protected def newWriter(env: SparkEnv, worker: PythonWorker,
+ inputIterator: Iterator[Int], partitionIndex: Int, context:
TaskContext): Writer = {
+ new Writer(env, worker, inputIterator, partitionIndex, context) {
+ override def open(dataOut: DataOutputStream): Unit = {}
+ override protected def writeCommand(dataOut: DataOutputStream): Unit
= {}
+ 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
+ }
+ }
+ }
+
+ override protected def newReaderIterator(stream: DataInputStream,
writer: Writer,
+ startTime: Long, env: SparkEnv, worker: PythonWorker,
+ handle: Option[PythonWorkerHandle], releasedOrClosed: AtomicBoolean,
+ context: TaskContext): Iterator[Int] = throw new
UnsupportedOperationException()
+
+ def newReaderInputStream(worker: PythonWorker): ReaderInputStream = new
ReaderInputStream(
+ worker, newWriter(SparkEnv.get, worker, Iterator.empty, 0, null),
None, 0, false, null)
+ }
+ val runner = new TestRunner
+
+ 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 input = runner.newReaderInputStream(worker)
+ @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))
+ // 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.
+ // The interrupt then surfaces as the channel's
ClosedByInterruptException.
+ assert(writeError === null)
+ assert(error.isInstanceOf[ClosedByInterruptException], error)
Review Comment:
**ScalaTest's `assert(cond, clue)` rejects a null clue before it evaluates
`cond`, since `macroAssert` calls `requireNonNull(clue)` first.** So if
`input.read` ever returns normally here, this fails with
`NullArgumentException: clue was null` instead of saying that no exception was
thrown. The clue also adds nothing when `error` is non-null, because the
`isInstanceOf` macro already prints the left operand. Could we drop it, or use
a null-safe string such as `s"expected ClosedByInterruptException but got:
$error"`? Similarly, if the task thread fails before it reaches the write,
`assert(inWrite.await(1, TimeUnit.MINUTES))` at L303 waits a minute and reports
only that the await returned false; `s"task thread failed before the write:
$error"` as its clue would show the cause.
##########
core/src/main/scala/org/apache/spark/api/python/CloseShieldOutputStream.scala:
##########
@@ -0,0 +1,40 @@
+/*
+ * 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 `out` 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(out: OutputStream) extends
FilterOutputStream(out) {
Review Comment:
**nit: since `write(b, off, len)` refers to `out`, Scala resolves it to this
constructor parameter and keeps it as a second field next to the inherited
`FilterOutputStream.out`.** Both point to the same object today, but L37 then
doesn't use the field that `write(int)` and `flush()` use. Naming the parameter
differently, e.g. `class CloseShieldOutputStream(underlying: OutputStream)
extends FilterOutputStream(underlying)`, makes `out` at L37 the inherited field
and drops the extra one.
##########
core/src/test/scala/org/apache/spark/api/python/BasePythonRunnerSuite.scala:
##########
@@ -211,4 +219,102 @@ class BasePythonRunnerSuite extends SparkFunSuite {
assert(BasePythonRunner.readWorkerMetrics(stream) == expectedMetrics)
checkEndMarkers(stream)
}
+
+ test("SPARK-60305: CloseShieldOutputStream forwards writes and does not
close the " +
+ "underlying stream") {
+ val bufferStream = new DirectByteBufferOutputStream()
+ val shielded = new CloseShieldOutputStream(bufferStream)
+ shielded.write(Array[Byte](1, 2, 3), 1, 2)
+ shielded.write(4)
+ 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: interrupting the task thread inside a channel write keeps
the input " +
+ "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 inWrite = new CountDownLatch(1)
+ val resume = new AtomicBoolean(false)
+ @volatile var writeError: Throwable = null
+
+ // Writes the way the Arrow inputs do: through
`Channels.newChannel(dataOut)`, whose writes
+ // 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`.
+ class TestRunner extends BasePythonRunner[Int,
Int](Seq(ChainedPythonFunctions(Seq(func))),
Review Comment:
**nit: `PythonRunner` is already a concrete `BasePythonRunner` with exactly
these constructor arguments (PythonRunner.scala L1403-L1406).** `new
PythonRunner(Seq(ChainedPythonFunctions(Seq(func))), None)` plus `new
runner.Writer(SparkEnv.get, worker, Iterator.empty, 0, null) { ... }` and `new
runner.ReaderInputStream(worker, writer, None, 0, false, null)` could replace
`TestRunner`, its never-called `newReaderIterator` override, the
`newReaderInputStream` helper and the `TaskContext` import, the same way #59332
builds its runner test in this suite. It would also ease the backports: the
dead override's `handle: Option[PythonWorkerHandle]` is `pid: Option[Int]` on
branch-4.2/4.1/4.0, while the `PythonRunner` and `Writer` constructors are the
same on all four branches.
##########
core/src/test/scala/org/apache/spark/api/python/BasePythonRunnerSuite.scala:
##########
@@ -211,4 +219,102 @@ class BasePythonRunnerSuite extends SparkFunSuite {
assert(BasePythonRunner.readWorkerMetrics(stream) == expectedMetrics)
checkEndMarkers(stream)
}
+
+ test("SPARK-60305: CloseShieldOutputStream forwards writes and does not
close the " +
+ "underlying stream") {
+ val bufferStream = new DirectByteBufferOutputStream()
+ val shielded = new CloseShieldOutputStream(bufferStream)
+ shielded.write(Array[Byte](1, 2, 3), 1, 2)
+ shielded.write(4)
+ 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: interrupting the task thread inside a channel write keeps
the input " +
Review Comment:
**The fix itself applies cleanly to branch-4.3, 4.2, 4.1 and 4.0, but this
test won't.** `BasePythonRunnerSuite` doesn't exist on branch-4.2/4.1/4.0.
`PythonWorkerHandle` (SPARK-58119) is 4.3+ only, so the `newReaderIterator`
override takes `pid: Option[Int]` there, and `ReaderInputStream` takes 7, 6
(differently typed) and 4 parameters on 4.2, 4.1 and 4.0. Even on branch-4.3,
the cherry-pick conflicts in the imports and at the end of this file. Since the
description plans backports down to 4.0, could the new tests go into their own
suite file, plus a runner-free check that ports unchanged? With the interrupt
flag already set, `begin()` closes the channel and its stream on the writing
thread before the write, so this fails at `size()` without the shield and
passes with it on JDK 17, 21 and 25:
```scala
test("SPARK-60305: an interrupted channel write does not close the shielded
buffer") {
val buffer = new DirectByteBufferOutputStream()
val channel = Channels.newChannel(new DataOutputStream(new
CloseShieldOutputStream(buffer)))
Thread.currentThread().interrupt()
try {
intercept[ClosedByInterruptException](channel.write(ByteBuffer.wrap(new
Array[Byte](16))))
} finally {
Thread.interrupted()
}
assert(buffer.size() === 16)
buffer.close()
}
```
It doesn't replace this test, since it covers neither the cross-thread close
nor the `ReaderInputStream` wiring.
##########
core/src/test/scala/org/apache/spark/api/python/BasePythonRunnerSuite.scala:
##########
@@ -211,4 +219,102 @@ class BasePythonRunnerSuite extends SparkFunSuite {
assert(BasePythonRunner.readWorkerMetrics(stream) == expectedMetrics)
checkEndMarkers(stream)
}
+
+ test("SPARK-60305: CloseShieldOutputStream forwards writes and does not
close the " +
+ "underlying stream") {
+ val bufferStream = new DirectByteBufferOutputStream()
+ val shielded = new CloseShieldOutputStream(bufferStream)
+ shielded.write(Array[Byte](1, 2, 3), 1, 2)
+ shielded.write(4)
+ 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: interrupting the task thread inside a channel write keeps
the input " +
+ "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 inWrite = new CountDownLatch(1)
+ val resume = new AtomicBoolean(false)
+ @volatile var writeError: Throwable = null
+
+ // Writes the way the Arrow inputs do: through
`Channels.newChannel(dataOut)`, whose writes
+ // 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`.
+ class TestRunner extends BasePythonRunner[Int,
Int](Seq(ChainedPythonFunctions(Seq(func))),
+ PythonEvalType.NON_UDF, Array(Array(0)), None, Map.empty) {
+ override protected def newWriter(env: SparkEnv, worker: PythonWorker,
+ inputIterator: Iterator[Int], partitionIndex: Int, context:
TaskContext): Writer = {
+ new Writer(env, worker, inputIterator, partitionIndex, context) {
+ override def open(dataOut: DataOutputStream): Unit = {}
+ override protected def writeCommand(dataOut: DataOutputStream): Unit
= {}
+ 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
+ }
+ }
+ }
+
+ override protected def newReaderIterator(stream: DataInputStream,
writer: Writer,
+ startTime: Long, env: SparkEnv, worker: PythonWorker,
+ handle: Option[PythonWorkerHandle], releasedOrClosed: AtomicBoolean,
+ context: TaskContext): Iterator[Int] = throw new
UnsupportedOperationException()
+
+ def newReaderInputStream(worker: PythonWorker): ReaderInputStream = new
ReaderInputStream(
+ worker, newWriter(SparkEnv.get, worker, Iterator.empty, 0, null),
None, 0, false, null)
+ }
+ val runner = new TestRunner
+
+ 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 input = runner.newReaderInputStream(worker)
+ @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))
+ // 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.
+ // The interrupt then surfaces as the channel's
ClosedByInterruptException.
+ assert(writeError === null)
+ assert(error.isInstanceOf[ClosedByInterruptException], error)
+ } finally {
Review Comment:
**`resume.set(true)` is reached only on the happy path, and nothing in this
`finally` releases the spin at L260.** That loop has no deadline and ignores
interrupts. If `inWrite.await` at L303 ever times out after the writer has
already passed the selector checks, the writer spins a core until the forked
test JVM exits (`worker.stop()` only stops a writer that hasn't reached them
yet). It is unlikely and only happens on a run that has already failed, but
`resume.set(true)` as the first statement of this `finally` (it is idempotent)
would make the writer always terminate.
##########
core/src/main/scala/org/apache/spark/api/python/PythonRunner.scala:
##########
@@ -1059,6 +1059,11 @@ 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 by `bufferStream.close()` below. See
`CloseShieldOutputStream`.
+ private[this] val writerStream = new CloseShieldOutputStream(bufferStream)
Review Comment:
**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.
--
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]