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


##########
sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/v2/columnar/ColumnarTable.scala:
##########
@@ -178,30 +204,89 @@ class ColumnarScan(
  * The batch of a scan, which plans it once, when Spark first asks for its 
partitions or its reader
  * factory. A failure is planned once too: the physical plan can ask again, 
for example from a lazy
  * value, and gets the same error.
+ *
+ * @param projection the ordinals of the columns that the scan reads, if the 
partitions return
+ *                   more columns
  */
-class ColumnarBatchScan(planner: ColumnarBatchPlanner) extends Batch {
+class ColumnarBatchScan(planner: ColumnarBatchPlanner, projection: 
Option[Array[Int]] = None)
+  extends Batch {
   private lazy val planned: Try[ColumnarPlannedBatch] = Try(planner.plan())
 
   override def planInputPartitions(): Array[InputPartition] = 
planned.get.partitions
 
-  override def createReaderFactory(): PartitionReaderFactory = 
planned.get.readerFactory
+  override def createReaderFactory(): PartitionReaderFactory = {
+    ColumnarProjection(planned.get.readerFactory, projection)
+  }
 }
 
 case class ColumnarStreamOffset(json: String) extends Offset
 
-class ColumnarMicroBatchStream(reader: ColumnarStreamReader) extends 
MicroBatchStream {
+/**
+ * The micro-batch stream of a [[ColumnarDataSource]]. It supports admission 
control only so that
+ * the reader gets the offset that each micro-batch starts after, the end of 
the last one, for
+ * every trigger: its default read limit reads all the available data. With
+ * `Trigger.AvailableNow`, Spark runs micro-batches up to the first latest 
offset that Spark asks
+ * for, rather than wrapping the stream in one that asks for the latest offset 
from the initial
+ * offset.
+ *
+ * @param checkpointLocation the location of the checkpoint of the source, 
where the stream keeps
+ *                           the initial offset, under 
`ColumnarMicroBatchStream.InitialOffsetLog`
+ */
+class ColumnarMicroBatchStream(reader: ColumnarStreamReader, 
checkpointLocation: String)
+  extends MicroBatchStream with SupportsTriggerAvailableNow {
+
+  // The offset that the reader returned last, which the progress of the query 
reports.
+  @volatile private var lastLatestOffset: Offset = _
+  // Whether the trigger is Trigger.AvailableNow.
+  @volatile private var availableNow = false
+  // The end of the run with Trigger.AvailableNow: the first latest offset 
asked.
+  @volatile private var availableNowEnd: Offset = _
+  // Spark asks for the initial offset for the start of the first latest 
offset, and again to plan
+  // the first micro-batch, which both get the same offset. The reader is only 
asked when the
+  // checkpoint does not have it yet: a query that restarts before its first 
micro-batch is
+  // committed plans it again from the same start, also when the initial 
offset of the reader
+  // depends on when it is asked, such as the latest data.
+  private lazy val initial = {
+    val log = new ColumnarInitialOffsetLog(SparkSession.active,
+      new Path(checkpointLocation, 
ColumnarMicroBatchStream.InitialOffsetLog).toString)
+    ColumnarStreamOffset(log.get(0).getOrElse {
+      val offset = reader.initialOffset()
+      log.add(0, offset)
+      offset
+    })
+  }
 
-  override def initialOffset(): Offset = 
ColumnarStreamOffset(reader.initialOffset())
+  override def initialOffset(): Offset = initial
 
-  override def latestOffset(): Offset = 
ColumnarStreamOffset(reader.latestOffset())
+  override def latestOffset(): Offset = {
+    throw SparkException.internalError(
+      "latestOffset(Offset, ReadLimit) should be called instead of 
latestOffset().")
+  }
+
+  override def latestOffset(start: Offset, limit: ReadLimit): Offset = {

Review Comment:
   This fixes the `Trigger.AvailableNow` end offset at the first `latestOffset` 
call instead of in `prepareForTriggerAvailableNow`, which differs from the 
contract of `SupportsTriggerAvailableNow`.
   
   When an AvailableNow query restarts from a checkpoint whose last batch is 
not committed, `MicroBatchExecution` calls `prepareForTriggerAvailableNow` 
while initializing the sources, re-runs that batch first, and only then calls 
`latestOffset`. So the data that arrives during the re-run is also processed, 
rather than only the data available when the query started.
   
   Could we fix the end offset in `prepareForTriggerAvailableNow`?



##########
sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/v2/ffi/NativeDataSource.scala:
##########
@@ -886,10 +1018,137 @@ class NativePartitionReader(
   }
 }
 
-/** Writes the batches of a task to the data writer of a native library. */
+object NativePartitionReader {
+  import NativeCalls._
+
+  /**
+   * Initializes the stream at `streamAddress` with the data of a partition 
for an engine, see
+   * [[SupportsArrowStreamRead]]: the context of its task is closed once the 
stream is released.
+   * It first checks the arguments of `openArrowStream`, before it looks at 
the partition, which
+   * must be a `P`, the class of the partitions of the scan of the reader 
factory. It then reads
+   * the partition with the state and the partition of the library that 
`stateAndPartition`
+   * returns for it.
+   */
+  private[ffi] def openEngineStream[P <: InputPartition: ClassTag](
+      location: NativeLibraryLocation,
+      name: String,
+      kind: Int,
+      partition: InputPartition,
+      schema: StructType,
+      timeZoneId: String,
+      streamAddress: Long)(
+      stateAndPartition: P => (Array[Byte], NativeInputPartition)): Unit = {
+    val context = TaskContext.get()
+    if (context != null) {
+      // A task that was killed gets the error of its kill, as Spark reports 
it.
+      context.killTaskIfInterrupted()
+    }
+    // A task that failed is over too, also while its listeners run.
+    if (context == null || context.isFailed() || context.isCompleted()) {
+      throw new IllegalStateException("openArrowStream must be called in the 
running task that " +
+        "reads the partition, on a thread that has its TaskContext.")
+    }
+    if (streamAddress == 0 || 
ArrowArrayStream.wrap(streamAddress).snapshot().release != 0) {
+      throw new IllegalArgumentException(
+        "openArrowStream needs the address of a stream whose release callback 
is NULL.")
+    }
+    val (readerState, nativePartition) = partition match {
+      case expected: P => stateAndPartition(expected)
+      case _ =>
+        throw new IllegalArgumentException("openArrowStream needs a partition 
of the scan of " +
+          s"the reader factory, a ${classTag[P].runtimeClass.getSimpleName}, 
but got " +
+          s"${Option(partition).map(_.getClass.getName).orNull}.")
+    }
+    val task = openStream(location, name, kind, readerState, nativePartition, 
schema, timeZoneId,
+      streamAddress)
+    try {
+      // As a failure of read, a failure to create the callbacks of the 
streams of engines, such as
+      // a link error, is an error of the data source.
+      call(name, "read")(NativeLibrary.wrapEngineStream(streamAddress, task, 
name))

Review Comment:
   When the static initialization of `EngineStreamCallbacks` fails, the first 
call throws `ExceptionInInitializerError`, whose `getMessage` is null, so 
`NativeCalls.message` falls back to `e.toString`, i.e. 
`java.lang.ExceptionInInitializerError`. The later calls get 
`NoClassDefFoundError: Could not initialize class ...`. Either way, 
`NATIVE_DATA_SOURCE_ERROR` does not show the root cause, including the `Cannot 
create the callbacks of the streams of engines: <rootCause>` message that 
`NativeLibrary.java` builds for this purpose.
   
   This is also inconsistent with `load()`, which catches a `LinkageError` of 
the FFM initialization and reports its root cause. Could we report the root 
cause here too?



##########
sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/v2/ffi/NativeDataSource.scala:
##########
@@ -886,10 +1018,137 @@ class NativePartitionReader(
   }
 }
 
-/** Writes the batches of a task to the data writer of a native library. */
+object NativePartitionReader {
+  import NativeCalls._
+
+  /**
+   * Initializes the stream at `streamAddress` with the data of a partition 
for an engine, see
+   * [[SupportsArrowStreamRead]]: the context of its task is closed once the 
stream is released.
+   * It first checks the arguments of `openArrowStream`, before it looks at 
the partition, which
+   * must be a `P`, the class of the partitions of the scan of the reader 
factory. It then reads
+   * the partition with the state and the partition of the library that 
`stateAndPartition`
+   * returns for it.
+   */
+  private[ffi] def openEngineStream[P <: InputPartition: ClassTag](
+      location: NativeLibraryLocation,
+      name: String,
+      kind: Int,
+      partition: InputPartition,
+      schema: StructType,
+      timeZoneId: String,
+      streamAddress: Long)(
+      stateAndPartition: P => (Array[Byte], NativeInputPartition)): Unit = {
+    val context = TaskContext.get()
+    if (context != null) {
+      // A task that was killed gets the error of its kill, as Spark reports 
it.
+      context.killTaskIfInterrupted()
+    }
+    // A task that failed is over too, also while its listeners run.
+    if (context == null || context.isFailed() || context.isCompleted()) {
+      throw new IllegalStateException("openArrowStream must be called in the 
running task that " +
+        "reads the partition, on a thread that has its TaskContext.")
+    }
+    if (streamAddress == 0 || 
ArrowArrayStream.wrap(streamAddress).snapshot().release != 0) {
+      throw new IllegalArgumentException(
+        "openArrowStream needs the address of a stream whose release callback 
is NULL.")
+    }
+    val (readerState, nativePartition) = partition match {
+      case expected: P => stateAndPartition(expected)
+      case _ =>
+        throw new IllegalArgumentException("openArrowStream needs a partition 
of the scan of " +
+          s"the reader factory, a ${classTag[P].runtimeClass.getSimpleName}, 
but got " +
+          s"${Option(partition).map(_.getClass.getName).orNull}.")
+    }
+    val task = openStream(location, name, kind, readerState, nativePartition, 
schema, timeZoneId,
+      streamAddress)
+    try {
+      // As a failure of read, a failure to create the callbacks of the 
streams of engines, such as
+      // a link error, is an error of the data source.
+      call(name, "read")(NativeLibrary.wrapEngineStream(streamAddress, task, 
name))
+    } catch {
+      case e: Throwable =>
+        releaseOnFailure(e, streamAddress, task)
+        throw e
+    }
+  }
+
+  /**
+   * Initializes the Arrow C stream at `streamAddress`, whose release callback 
is NULL, with the
+   * data of a partition, by calling read of the library in the running task. 
Returns the context
+   * of the task, which the library may use until the stream is released: it 
is closed then. If
+   * this fails, the stream is released.
+   *
+   * @param partition the partition, whose index in the scan the library gets 
as `partition_id`,
+   *                  rather than the partition of the task: a task may read 
several partitions of
+   *                  a scan, or of several scans, as after a coalesce or a 
union
+   */
+  private def openStream(
+      location: NativeLibraryLocation,
+      name: String,
+      kind: Int,
+      readerState: Array[Byte],
+      partition: NativeInputPartition,
+      schema: StructType,
+      timeZoneId: String,
+      streamAddress: Long): NativeLibrary.Task = {
+    val library = NativeLibraries.get(location)
+    val stream = ArrowArrayStream.wrap(streamAddress)
+    val task = NativeTask.create(Some(partition.index), read = true)
+    try {
+      NativeArrow.exportSchema(schema, timeZoneId) { schemaAddress =>
+        call(name, "read") {
+          library.read(task, kind, schemaAddress, readerState, 
partition.bytes, streamAddress)
+        }
+      }
+      // Arrow calls the callbacks of the stream without checking them, and 
calling NULL would
+      // crash the JVM.
+      val snapshot = stream.snapshot()
+      if (snapshot.release == 0 || snapshot.get_schema == 0 || 
snapshot.get_next == 0 ||
+          snapshot.get_last_error == 0) {
+        throw error(name, "read", "The library did not initialize the stream, 
or a callback of " +
+          "the stream is NULL.")
+      }
+      task
+    } catch {
+      case e: Throwable =>
+        releaseOnFailure(e, streamAddress, task)
+        throw e
+    }
+  }
+
+  /**
+   * After `failure`, releases the stream at `streamAddress`, if the library 
initialized it, before
+   * the task that it may use, and marks it released in case its release 
callback did not. A
+   * failure to clean up is added to `failure`.
+   */
+  private def releaseOnFailure(
+      failure: Throwable,
+      streamAddress: Long,
+      task: NativeLibrary.Task): Unit = {
+    try {
+      Utils.tryWithSafeFinally {
+        val stream = ArrowArrayStream.wrap(streamAddress)
+        stream.release()

Review Comment:
   If `stream.release()` here throws, `markReleased()` is skipped but 
`task.close()` still runs. The engine path otherwise uses only FFM, so in a JVM 
where Spark's own Arrow reader has never run, the Arrow C Data JNI library may 
not be loaded yet, and this JNI call can fail with `UnsatisfiedLinkError`.
   
   In that case, the engine's stream is left with `release != NULL` while the 
`SparkDsTaskContext` is already freed. An engine that trusts the contract (the 
stream is released on failure) leaks it, and an engine that calls `release` 
runs the library's release, which refers to the freed task (use-after-free).
   
   Could we release the stream through FFM like the rest of the engine path, or 
keep the task open when the release fails?



##########
sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/v2/ffi/NativeDataSource.scala:
##########
@@ -350,37 +382,107 @@ class NativeDataSourceStreamReader(
     offset
   }
 
-  override def partitions(start: String, end: String): Array[InputPartition] = 
{
-    val partitions = call(name, "plan a scan of") {
-      library.streamPartitions(handle.get, start, end)
+  override def partitions(start: String, end: String): Array[InputPartition] = 
synchronized {
+    if (plannedRange != (start, end)) {
+      plannedPartitions = Try {
+        val planned = call(name, "plan a scan of") {
+          library.streamPartitions(handle.get, start, end)
+        }
+        val partitions = NativeInputPartition.from(planned.partitions)
+        val bytes = bytesOrEmpty(Option(planned.state))
+        // A small state goes with each task, while the executors fetch a 
larger one once for the
+        // micro-batch, as a broadcast. Spark only plans the next range once 
the micro-batch of the
+        // previous one finished, so the copies of its broadcast on the 
executors are removed then.
+        // The driver keeps its own copy, so that the partitions and the 
reader factories that an
+        // engine keeps can still fetch it, until the ContextCleaner removes 
it once nothing refers
+        // to them.
+        plannedBroadcast.foreach(_.unpersist(blocking = false))

Review Comment:
   When the state is larger than 64 KiB, it is broadcast for each micro-batch, 
and the previous broadcast only gets `unpersist(blocking = false)` when the 
next range is planned. That removes the executor copies, but the driver copy 
stays until the `ContextCleaner` removes it after a GC.
   
   For example, with a 1-second trigger and a 1 MB state, the broadcasts can be 
promoted to the old generation and survive until 
`spark.cleaner.periodicGC.interval` (30 minutes by default). That is up to 
about 1.8 GB of broadcast blocks in the driver `BlockManager`, which can evict 
cached blocks or put pressure on the driver memory.
   
   Is there a way to bound this? For example, could the broadcast of a 
micro-batch be destroyed once the micro-batch is committed, or do engines need 
it beyond that?



##########
sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/v2/columnar/ColumnarTable.scala:
##########
@@ -123,39 +132,55 @@ class ColumnarScanBuilder(
 
   override def pruneColumns(requiredSchema: StructType): Unit = {
     // Only top-level columns are pruned by the data source. Spark prunes the 
nested fields of the
-    // columns it reads.
+    // columns it reads. The required columns are only recorded here: a batch 
scan asks its planner
+    // to prune the others, and a streaming scan, which has no planner, passes 
the required
+    // columns to its stream reader.
     val required =
       StructType(requiredSchema.fieldNames.flatMap(name => schema.find(_.name 
== name)))
-    if (required.length < schema.length && 
batchPlanner.pruneColumns(required)) {
-      readSchema = required
+    if (required.length < schema.length) {
+      this.requiredSchema = Some(required)
     }
   }
 
   // The scan takes over the planner, so it is built once.
   private lazy val scan = new ColumnarScan(
-    shortName, schema, readSchema, dataSource, Option(planner), pushedLimit)
+    shortName, schema, requiredSchema, dataSource, Option(planner), 
pushedLimit)
 
   override def build(): Scan = scan
 }
 
 /**
+ * A scan of a [[ColumnarDataSource]]. It reads the top-level columns that the 
query needs, the
+ * columns of its read schema: a batch scan asks its planner to prune the 
other columns when it
+ * plans its partitions, and drops them from the batches of the reader if the 
planner does not,
+ * while the stream reader of a streaming scan reads only these columns.
+ *
+ * @param requiredSchema the top-level columns that the query needs, if fewer 
than those of the
+ *                       full schema
  * @param pushedDownPlanner the planner that the operations were pushed down 
to, if any. Otherwise,
  *                          the scan creates one when its batch is planned.
  */
 class ColumnarScan(
     shortName: String,
     fullSchema: StructType,
-    prunedSchema: StructType,
+    requiredSchema: Option[StructType],
     dataSource: ColumnarDataSource,
     pushedDownPlanner: Option[ColumnarBatchPlanner],
     pushedLimit: Option[Int])
   extends Scan with SupportsMetadata {
 
-  // Spark may call `toBatch` more than once for the same scan; the partitions 
are planned once.
-  private lazy val batch = new ColumnarBatchScan(
-    pushedDownPlanner.getOrElse(dataSource.batchPlanner(fullSchema)))
+  // Spark may call `toBatch` more than once for the same scan; the partitions 
are planned once,
+  // after the planner prunes the columns, if it does.
+  private lazy val batch = {
+    val planner = 
pushedDownPlanner.getOrElse(dataSource.batchPlanner(fullSchema))
+    // The ordinals in the full schema of the columns that the scan reads, if 
the planner does
+    // not prune the others.
+    val projection = requiredSchema.filterNot(planner.pruneColumns)

Review Comment:
   `pruneColumns` is now called lazily while building `ColumnarScan.batch`, but 
a failure here is not memoized. `BatchScanExec.batch` is a `@transient lazy 
val` that can be evaluated again (for example from `equals`, `hashCode`, or 
`inputPartitions`), so the next `toBatch` calls `prune_columns` again:
   
   - With a pushdown (e.g. `select("id").limit(3)`), it is called twice on the 
same pushed-down planner, which breaks the "each at most once" contract of the 
header.
   - Without a pushdown, a new planner and native reader are created each time, 
and the previous planner handle leaks until the GC `Cleaner` runs.
   
   This also contradicts the doc of `ColumnarBatchScan` ("A failure is planned 
once too"). Could we memoize the result of building the scan, including a 
failure?



##########
sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/v2/ffi/NativeDataSource.scala:
##########
@@ -969,22 +1228,69 @@ class NativeDataWriter(
 
 /** Creates the `SparkDsTaskContext` of the running task. */
 private[ffi] object NativeTask {
+  // The flag of each running task that reads, set once the task failed or 
completed by a single
+  // pair of listeners for all its reads, which then also remove it: a task 
that reads many
+  // partitions, as after a coalesce, registers them once. The contexts of the 
reads keep their
+  // flag, so they still see it once it is removed. A task is the key rather 
than its attempt ID,
+  // which is 0 for each context of `TaskContext.empty()`.
+  private val endedTasks = new ConcurrentHashMap[TaskContext, AtomicBoolean]()

Review Comment:
   `endedTasks` is a static map that strongly references its `TaskContext` 
keys, and an entry is removed only when the completion or failure listener 
runs. So a context that never ends is never removed: for example 
`TaskContext.empty()` mentioned above, or a context that an engine or a test 
sets with `TaskContext.setTaskContext` before a native read or 
`openArrowStream`. Its `TaskContextImpl`, `TaskMetrics`, and registered 
listener closures then stay in this map until the JVM exits.
   
   This also defeats holding the task through a `WeakReference`. Could we use 
weak keys here?



##########
sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/v2/columnar/ColumnarTable.scala:
##########
@@ -222,9 +338,59 @@ abstract class ColumnarOnlyPartitionReaderFactory extends 
PartitionReaderFactory
   override def supportColumnarReads(partition: InputPartition): Boolean = true
 }
 
-class ColumnarStreamPartitionReaderFactory(reader: ColumnarStreamReader)
+object ColumnarProjection {

Review Comment:
   nit: `ColumnarProjection.apply` has a single caller, 
`ColumnarBatchScan.createReaderFactory`. We can inline it there and remove this 
object:
   
   ```scala
   projection.fold(factory)(new ColumnarProjectionReaderFactory(factory, _))
   ```



##########
sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/v2/columnar/ColumnarTable.scala:
##########
@@ -178,30 +204,89 @@ class ColumnarScan(
  * The batch of a scan, which plans it once, when Spark first asks for its 
partitions or its reader
  * factory. A failure is planned once too: the physical plan can ask again, 
for example from a lazy
  * value, and gets the same error.
+ *
+ * @param projection the ordinals of the columns that the scan reads, if the 
partitions return
+ *                   more columns
  */
-class ColumnarBatchScan(planner: ColumnarBatchPlanner) extends Batch {
+class ColumnarBatchScan(planner: ColumnarBatchPlanner, projection: 
Option[Array[Int]] = None)
+  extends Batch {
   private lazy val planned: Try[ColumnarPlannedBatch] = Try(planner.plan())
 
   override def planInputPartitions(): Array[InputPartition] = 
planned.get.partitions
 
-  override def createReaderFactory(): PartitionReaderFactory = 
planned.get.readerFactory
+  override def createReaderFactory(): PartitionReaderFactory = {
+    ColumnarProjection(planned.get.readerFactory, projection)

Review Comment:
   For a library that does not prune the columns, 
`ColumnarProjectionReaderFactory` wraps the factory and hides 
`SupportsArrowStreamRead`, so the engine native path is disabled entirely. The 
PR description explains that this is because the arrays of the stream would 
still have the other columns. However, `select("id")` on such a library then 
sends engines like Gluten back to the Arrow Java import -> `ColumnarToRow` -> 
`RowToVelox` path, which is the cost that this PR aims to remove.
   
   Since Spark already intercepts `get_schema` and `get_next` of the engine 
streams, how about projecting the children of the struct there instead, so that 
the native path is kept for such libraries too?



##########
sql/core/src/test/scala/org/apache/spark/sql/execution/datasources/v2/ffi/NativeDataSourceSuite.scala:
##########
@@ -1143,6 +1515,464 @@ class NativeDataSourceSuite extends QueryTest with 
SharedSparkSession {
       .mode("append")
       .save()
     check(writeTasks)
+
+    // A read gets the index of its partition in the scan, also when a task 
reads several
+    // partitions, or the partitions of several scans: the task attempt that 
read each partition.
+    def readers(dir: File): Map[Int, Long] = dir.listFiles().map { file =>
+      file.getName.toInt -> Files.readString(file.toPath).split(",")(5).toLong
+    }.toMap
+    val (first, second) = (Utils.createTempDir(), Utils.createTempDir())
+    checkRows(
+      range(session, "end" -> "6", "task_path" -> first.getPath).select("id")
+        .union(range(session, "end" -> "6", "task_path" -> 
second.getPath).select("id")),
+      (0L until 6L).flatMap(i => Seq(Row(i), Row(i))))
+    assert(readers(first).keySet == Set(0, 1) && readers(second).keySet == 
Set(0, 1))
+    val coalesced = Utils.createTempDir()
+    checkRows(range(session, "end" -> "6", "task_path" -> 
coalesced.getPath).select("id")
+      .coalesce(1), (0L until 6L).map(Row(_)))
+    assert(readers(coalesced).keySet == Set(0, 1) && 
readers(coalesced).values.toSet.size == 1)
+    // So does a read of a micro-batch.
+    val (firstStream, secondStream) = (Utils.createTempDir(), 
Utils.createTempDir())
+    def counter(dir: File): DataFrame = 
session.readStream.format("native_counter")
+      .option("max_offset", "4").option("task_path", dir.getPath).load()
+    val query = 
counter(firstStream).union(counter(secondStream)).writeStream.format("noop").start()
+    try query.processAllAvailable() finally query.stop()
+    assert(readers(firstStream).keySet == Set(0) && 
readers(secondStream).keySet == Set(0))
+  }
+
+  nativeTest("open the partitions of batch and micro-batch scans as Arrow C 
streams") {
+    val session = newSession(defaultPackage)
+    // The reader factory and the partitions of the batch scan of a query, 
which an engine gets
+    // from its plan.
+    def plannedScan(df: DataFrame): (SupportsArrowStreamRead, 
Seq[InputPartition]) = {
+      val scan = df.queryExecution.executedPlan.collectFirst { case s: 
BatchScanExec => s }.get
+      (scan.readerFactory.asInstanceOf[SupportsArrowStreamRead], 
scan.inputPartitions)
+    }
+    // The batch scan of only some of the columns from a library that does not 
prune the others
+    // drops them from the batches itself, so its reader factory does not open 
streams, which
+    // would have all the columns. With all the columns, it does.
+    val readOnly = newSession(defaultPackage, createPackage(readOnlyLibrary, 
dataSources("ro_")))
+    def readsStreams(df: DataFrame): Boolean = {
+      df.queryExecution.executedPlan.collectFirst { case s: BatchScanExec => s 
}.get
+        .readerFactory.isInstanceOf[SupportsArrowStreamRead]
+    }
+    val unpruned = readOnly.read.format("ro_native_range").option("end", 
"4").load()
+    assert(!readsStreams(unpruned.select("id")) && readsStreams(unpruned))
+    // Runs `f` in a task that only gets the files of the session. It is the 
task of the partition
+    // 1, so that the partition of the scan that a read gets is not the 
partition of its task.
+    def inTask[T: ClassTag](f: => T): T = 
session.artifactManager.withResources {
+      session.sparkContext.parallelize(Seq(0, 1), 2).flatMap(i => if (i == 1) 
Seq(f) else Nil)
+        .collect().head
+    }
+    import TestArrowEngine.{callbacks, readIds, readIdsInPlace}
+    // Reads the given partitions in a single task, as an engine does.
+    def readAll(factory: SupportsArrowStreamRead, partitions: 
Seq[InputPartition]): Seq[Long] = {
+      inTask {
+        partitions.flatMap { partition =>
+          val allocator = ArrowUtils.rootAllocator.newChildAllocator("engine", 
0, Long.MaxValue)
+          try {
+            val stream = ArrowArrayStream.allocateNew(allocator)
+            try factory.openArrowStream(partition, stream.memoryAddress()) 
catch {
+              case e: Throwable =>
+                stream.close()
+                throw e
+            }
+            readIds(stream, allocator)
+          } finally {
+            allocator.close()
+          }
+        }
+      }
+    }
+    // Checks that `f` opens `delta` more task contexts than it closes. The 
count is global to the
+    // JVM, where the tasks of earlier tests may still close their contexts, 
so it is read right
+    // before `f`.
+    def checkContexts[T](delta: Int)(f: => T): T = {
+      val before = NativeLibrary.openTaskContexts()
+      val result = f
+      assert(NativeLibrary.openTaskContexts() == before + delta)
+      result
+    }
+    // The size of struct ArrowArrayStream, which the layout test compares 
with the header.
+    val streamSize = NativeLibrary.layout().get("ArrowArrayStream").longValue
+
+    // An engine reads both partitions of the scan in a single task.
+    val taskDir = Utils.createTempDir()
+    val (factory, partitions) =
+      plannedScan(range(session, "end" -> "6", "task_path" -> 
taskDir.getPath).select("id"))
+    assert(partitions.length == 2)
+    assert(readAll(factory, partitions).sorted == (0L until 6L))
+    // The library got the partition of the scan that it reads, not the one of 
the task.
+    val tasks = taskDir.listFiles().map { file =>
+      file.getName.toInt -> 
Files.readString(file.toPath).split(",").map(_.toLong).toSeq
+    }.toMap
+    assert(tasks.keySet == Set(0, 1) && tasks.values.map(_(5)).toSet.size == 
1, tasks)
+    assert(NativeLibrary.openEngineStreams() == 0)
+    // The reads of a task share the flag that tells them that the task ended, 
with a single pair
+    // of listeners, which remove it once the task ended. The tasks of earlier 
tests may still
+    // be ending.
+    eventually(timeout(1.minute))(assert(NativeTask.tasksWithEndedFlags == 0))
+    val flagsInTask = inTask {
+      val ids = partitions.flatMap { partition =>
+        val stream = ArrowArrayStream.allocateNew(ArrowUtils.rootAllocator)
+        try {
+          factory.openArrowStream(partition, stream.memoryAddress())
+          try readIdsInPlace(stream) finally stream.release()
+        } finally {
+          stream.close()
+        }
+      }
+      assert(ids.sorted == (0L until 6L))
+      NativeTask.tasksWithEndedFlags
+    }
+    assert(flagsInTask == 1 && NativeTask.tasksWithEndedFlags == 0)
+
+    // The engine may release the stream after its task ended, and on another 
thread: Spark frees
+    // the context of the task once the library released the stream.
+    val released = new File(Utils.createTempDir(), "released")
+    val (laterFactory, laterPartitions) =
+      plannedScan(range(session, "end" -> "6", "released_path" -> 
released.getPath).select("id"))
+    // Opens a stream in a task, and returns it unreleased, with what `read` 
returned from it in
+    // the task.
+    def openInTask[T: ClassTag](factory: SupportsArrowStreamRead, partition: 
InputPartition)(
+        read: ArrowArrayStream => T): (Long, T) = inTask {
+      val stream = Platform.allocateMemory(streamSize)
+      Platform.setMemory(stream, 0.toByte, streamSize)
+      factory.openArrowStream(partition, stream)
+      (stream, read(ArrowArrayStream.wrap(stream)))
+    }
+    // The engine reads the stream to its end in the task, but releases it 
later.
+    val (address, readInTask) =
+      checkContexts(1)(openInTask(laterFactory, 
laterPartitions.head)(readIdsInPlace))
+    // A copy of the stream, as an engine that moves the stream may leave 
behind.
+    val copy = Platform.allocateMemory(streamSize)
+    Utils.tryWithSafeFinally {
+      Platform.copyMemory(null, address, null, copy, streamSize)
+      assert(readInTask == (0L until 3L))
+      assert(NativeLibrary.openEngineStreams() == 1 && !released.exists())
+      checkContexts(-1) {
+        ArrowArrayStream.wrap(address).release()
+        assert(NativeLibrary.openEngineStreams() == 0 && released.exists())
+      }
+      // Once the stream is released, the callbacks of the copy fail with 
EINVAL, and its release
+      // only marks it released.
+      checkContexts(0) {
+        val array = ArrowArray.allocateNew(ArrowUtils.rootAllocator)
+        val stale = try 
intercept[Exception](ArrowArrayStream.wrap(copy).getNext(array)) finally {
+          array.close()
+        }
+        assert(Utils.exceptionString(stale)
+          .contains("The stream was released, or is not a stream of Spark."))
+        ArrowArrayStream.wrap(copy).release()
+        assert(ArrowArrayStream.wrap(copy).snapshot().release == 0)
+        assert(NativeLibrary.openEngineStreams() == 0)
+      }
+    } {
+      // A failure above may leave the stream open, which would keep the 
context of its task.
+      if (ArrowArrayStream.wrap(address).snapshot().release != 0) {
+        ArrowArrayStream.wrap(address).release()
+      }
+      Platform.freeMemory(address)
+      Platform.freeMemory(copy)
+    }
+
+    // Once the task ended, its context tells the library that it was 
interrupted, so that the
+    // library stops waiting.
+    val marker = new File(Utils.createTempDir(), "interrupted")
+    val (waitingFactory, waitingPartitions) = plannedScan(
+      range(session, "end" -> "6", "wait_for_interrupt" -> 
marker.getPath).select("id"))
+    checkContexts(0) {
+      val (waiting, _) = openInTask(waitingFactory, waitingPartitions.head)(_ 
=> ())
+      val error = intercept[Exception] {
+        readIds(ArrowArrayStream.wrap(waiting), ArrowUtils.rootAllocator)
+      }
+      assert(Utils.exceptionString(error).contains("the task was killed"))
+      assert(Files.readString(marker.toPath) == "interrupted")
+      assert(NativeLibrary.openEngineStreams() == 0)
+      Platform.freeMemory(waiting)
+    }
+
+    // So does the context of a stream that an engine reads in its task once 
the task is killed,
+    // although the task did not end.
+    val killedMarker = new File(Utils.createTempDir(), "interrupted")
+    val (killedFactory, killedPartitions) = plannedScan(range(session, "end" 
-> "6",
+      "partitions" -> "1", "wait_for_interrupt" -> 
killedMarker.getPath).select("id"))
+    val contextsBeforeKill = NativeLibrary.openTaskContexts()
+    val jobGroup = s"native-data-source-${UUID.randomUUID()}"
+    val killError = new AtomicReference[Throwable]()
+    val engineThread = new Thread(() => {
+      session.sparkContext.setJobGroup(jobGroup, "an engine that waits", 
interruptOnCancel = true)
+      try {
+        inTask {
+          // Reads the stream on the thread of the task, which waits in 
get_next.
+          val stream = ArrowArrayStream.allocateNew(ArrowUtils.rootAllocator)
+          try {
+            killedFactory.openArrowStream(killedPartitions.head, 
stream.memoryAddress())
+            try readIdsInPlace(stream) finally stream.release()
+          } finally {
+            stream.close()
+          }
+        }
+      } catch {
+        case e: Throwable => killError.set(e)
+      }
+    })
+    engineThread.start()
+    eventually(timeout(1.minute)) {
+      assert(new File(killedMarker.getPath + ".waiting").isFile)
+    }
+    session.sparkContext.cancelJobGroup(jobGroup)
+    engineThread.join(TimeUnit.MINUTES.toMillis(1))
+    assert(!engineThread.isAlive && killError.get() != null)
+    eventually(timeout(1.minute)) {
+      assert(killedMarker.isFile && Files.readString(killedMarker.toPath) == 
"interrupted")
+      assert(NativeLibrary.openEngineStreams() == 0)
+      assert(NativeLibrary.openTaskContexts() == contextsBeforeKill)

Review Comment:
   `NativeLibrary.openTaskContexts()` is a JVM-wide counter, and this compares 
it with `==` to a baseline read before the test. If a context of an earlier 
test (e.g. of a killed task or a `PipedRDD` thread) is closed after 
`contextsBeforeKill` is read, the counter settles at `contextsBeforeKill - 1`, 
and this `eventually` fails after the 1-minute timeout. The same pattern is at 
lines 2007, 2036, and 2043, where the comment notes that the tasks of earlier 
tests may still be closing.
   
   Could we make these assertions robust to the contexts of earlier tests that 
close late, for example by using `<=`?



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