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


##########
sql/core/src/main/scala/org/apache/spark/sql/execution/python/PythonArrowOutput.scala:
##########
@@ -242,9 +249,10 @@ abstract class BaseSliceArrowOutputProcessor(
     batch
   }
 
+  // The slices of a batch are closed once the next one is produced, so they 
are reused vectors.

Review Comment:
   **This comment covers only the slices, but `getVectors` also wraps the 
reader's own `root`, and `produceBatch` closes that root itself.** `getRoot` 
returns the unsliced `root` for a batch that fits (L275-277) and for the last 
chunk of every batch in `SliceRecordsArrowOutputProcessorImpl` (L327-328). That 
is the case where the no-op `closeIfFreeable` from L255 matters, since closing 
the struct vector of the root removes its children for every later batch. 
Separately, and pre-existing since SPARK-51769: when the previous chunk was 
`root` and the next `getRoot` returns a slice, L236-241 close `prevVectors` and 
`prevRoot`, i.e. the reader's root that holds the batch being sliced. With 
`spark.sql.execution.arrow.maxRecordsPerOutputBatch=3` and a `mapInArrow` 
partition whose Python output is two 10-row batches, the next `root.slice(3, 
3)` of the second batch would throw `IllegalArgumentException` (`valueCount` 
0). With `maxBytesPerOutputBatch`, a small batch followed by a large one woul
 d make `getRoot` return the cleared root with its old row count. 
`ReusedArrowColumnVector` cannot prevent this, because L238 calls `close()`. 
Could the comment say that these vectors are either the reader's root, into 
which the next batch is loaded, or a slice of it that is closed once the next 
one is produced? And could `produceBatch` close `prevRoot` and `prevVectors` 
only when `prevRoot ne root`, here or in a separate JIRA?



##########
sql/core/src/test/scala/org/apache/spark/sql/execution/python/ArrowColumnarPythonUDFSuite.scala:
##########
@@ -114,6 +114,71 @@ class ArrowColumnarPythonUDFSuite extends 
SharedSparkSession {
     }
   }
 
+  test("ColumnarToRow releases each batch once its rows are consumed") {
+    Seq(true, false).foreach { codegen =>
+      withSQLConf(SQLConf.WHOLESTAGE_CODEGEN_ENABLED.key -> codegen.toString) {
+        val numRows = 50000
+        val numBatches = 500
+        val df = readArrowSource(numRows = numRows, batchSize = numRows / 
numBatches)
+        val plan = df.queryExecution.executedPlan
+        assert(collectNodes[ColumnarToRowExec](plan).nonEmpty)
+        assert(collectNodes[WholeStageCodegenExec](plan).nonEmpty == codegen)
+
+        ArrowBackedDataSourceV2.resetBatchCounters()
+        val rows = df.collect()
+        assert(rows.map(_.getInt(0)).toSeq == (0 until numRows))
+        assert(rows.forall(row => row.getString(1) == s"row_${row.getInt(0)}"))
+        // A batch is released before the next one is read.
+        assert(ArrowBackedDataSourceV2.returnedBatches.get == numBatches)
+        assert(ArrowBackedDataSourceV2.releasedBatches.get == numBatches)
+        assert(ArrowBackedDataSourceV2.maxUnreleasedBatches.get == 1)
+      }
+    }
+  }
+
+  test("Arrow Python UDF over many batches releases each input batch") {
+    assume(shouldTestPandasUDFs)
+    withSQLConf(
+        SQLConf.ARROW_PYSPARK_EXECUTION_ENABLED.key -> "true",
+        SQLConf.ARROW_PYSPARK_UDF_COLUMNAR_INPUT_ENABLED.key -> "true") {
+      val pandasUDF = TestScalarPandasUDF(
+        name = "arrow_release_udf", returnType = Some(StringType))
+      registerTestUDF(pandasUDF, spark)
+
+      val numRows = 50000
+      val numBatches = 500
+      // On a string column, the cast of the test UDF is removed, so the 
batches take the Arrow
+      // columnar path, whose output batches carry the input columns to the 
row conversion of
+      // ArrowEvalPythonExec, which releases them. On an expression, they take 
the row fallback
+      // path, which releases each batch once it has been written to Python.
+      Seq("name" -> "", "concat(name, '_')" -> "_").foreach { case (input, 
suffix) =>
+        val result = readArrowSource(numRows = numRows, batchSize = numRows / 
numBatches)
+          .selectExpr("id", "name", "value", "data", 
s"arrow_release_udf($input) as udf_out")
+        val arrowExec = 
collectNodes[ArrowEvalPythonExec](result.queryExecution.executedPlan)
+        assert(arrowExec.nonEmpty && arrowExec.head.supportsColumnar)

Review Comment:
   Minor: the `name` iteration relies on the comment at L150-153, not on an 
assertion, to take the Arrow columnar path. Today `SimplifyCasts` removes the 
`Cast(name, StringType)` that `TestScalarPandasUDF` adds 
(IntegratedUDFTestUtils.scala:1403), so this iteration does cover the release 
in `ArrowEvalPythonExec.doExecute`. But L158 holds on every path, the counters 
at L173-174 also reach 500 on the row fallback path, and `maxUnreleasedBatches` 
is checked only for the expression (L175-177). If the cast were kept one day, 
both iterations would take the row fallback path and the test would still pass. 
Could L158 also check the path, e.g. 
`assert(arrowExec.head.udfs.forall(_.children.forall(_.isInstanceOf[AttributeReference]))
 == suffix.isEmpty)`?



##########
sql/core/src/main/scala/org/apache/spark/sql/execution/python/PythonArrowOutput.scala:
##########
@@ -242,9 +249,10 @@ abstract class BaseSliceArrowOutputProcessor(
     batch

Review Comment:
   **Pre-existing since SPARK-51769: the batch returned here repeats rows at 
the end of every Python batch that `SliceRecordsArrowOutputProcessorImpl` 
slices.** Once `remainingRows <= arrowMaxRecordsPerOutputBatch`, its `getRoot` 
(L323-334) returns the whole `root` instead of the remaining rows, and L248 
sets `numRows` to the full row count. With `maxRecordsPerOutputBatch=3`, a 
10-row batch comes out as rows 0-2, 3-5 and 6-8, then 0-9 again: 19 rows 
instead of 10. `test_different_output_length` in `test_arrow_map.py`, run by 
`MapInArrowWithOutputArrowBatchSlicingRecordsTests`, would get 199 rows for 
100, but it compares sets, so it passes. The loop in 
`ArrowOutputProcessorSuite` with `new 
SliceRecordsArrowOutputProcessorImpl(reader, metrics(), 2)` would show it as 
rows 0, 1, 0, 1, 2 for the first batch. Could `getRoot` return 
`root.slice(currentRowIdx, remainingRows)` once `currentRowIdx > 0`, here or in 
a separate JIRA?



##########
sql/core/src/main/scala/org/apache/spark/sql/execution/python/ArrowEvalPythonExec.scala:
##########
@@ -113,10 +113,11 @@ case class ArrowEvalPythonExec(
       // Columnar path: delegate to doExecuteColumnar, flatten to

Review Comment:
   **Pre-existing since SPARK-60294, but in the branch this PR edits: L112 
chooses between the columnar and row paths at execution time, from the live 
session conf.** `supportsColumnar` (L104-105) reads 
`conf.arrowPySparkUDFColumnarInputEnabled`, and `SparkPlan.conf` is 
`session.sessionState.conf` (SparkPlan.scala:70-76), while the transitions were 
inserted with the value at planning time. A Dataset reuses its `executedPlan` 
across actions (Dataset.scala:2334-2339). So enable 
`spark.sql.execution.arrow.pythonUDF.columnarInput.enabled`, `collect()` a 
pandas UDF over an Arrow-backed DSv2 scan, disable the conf, and `collect()` 
the same Dataset again: the plan still has `ArrowEvalPythonExec` directly over 
the columnar-only `BatchScanExec`, but L112 is now false, so 
`super.doExecute()` calls `child.execute().map(_.copy())` 
(EvalPythonExec.scala:73). `BatchScanExec.doExecute` 
(DataSourceV2ScanExecBase.scala:210-216) then hands the `ColumnarBatch`es of 
its columnar `DataSourceRDD` over as 
 rows, which should end in a `ClassCastException`. A `.cache()`d plan that is 
materialized after the conf changes takes the same route. Before SPARK-60294, 
this check was the plan-time `child.supportsColumnar`. Could the choice be 
fixed when the plan is built, for example with a flag set at planning time, or 
through the planner as in my comment on L120?



##########
sql/core/src/test/scala/org/apache/spark/sql/execution/python/ArrowOutputProcessorSuite.scala:
##########
@@ -0,0 +1,103 @@
+/*
+ * 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.sql.execution.python
+
+import java.io.{ByteArrayInputStream, ByteArrayOutputStream}
+
+import scala.collection.mutable.ArrayBuffer
+
+import org.apache.arrow.vector.VectorSchemaRoot
+import org.apache.arrow.vector.ipc.{ArrowStreamReader, ArrowStreamWriter}
+
+import org.apache.spark.SparkFunSuite
+import org.apache.spark.sql.catalyst.InternalRow
+import org.apache.spark.sql.execution.arrow.ArrowWriter
+import org.apache.spark.sql.execution.metric.SQLMetric
+import org.apache.spark.sql.execution.vectorized.ReusedColumnVector
+import org.apache.spark.sql.types.{IntegerType, StringType, StructType}
+import org.apache.spark.sql.util.ArrowUtils
+import org.apache.spark.unsafe.types.UTF8String
+
+class ArrowOutputProcessorSuite extends SparkFunSuite {
+
+  private val schema = new StructType()
+    .add("s", new StructType().add("a", IntegerType).add("b", StringType))
+  private val numBatches = 3
+  private val rowsPerBatch = 3
+
+  /** Writes `numBatches` batches of a struct column as an Arrow stream, as a 
Python worker does. */
+  private def arrowStream(): Array[Byte] = {
+    val allocator =
+      ArrowUtils.rootAllocator.newChildAllocator("arrow-output-writer", 0, 
Long.MaxValue)
+    val root = VectorSchemaRoot.create(
+      ArrowUtils.toArrowSchema(schema, null, errorOnDuplicatedFieldNames = 
true,
+        largeVarTypes = false),
+      allocator)
+    try {
+      val out = new ByteArrayOutputStream()
+      val writer = new ArrowStreamWriter(root, null, out)
+      val arrowWriter = ArrowWriter.create(root)
+      writer.start()
+      (0 until numBatches * rowsPerBatch).grouped(rowsPerBatch).foreach { 
values =>
+        values.foreach { v =>
+          arrowWriter.write(InternalRow(InternalRow(v, 
UTF8String.fromString(s"v$v"))))
+        }
+        arrowWriter.finish()
+        writer.writeBatch()
+        arrowWriter.reset()
+      }
+      writer.end()
+      out.toByteArray
+    } finally {
+      root.close()
+      allocator.close()
+    }
+  }
+
+  test("Struct output columns stay valid after a consumer releases each 
batch") {
+    // The output processor loads every batch into the same vectors. A 
consumer releases each
+    // batch with closeIfFreeable once it is done with it, which must leave 
the vectors, including
+    // the child columns of a struct, to the processor.
+    val allocator =
+      ArrowUtils.rootAllocator.newChildAllocator("arrow-output-reader", 0, 
Long.MaxValue)
+    val reader = new ArrowStreamReader(new 
ByteArrayInputStream(arrowStream()), allocator)
+    val processor = new ArrowOutputProcessorImpl(reader, metrics())

Review Comment:
   **Only `ArrowOutputProcessorImpl` is tested here, so the change to 
`BaseSliceArrowOutputProcessor.getVectors` (PythonArrowOutput.scala:255) has no 
test.** The slicing tests in `test_arrow_map.py` go through `mapInArrow`, which 
never releases its output batches, so nothing would fail if 
PythonArrowOutput.scala:255 went back to `ArrowColumnVector`. That line matters 
on the Arrow columnar path when output slicing is enabled but no batch is 
split, the only case the `passThruRows == numRows` assertion 
(ColumnarArrowEvalPythonEvaluatorFactory.scala:223-225) allows: `getRoot` then 
returns the reader's root, which `ColumnarToRowIterator` in 
`ArrowEvalPythonExec.doExecute` releases. The struct-returning UDF of the 
earlier test request 
(https://github.com/apache/spark/pull/59581#discussion_r4309307580) is also 
still open: `TestScalarPandasUDF` always returns strings, so the new many-batch 
UDF test cannot notice cleared struct children. Could this test also run the 
loop with `new SliceRecord
 sArrowOutputProcessorImpl(reader, metrics(), rowsPerBatch)` and `new 
SliceBytesArrowOutputProcessorImpl(reader, metrics(), Int.MaxValue)`, i.e. with 
limits that never split a batch? The whole-stage codegen on/off part of that 
request makes no difference here: `ArrowEvalPythonExec.supportsRowBased` is 
true, so both modes go through `ColumnarToRowIterator`.



##########
sql/core/src/main/java/org/apache/spark/sql/execution/vectorized/ReusedColumnVector.java:
##########
@@ -0,0 +1,37 @@
+/*
+ * 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.sql.execution.vectorized;
+
+/**
+ * A marker for a {@link org.apache.spark.sql.vectorized.ColumnVector} that 
its producer may
+ * release or overwrite after returning it in a batch: for example, a reader 
that refills the same
+ * vectors for every batch, or frees the vectors of a batch once the next one 
is read or once it is
+ * closed. A consumer may only read such a vector until it asks the producer 
for the next batch,
+ * and must copy what it needs past that point.
+ *
+ * <p>There is no other agreement between the producer and the consumer of a 
batch, so the type of
+ * its vectors is the protocol, as for {@code ColumnVector.closeIfFreeable}: 
the producer owns the
+ * lifetime of a vector carrying this marker, and its {@code closeIfFreeable} 
is a no-op. A vector
+ * without it may be held by the consumer past the next batch without a copy, 
and the consumer

Review Comment:
   **The Arrow cache scan frees the vectors of a batch once the next one is 
read, as L22 describes, but its vectors are not marked, so L28-30 let a 
consumer hold them past the next batch.** 
`ArrowCachedBatchToColumnarBatchIterator.next()` closes `previousRoot` 
(ArrowCachedBatchSerializer.scala:1332-1337), while it wraps the vectors in 
plain `ArrowColumnVector`s (ArrowCachedBatchSerializer.scala:1356 and 1359). In 
#59551 you listed this iterator next to `ArrowOutputProcessorImpl` and wrote 
that it "should only close a root once none of its vectors is still in use, or 
mark its vectors as reused", but only `ArrowOutputProcessorImpl` is marked 
here. Once the follow-up holds unmarked inputs on the Arrow columnar path, the 
Arrow cache case from #59551 comes back: with 
`spark.sql.cache.serializer=org.apache.spark.sql.execution.columnar.ArrowCachedBatchSerializer`
 and columnar input enabled, a pandas UDF over `spark.range(0, 2000000, 1, 
1).cache()` takes that path, and `next()` for batch N+1
  closes the root of batch N while its pass-through columns are still held. 
Could you wrap the cache vectors in `ReusedArrowColumnVector` in this PR too? 
`vectorTypes` (ArrowCachedBatchSerializer.scala:141), the `isArrow` check and 
the zero-copy re-cache path all still accept it. The iterator would then also 
need to close `previousRoot` once it is exhausted, since a consumer's 
`closeIfFreeable` would no longer free the last root.



##########
sql/core/src/test/scala/org/apache/spark/sql/execution/python/ArrowColumnarPythonUDFSuite.scala:
##########
@@ -114,6 +114,71 @@ class ArrowColumnarPythonUDFSuite extends 
SharedSparkSession {
     }
   }
 
+  test("ColumnarToRow releases each batch once its rows are consumed") {
+    Seq(true, false).foreach { codegen =>
+      withSQLConf(SQLConf.WHOLESTAGE_CODEGEN_ENABLED.key -> codegen.toString) {
+        val numRows = 50000

Review Comment:
   Minor: the two new tests read 50,000 rows in 500 batches, each of them 
twice. In the CI run for 2b49870 they took 759 ms and 1.852 s, about half of 
the suite's 5.07 s, and the UDF test makes 1,000 sequential round trips to 
Python. Their assertions (`returnedBatches == releasedBatches == numBatches`, 
`maxUnreleasedBatches == 1`) only need two or more batches. Could this test use 
2,000 rows in 20 batches, and the UDF test 20,000 rows in 20 batches? The 
latter still gives two output batches on the row fallback path 
(`arrowMaxRecordsPerBatch` is 10,000 by default), so the release and refill of 
its reused output batch is still exercised.



##########
sql/core/src/test/scala/org/apache/spark/sql/execution/python/ArrowOutputProcessorSuite.scala:
##########
@@ -0,0 +1,103 @@
+/*
+ * 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.sql.execution.python
+
+import java.io.{ByteArrayInputStream, ByteArrayOutputStream}
+
+import scala.collection.mutable.ArrayBuffer
+
+import org.apache.arrow.vector.VectorSchemaRoot
+import org.apache.arrow.vector.ipc.{ArrowStreamReader, ArrowStreamWriter}
+
+import org.apache.spark.SparkFunSuite
+import org.apache.spark.sql.catalyst.InternalRow
+import org.apache.spark.sql.execution.arrow.ArrowWriter
+import org.apache.spark.sql.execution.metric.SQLMetric
+import org.apache.spark.sql.execution.vectorized.ReusedColumnVector
+import org.apache.spark.sql.types.{IntegerType, StringType, StructType}
+import org.apache.spark.sql.util.ArrowUtils
+import org.apache.spark.unsafe.types.UTF8String
+
+class ArrowOutputProcessorSuite extends SparkFunSuite {

Review Comment:
   Minor: the PR description does not mention this suite or the new UDF test. 
"How was this patch tested?" still describes only `ColumnarToRow releases each 
batch once its rows are consumed`, and the "Ran ..." list does not include 
`ArrowOutputProcessorSuite`. The template asks "If tests were added, say they 
were added here.", and `dev/merge_spark_pr.py` copies the description into the 
commit message. Also, "The marker is checked on the top-level columns of a 
batch" describes a check that no code performs yet. Could you update the 
description?



##########
sql/core/src/test/scala/org/apache/spark/sql/execution/python/ArrowBackedDataSourceV2.scala:
##########
@@ -246,14 +266,44 @@ private class ArrowBackedPartitionReader(start: Int, end: 
Int, batchSize: Int)
   // Single allocator for the entire partition reader lifetime.
   // Arrow vectors from earlier batches may still be referenced by
   // downstream operators (e.g., pass-through column queue), so we
-  // must NOT close the allocator until the reader itself is closed.
+  // must NOT close the allocator until the task completes.
   private val allocator: BufferAllocator =
     ArrowUtils.rootAllocator.newChildAllocator(
       s"arrow-test-reader-$start", 0, Long.MaxValue)
 
-  // Track all batches so we can close them in close().
+  // Track all batches so we can close them when the task completes.
   private val allBatches =
     new java.util.ArrayList[ColumnarBatch]()
+  private var numReleased = 0
+  @volatile private var taskCompleted = false
+
+  TaskContext.get().addTaskCompletionListener[Unit] { _ =>

Review Comment:
   Minor: registering the cleanup listener in the constructor can make it run 
before the pipelined Python writer stops. `DataSourceRDD` creates the reader on 
the first `hasNext` (DataSourceRDD.scala:188-195), and on the row path 
(columnar input off, the default) nothing peeks first, so with 
`spark.python.udf.pipelined.enabled=true` the writer thread creates this reader 
after `createPipelinedDataIn` registered its writer-wait listener 
(PythonRunner.scala:553-561). Listeners run in LIFO order, so on a task kill, 
or an early stop that is not pushed below the UDF, L280-287 free every batch 
and the allocator while the writer may still be reading the current batch. That 
is the SPARK-33277 hazard the writer-wait listener exists for. Before this PR, 
`close()` freed them, either on the consuming thread or from the listener that 
`DataSourceRDD` registers before the runner. No test runs this source in 
pipelined mode today. Could `close()` register this listener instead of the 
constructor?



##########
sql/core/src/test/scala/org/apache/spark/sql/execution/python/ArrowBackedDataSourceV2.scala:
##########
@@ -232,10 +247,15 @@ private class ReusingArrowPartitionReader(start: Int, 
end: Int, batchSize: Int)
 /**
  * Partition reader that produces Arrow-backed [[ColumnarBatch]]es.
  *
- * Each batch contains up to [[ArrowBackedDataSourceV2.BATCH_SIZE]] rows with:
+ * Each batch contains up to `batchSize` rows with:

Review Comment:
   Nit: this list still has only `id`, `name` and `value`, while `next()` also 
builds a `data` column of 100 random characters (L317-318, L404), which the new 
UDF test checks (ArrowColumnarPythonUDFSuite.scala:167). Could you add `- data 
(STRING): 100 random characters`?



##########
sql/core/src/main/scala/org/apache/spark/sql/execution/python/ArrowEvalPythonExec.scala:
##########
@@ -113,10 +113,11 @@ case class ArrowEvalPythonExec(
       // Columnar path: delegate to doExecuteColumnar, flatten to
       // UnsafeRow. ColumnarBatchRow from rowIterator() is NOT
       // UnsafeRow, and downstream operators (e.g., outer
-      // EvalPythonExec) may cast to UnsafeRow.
+      // EvalPythonExec) may cast to UnsafeRow. Like ColumnarToRowExec,
+      // this releases each batch once its rows are consumed: the
+      // batches of the Arrow columnar path carry the input columns.
       doExecuteColumnar().mapPartitionsInternal { batchIter =>
-        val toUnsafe = UnsafeProjection.create(schema)
-        batchIter.flatMap(_.rowIterator().asScala.map(toUnsafe))
+        new ColumnarToRowIterator(batchIter, UnsafeProjection.create(schema))

Review Comment:
   Minor: this private flatten, and now the release in it, are needed only 
because L106 declares `supportsRowBased = true`. With the default 
(`!supportsColumnar`), `ApplyColumnarRulesAndInsertTransitions` would put a 
`ColumnarToRowExec` above this node (Columnar.scala:581-584) and still keep the 
scan-to-UDF edge columnar (Columnar.scala:569-570). With columnar input off, 
the plan would not change. That `ColumnarToRowExec` already releases batches in 
both codegen and non-codegen modes, is fused into the whole-stage codegen of 
the parent, and reports `numInputBatches` and `numOutputRows`. `doExecute` 
would then need only the row path, and the choice would be fixed at planning 
time (see my comment on L113). SPARK-56350 declared `supportsRowBased = true` 
"so Spark's transition rules no longer insert ColumnarToRowExec when the child 
supports columnar output", which `supportsColumnar` alone already achieves. The 
trade-offs are that EXPLAIN would show a ColumnarToRow above ArrowEvalPython w
 hen columnar input is enabled, and that the codegen limit cleanup 
(Columnar.scala:201-204) calls `close()`, not `closeIfFreeable()`, on the 
Python result vectors. Could this be considered, here or in a follow-up?



##########
sql/core/src/main/java/org/apache/spark/sql/execution/vectorized/ReusedColumnVector.java:
##########
@@ -0,0 +1,37 @@
+/*
+ * 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.sql.execution.vectorized;
+
+/**
+ * A marker for a {@link org.apache.spark.sql.vectorized.ColumnVector} that 
its producer may
+ * release or overwrite after returning it in a batch: for example, a reader 
that refills the same
+ * vectors for every batch, or frees the vectors of a batch once the next one 
is read or once it is
+ * closed. A consumer may only read such a vector until it asks the producer 
for the next batch,
+ * and must copy what it needs past that point.
+ *
+ * <p>There is no other agreement between the producer and the consumer of a 
batch, so the type of
+ * its vectors is the protocol, as for {@code ColumnVector.closeIfFreeable}: 
the producer owns the
+ * lifetime of a vector carrying this marker, and its {@code closeIfFreeable} 
is a no-op. A vector
+ * without it may be held by the consumer past the next batch without a copy, 
and the consumer
+ * releases it with {@code closeIfFreeable} once done with it.
+ *
+ * <p>The marker covers the whole vector, so it is only checked on the 
top-level columns of a
+ * batch. The child vectors of a struct, array or map column belong to that 
column and share its
+ * lifetime.
+ */
+public interface ReusedColumnVector {

Review Comment:
   Minor: nothing in the main code reads this interface yet, and both fixes 
here only need the no-op `closeIfFreeable` of `ReusedArrowColumnVector`. Its 
only reader is the test assertion at ArrowOutputProcessorSuite.scala:85, so 
removing `implements ReusedColumnVector` from the three classes changes no 
behavior. My comment on L29 shows why the classification is easier to review 
next to the code that relies on it, and in #59551 you planned to add the marker 
together with closing the gaps in path 1. This is about where the marker lands, 
not about the default for unmarked vectors raised in the earlier review. Could 
the interface and the `implements` clauses move to that follow-up, with the 
test asserting `isInstanceOf[ReusedArrowColumnVector]` here?



##########
sql/core/src/test/scala/org/apache/spark/sql/execution/python/ArrowColumnarPythonUDFSuite.scala:
##########
@@ -114,6 +114,71 @@ class ArrowColumnarPythonUDFSuite extends 
SharedSparkSession {
     }
   }
 
+  test("ColumnarToRow releases each batch once its rows are consumed") {
+    Seq(true, false).foreach { codegen =>
+      withSQLConf(SQLConf.WHOLESTAGE_CODEGEN_ENABLED.key -> codegen.toString) {
+        val numRows = 50000
+        val numBatches = 500
+        val df = readArrowSource(numRows = numRows, batchSize = numRows / 
numBatches)
+        val plan = df.queryExecution.executedPlan
+        assert(collectNodes[ColumnarToRowExec](plan).nonEmpty)
+        assert(collectNodes[WholeStageCodegenExec](plan).nonEmpty == codegen)
+
+        ArrowBackedDataSourceV2.resetBatchCounters()
+        val rows = df.collect()
+        assert(rows.map(_.getInt(0)).toSeq == (0 until numRows))
+        assert(rows.forall(row => row.getString(1) == s"row_${row.getInt(0)}"))
+        // A batch is released before the next one is read.
+        assert(ArrowBackedDataSourceV2.returnedBatches.get == numBatches)
+        assert(ArrowBackedDataSourceV2.releasedBatches.get == numBatches)
+        assert(ArrowBackedDataSourceV2.maxUnreleasedBatches.get == 1)
+      }
+    }
+  }
+
+  test("Arrow Python UDF over many batches releases each input batch") {
+    assume(shouldTestPandasUDFs)
+    withSQLConf(
+        SQLConf.ARROW_PYSPARK_EXECUTION_ENABLED.key -> "true",
+        SQLConf.ARROW_PYSPARK_UDF_COLUMNAR_INPUT_ENABLED.key -> "true") {

Review Comment:
   Nit: `sparkConf` (L38-40) already enables 
`ARROW_PYSPARK_UDF_COLUMNAR_INPUT_ENABLED` for every test in this suite, and 
the other columnar tests set only `ARROW_PYSPARK_EXECUTION_ENABLED`. Could this 
be `withSQLConf(SQLConf.ARROW_PYSPARK_EXECUTION_ENABLED.key -> "true")`?



##########
sql/core/src/main/scala/org/apache/spark/sql/execution/ColumnarEvaluatorFactory.scala:
##########
@@ -44,12 +42,52 @@ class ColumnarToRowEvaluatorFactory(
         inputs: Iterator[ColumnarBatch]*): Iterator[InternalRow] = {
       assert(inputs.length == 1)
       val toUnsafe = UnsafeProjection.create(childOutput, childOutput)
-      inputs.head.flatMap { input =>
+      new ColumnarToRowIterator(inputs.head, toUnsafe, { batch =>
         numInputBatches += 1
-        numOutputRows += input.numRows()
-        input.rowIterator().asScala.map(toUnsafe)
+        numOutputRows += batch.numRows()
+      })
+    }
+  }
+}
+
+/**
+ * Flattens columnar batches into `UnsafeRow`s. A batch is released with 
closeIfFreeable once all
+ * its rows have been returned, before the next batch is fetched, as the 
generated code of
+ * [[ColumnarToRowExec]] does in [[ColumnarToRowExec.advanceBatch]]. The rows 
are projected into a
+ * buffer of their own, so the last row of a batch stays valid after the batch 
is released.
+ *
+ * @param onNextBatch called on each batch once it is fetched
+ */
+private[sql] class ColumnarToRowIterator(

Review Comment:
   Minor: the existing `flatMap` with Spark's `CompletionIterator` gives the 
same release order without a new class. Scala 2.13's `Iterator.flatMap` calls 
`hasNext` of the inner iterator, which runs the completion, before 
`hasNext`/`next()` of the outer one, so each batch is released after its last 
row and before the next one is fetched, as `ColumnarToRowExec.advanceBatch` 
does:
   ```scala
   inputs.head.flatMap { input =>
     numInputBatches += 1
     numOutputRows += input.numRows()
     CompletionIterator[InternalRow, Iterator[InternalRow]](
       input.rowIterator().asScala.map(toUnsafe), input.closeIfFreeable())
   }
   ```
   `ArrowEvalPythonExec.doExecute` can do the same, and 
`FlatMapGroupsInPandasWithStateExec` already uses this form. That removes this 
class, its `NoSuchElementException` path and the `onNextBatch` default that 
only one caller uses. Could you use it instead?



##########
sql/core/src/test/scala/org/apache/spark/sql/execution/python/ArrowBackedDataSourceV2.scala:
##########
@@ -246,14 +266,44 @@ private class ArrowBackedPartitionReader(start: Int, end: 
Int, batchSize: Int)
   // Single allocator for the entire partition reader lifetime.
   // Arrow vectors from earlier batches may still be referenced by
   // downstream operators (e.g., pass-through column queue), so we
-  // must NOT close the allocator until the reader itself is closed.
+  // must NOT close the allocator until the task completes.
   private val allocator: BufferAllocator =
     ArrowUtils.rootAllocator.newChildAllocator(
       s"arrow-test-reader-$start", 0, Long.MaxValue)
 
-  // Track all batches so we can close them in close().
+  // Track all batches so we can close them when the task completes.
   private val allBatches =
     new java.util.ArrayList[ColumnarBatch]()
+  private var numReleased = 0
+  @volatile private var taskCompleted = false
+
+  TaskContext.get().addTaskCompletionListener[Unit] { _ =>
+    taskCompleted = true
+    allBatches.synchronized {
+      allBatches.forEach(_.close())
+      allBatches.clear()
+    }
+    allocator.close()
+  }
+
+  /** The first column of a batch, which counts the release of the batch. */
+  private class ReleaseCountingArrowColumnVector(vector: ValueVector)
+      extends ArrowColumnVector(vector) {
+    private var closed = false
+
+    override def close(): Unit = {

Review Comment:
   Nit: counting in `close()` needs both `closed` and `taskCompleted` to skip 
the closes of the completion listener. Every consumer under test releases 
through `closeIfFreeable`, so counting there counts exactly what L83-84 
describe, and both flags can go:
   ```scala
   override def closeIfFreeable(): Unit = {
     allBatches.synchronized {
       numReleased += 1
       ArrowBackedDataSourceV2.releasedBatches.incrementAndGet()
     }
     super.closeIfFreeable()
   }
   ```
   A double release would then show up as `releasedBatches > numBatches` 
instead of being hidden by `closed`. Could you count it this way?



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