spark-test commented on code in PR #59961:
URL: https://github.com/apache/spark/pull/59961#discussion_r4488995108


##########
sql/core/src/test/scala/org/apache/spark/sql/execution/datasources/v2/ffi/NativeDataSourceSuite.scala:
##########
@@ -2416,14 +2535,110 @@ class NativeDataSourceSuite extends QueryTest with 
SharedSparkSession {
       }
     }
     try {
-      assert(closeWhileReading(failing = false) == Right(false))
+      checkClosed(closeWhileReading(failing = false).swap.getOrElse(fail("next 
did not fail")))
       val error = closeWhileReading(failing = true).swap.getOrElse(fail("next 
did not fail"))
       assert(error.getMessage.contains("injected failure after a slow 
get_next"))
     } finally {
       pool.shutdown()
     }
   }
 
+  nativeTest("close a reader while another thread reads the batch that it 
returned") {
+    val session = newSession(defaultPackage)
+    val pool = ThreadUtils.newDaemonSingleThreadExecutor("native-reader")
+    def onPool[T](f: => T): T = pool.submit(new Callable[T] {
+      override def call(): T = f
+    }).get(1, TimeUnit.MINUTES)
+    // The contexts of the tasks of earlier tests are closed first, so that 
the counts below are
+    // exactly those of this test.
+    eventually(timeout(1.minute))(assert(NativeLibrary.openTaskContexts() == 
0))
+    // Closes a reader on this thread once the other thread read a batch, and 
returns the allocator
+    // of the reader, which is no longer referenced then. With `nextAgain`, 
the other thread then
+    // calls next, which tells that it no longer reads the batch.
+    def closeWhileRead(nextAgain: Boolean): BufferAllocator = {
+      val marker = new File(Utils.createTempDir(), "released")
+      // get_next does not wait: the file .continue exists.
+      new File(marker.getPath + ".continue").createNewFile()
+      val (reader, allocator) = readerAndAllocator(
+        range(session, "end" -> "10", "partitions" -> "1", "slow_read" -> 
marker.getPath)
+          .select("id"))
+      try {
+        assert(onPool(reader.next()))
+        assert(NativeLibrary.openTaskContexts() == 1)
+        reader.close()
+        // The stream and the task are released at once, but not the buffers 
of the batch, which
+        // the other thread may still read.
+        assert(Files.readString(marker.toPath) == "after get_next")
+        assert(NativeLibrary.openTaskContexts() == 0)
+        assert(readerAllocators().contains(allocator) && 
allocator.getAllocatedMemory > 0)
+        val rows = onPool((0 until 
reader.get().numRows()).map(reader.get().column(0).getLong))

Review Comment:
   **Nit (P3):** This read goes through `reader.get()`, and the test keeps 
`reader` in a local until `closeWhileRead` returns, so the reader stays 
strongly reachable while the other thread reads. What keeps a closed reader's 
buffers allocated for a consumer that holds only the batch is the `reader` 
field of `NativeColumnVector`, which no code reads. Deleting `column.reader = 
reader;` in `NativeColumnVector.create`, as a cleanup of an unused field might, 
still passes every test: `NativeColumnVectorSuite` passes a null reader, and 
`checkReadersFreed` only waits for frees. In production, 
`PartitionIterator.close()` drops `DataSourceRDD`'s reference to the reader, so 
script transformation's writer holds only the batch, and without the field a 
garbage collection would run the Cleaner and free the buffers that the writer 
still reads at their addresses.
   
   **Verification:**
   - **Regression:** With the column vectors' reference to their reader 
removed, a garbage collection frees the allocator of a closed reader whose 
batch is still held, and the test fails before it reads the batch.
   - **Behavior:** With the reference in place, a closed reader's batch held 
without the reader keeps its allocator across garbage collections and reads its 
values, and the allocator is freed once the batch is dropped.



##########
sql/core/src/main/scala/org/apache/spark/sql/execution/python/ColumnarArrowEvalPythonEvaluatorFactory.scala:
##########
@@ -155,11 +157,15 @@ private[python] class 
ColumnarArrowEvalPythonEvaluatorFactory(
 
       val inputColumnIndices = resolveColumnIndices(allInputs.toSeq)
 
-      // Peek at first batch to check if Arrow-backed.
+      // Peek at first batch to check if Arrow-backed. Path 1 holds the 
pass-through columns past
+      // the next batch, which a ReusedArrowColumnVector does not allow: its 
producer overwrites or
+      // releases it, so such batches take path 2, which copies them.
       val peekIter = new PeekableIterator(inputIter)
       val isArrow = peekIter.peek().exists { batch =>
         batch.numCols() > 0 &&
-          batch.column(0).isInstanceOf[ArrowColumnVector]
+          batch.column(0).isInstanceOf[ArrowColumnVector] &&
+          !(0 until batch.numCols()).exists(

Review Comment:
   **Nit (P3):** This checks every column, but the only test that reaches it, 
'Arrow UDFs on the columns of a native data source', reads batches whose 
columns are all `NativeColumnVector`s, so narrowing it back to 
`batch.column(0)` passes every test. The case that tells the two apart is a 
batch that mixes plain and reused Arrow columns. Path 1 builds its output from 
the input's pass-through columns plus the result columns that 
`ArrowOutputProcessorImpl` wraps as `ReusedArrowColumnVector`s, so an Arrow UDF 
stacked directly on another, as `ExtractPythonUDFs` stacks nested UDFs of 
different eval types, gets the source's plain vectors first and the inner UDF's 
reused result vector last. Under a first-column check the outer node would hold 
that result vector past the next batch and pair its results with the inner 
results of a later batch, with no failure. The Arrow-backed sources of 
`ArrowColumnarPythonUDFSuite` build plain `ArrowColumnVector`s, so no test 
there reaches this check, and th
 e native test is skipped in pull request CI, which runs Java 21.
   
   This extends an [existing 
discussion](https://github.com/apache/spark/pull/59961#discussion_r4488238424).
   
   **Verification:**
   - **Regression:** With the guard narrowed back to the first column, the 
outer UDF keeps the inner UDF's reused result vector past the next batch, so 
over many small batches its output carries the inner results of later batches, 
and the test fails.
   - **Behavior:** Stacked columnar Arrow UDFs of different eval types over an 
Arrow-backed source return correct pass-through and result values over many 
small batches, with both nodes taking columnar input.



##########
sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/v2/ffi/NativeDataSource.scala:
##########
@@ -858,9 +897,21 @@ class NativePartitionReader(
   override def close(): Unit = synchronized {
     if (!closed) {
       closed = true
-      if (!reading) {
+      // Another thread may be in next, or still read the batch that next 
returned to it.
+      if (!reading && consumerDone) {
+        released = true
         closeResources()
+      } else if (!reading) {
+        // That thread may not call next or close again, so the reader is 
released once it ends.
+        val thread = consumer
+        NativePartitionReader.releaser.execute(() => {
+          thread.join()

Review Comment:
   The rework settles the pipelined Python case: close() now releases the 
stream, the task context and the dictionaries at once, and no thread stays 
parked. The script transformation case still holds at this head, though. 
Because the closed reader keeps its last batch readable, the writer goes on 
writing the rest of that batch to the script, and it fails only when it asks 
for the next batch. When nobody reads the script's output any more, as under a 
LIMIT, and the rest of the batch is more than the two pipes hold, the writer 
blocks in the write before that. Nothing destroys the script when the task 
completes, so the writer thread, the script process and the batch's native 
buffers stay alive for good. On the base branch, the writer's next read of the 
batch after the close failed Arrow Java's checks, and the writer destroyed the 
script. Destroying the script when its task completes, as PipedRDD does, would 
end the writer and let the batch be freed.
   
   <!-- SPARK_DEV_REVIEW_REPLY 
{"feedback_id":"inline:4439604925","thread_id":"inline:4439604925","verdict_sha256":"fd564c19e251e7f8c8c414ff2dd2288dbad9d6be08b14411f2b2dfdb9b160e0a"}
 -->



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