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


##########
docs/sql-native-data-sources.md:
##########
@@ -161,28 +179,33 @@ to, but it cannot check that the buffers of the arrays 
hold those values, becaus
 interface does not carry the sizes of buffers: a library that exports a buffer 
that is too short
 makes Spark read past its end.
 
-`read` and `create_data_writer` get a `SparkDsTaskContext`, with the IDs of 
the task, and its
-partition and attempt, and an `is_interrupted` function that returns whether 
the task was killed,
-for example because its query was canceled. Spark cannot interrupt native 
code: when a task is
-killed, Spark stops reading only once the `get_next` function of the stream 
returns. So a read or
-a write that runs for long, or blocks, for example on network I/O, should 
check `is_interrupted`
-regularly and use timeouts. The task reaper, `spark.task.reaper.enabled` and
-`spark.task.reaper.killTimeout`, kills an executor whose killed task does not 
end.
+`read` and `create_data_writer` get a `SparkDsTaskContext`, with the IDs of 
the task, its
+attempt, and the partition: for `read`, the index of the partition in the list 
that the planning
+returned, which is unique within a scan but not across the scans that a task 
reads, and for
+`create_data_writer`, the partition of the task. It also has an 
`is_interrupted` function that

Review Comment:
   **3. [Low] The guide still says that `is_interrupted` returns whether the 
task was killed**
   
   For a read, `is_interrupted` now also returns true once the task failed or 
completed, and every read gets the context of a read, also the reads of Spark 
itself (NativeDataSource.scala:1098). So a library that no engine reads also 
sees true in a task that succeeded, for example while the stdin writer thread 
of a `PipedRDD` is still in get_next after the task completed. 
`spark_datasource.h` says so (L384-L387), but the guide only says it in the 
section on engines (L261). Could this paragraph say it too, for example 
"returns whether the task was killed, or for `read`, also whether the task 
failed or completed"?



##########
sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/v2/ffi/NativeDataSource.scala:
##########
@@ -350,37 +384,104 @@ 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 {
+    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 the previous broadcast on the 
executors are removed
+    // then. The broadcast itself is destroyed once its micro-batch is 
committed, whose partitions
+    // are not read anymore then, also by an engine.
+    uncommitted.values.lastOption.flatten.foreach(_.unpersist(blocking = 
false))
+    val state = if (bytes.length > 
NativeDataSourceStreamReader.maxInlineStateBytes &&

Review Comment:
   **2. [Low] Correction to my item 6 of round 8: the size limit of an inline 
state does not count the partitions**
   
   This follows [my item 6 of round 
8](https://github.com/apache/spark/pull/59934#discussion_r4471627311), which 
suggested a limit on the size of the state alone. But the cost that [item 1 of 
round 5](https://github.com/apache/spark/pull/59934#discussion_r4420249991) 
described also grows with the number of partitions: each task serializes its 
own `NativeMicroBatchPartition`, so a state just below 64 KiB over 1,000 
partitions sends about 64 MB with the tasks of each micro-batch, while its 
broadcast would go once to each executor. Could the limit apply to the total, 
for example `bytes.length.toLong * partitions.length > maxInlineStateBytes`? 
The comments of `maxInlineStateBytes` (L440-L443) and of 
`NativeMicroBatchPartition` (L569-L571) would then say so.



##########
sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/v2/columnar/ColumnarDataSource.scala:
##########
@@ -101,18 +111,37 @@ case class ColumnarPlannedBatch(
 
 /**
  * Reads the data of a [[ColumnarDataSource]] for a micro-batch streaming 
scan, with offsets in
- * JSON. Spark plans the partitions of each micro-batch on the driver, then 
serializes the reader
- * to the executors, where `read` is called for each partition.
+ * JSON. Spark plans the partitions of each micro-batch on the driver with 
this reader, which stays
+ * there, and serializes the reader factory that `readerFactory` returns to 
the executors, which
+ * read the partitions with it.
  */
-trait ColumnarStreamReader extends Serializable {
+trait ColumnarStreamReader {
+  /**
+   * Returns the offset to start from when the streaming query has no 
checkpoint. Spark keeps it in
+   * the checkpoint of the source, and only asks for it when the checkpoint 
does not have it yet.
+   */
   def initialOffset(): String
 
-  def latestOffset(): String
+  /**
+   * Returns the latest offset available.
+   *
+   * @param start the offset that the next micro-batch starts after: the end 
of the last
+   *              micro-batch, or the initial offset before the first one
+   */
+  def latestOffset(start: String): String
 
-  /** Plans the partitions that read the data after `start` up to and 
including `end`. */
+  /**
+   * Plans the partitions that read the data after `start` up to and including 
`end`. Spark plans
+   * each range once, also for a micro-batch that reads the source several 
times.
+   */
   def partitions(start: String, end: String): Array[InputPartition]
 
-  def read(partition: InputPartition): PartitionReader[ColumnarBatch]
+  /**
+   * Returns the reader factory of a micro-batch, which Spark serializes to 
the executors. It may

Review Comment:
   **4. [Low] The doc of `readerFactory()` does not say that its factory reads 
the partitions of every micro-batch**
   
   "the reader factory of a micro-batch" reads as one factory for each 
micro-batch, but `readerFactory()` gets no range, and an engine may keep the 
factory and read the partitions of later micro-batches with it, as 
NativeDataSourceSuite.scala:1774-1776 says. The native stream reader handles 
this by keeping the state in the partitions, which answered [item 1 of round 
4](https://github.com/apache/spark/pull/59934#discussion_r4419050240), but the 
contract is not written here, so another `ColumnarStreamReader` could put the 
state of the last planned range in its factory and bring that bug back. Could 
this say that the factory reads the partitions of any micro-batch, so what 
changes with each micro-batch goes with its partitions?



##########
sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/v2/ffi/NativeDataSource.scala:
##########
@@ -350,37 +384,104 @@ 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 {
+    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 the previous broadcast on the 
executors are removed
+    // then. The broadcast itself is destroyed once its micro-batch is 
committed, whose partitions
+    // are not read anymore then, also by an engine.
+    uncommitted.values.lastOption.flatten.foreach(_.unpersist(blocking = 
false))

Review Comment:
   **5. [Nit] The `unpersist` in `partitions()` only does something when 
commits are skipped**
   
   `MicroBatchExecution.constructNextBatch` commits the previous micro-batch 
(`cleanUpLastExecutedMicroBatch`, MicroBatchExecution.scala:1157) before 
`runBatch` plans the next range, so `commit(end)` has already destroyed and 
removed its entry, and `uncommitted.values.lastOption` is empty here. This line 
only finds a broadcast when Spark skipped the commit of the previous 
micro-batch, as with async progress tracking, while the comment above reads as 
if it always removes the previous copies. Could the comment say so, or the line 
go, since the next commit destroys the broadcast anyway?



##########
sql/catalyst/src/main/java/org/apache/spark/sql/connector/read/SupportsArrowStreamRead.java:
##########
@@ -0,0 +1,62 @@
+/*
+ * 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.connector.read;
+
+import org.apache.spark.annotation.Evolving;
+
+/**
+ * A mix-in interface for a {@link PartitionReaderFactory} that also reads a 
partition as an
+ * <a href="https://arrow.apache.org/docs/format/CStreamInterface.html";>Arrow 
C stream</a>, for an
+ * engine that processes Arrow data in native code: the engine imports the 
arrays of the stream
+ * itself, instead of reading the {@link 
org.apache.spark.sql.vectorized.ColumnarBatch}es of
+ * {@link #createColumnarReader(InputPartition)}. The reader factories of the 
batch and
+ * micro-batch scans of native data sources implement it, with the stream that 
the library
+ * returns.
+ * <p>
+ * Each array of the stream is a struct array whose fields are the columns of 
the read schema of
+ * the scan. Spark only checks that the source initialized the callbacks of 
the stream, not its
+ * arrays, while it checks the arrays of the streams that it reads itself, so 
the engine checks
+ * what its import of the arrays requires, such as the schema of the stream 
against the read
+ * schema, and the offsets and the lengths of the arrays. The callbacks of the 
stream can be called
+ * from any thread, but never concurrently. Each call goes through the JVM, 
which then calls the
+ * callback of the stream of the source, so the engine releases the stream 
before the JVM exits.
+ * The first call on a native thread attaches the thread to the JVM, until the 
thread exits.
+ *
+ * @since 4.4.0
+ */
+@Evolving
+public interface SupportsArrowStreamRead extends PartitionReaderFactory {
+
+  /**
+   * Initializes an Arrow C stream with the data of a partition. It is called 
in the running task
+   * that reads the partition, on a thread that has its {@code TaskContext}, 
and fails otherwise.
+   * The caller reads the stream in that task: once the task ended, the source 
may stop returning
+   * data and fail. The caller owns

Review Comment:
   **8. [Nit] A line of the Javadoc breaks early**
   
   This line ends after "The caller owns", while the lines around it wrap near 
100 characters. Could L48-L52 be rewrapped?



##########
sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/v2/ffi/NativeDataSource.scala:
##########
@@ -267,65 +276,90 @@ class NativeBatchPlanner(
     call(name, "plan a scan of")(optional(library.pushLimit(handle.get, 
limit))).getOrElse(false)
   }
 
-  override def pruneColumns(requiredSchema: StructType): Boolean = {
-    val pruned = call(name, "plan a scan of") {
+  override def pruneColumns(requiredSchema: StructType): Unit = {
+    // read gets the columns to read as its schema, and returns exactly them, 
so a library that
+    // does not implement prune_columns, which only lets it plan to read them, 
reads them too.
+    call(name, "plan a scan of") {
       optional(library.pruneColumns(handle.get, requiredSchema.fieldNames))
-    }.getOrElse(false)
-    if (pruned) {
-      readSchema = requiredSchema
     }
-    pruned
+    readSchema = requiredSchema
   }
 
   override def plan(): ColumnarPlannedBatch = {
     try {
-      val partitions = call(name, "plan a scan 
of")(library.partitions(handle.get))
-      val readerState = bytesOrEmpty(
-        call(name, "plan a scan 
of")(optional(library.serializeReader(handle.get))))
+      val planned = call(name, "plan a scan 
of")(library.partitions(handle.get))
       ColumnarPlannedBatch(
-        NativeInputPartition.from(partitions),
-        new NativePartitionReaderFactory(executorLocation, name, readerState, 
readSchema))
+        NativeInputPartition.from(planned.partitions).toArray[InputPartition],
+        new NativePartitionReaderFactory(executorLocation, name,
+          bytesOrEmpty(Option(planned.state)), readSchema, timeZoneId))
     } finally {
-      // The executors only need the serialized state.
+      // The executors only need the state that the reader returned.
       handle.close()
     }
   }
 }
 
-/** Reads the partitions of a planned batch scan of a native data source, on 
the executors. */
+/**
+ * Reads the partitions of a planned batch scan of a native data source, on 
the executors. An
+ * engine can also get the Arrow C stream of a partition from it, see 
[[SupportsArrowStreamRead]].
+ */
 class NativePartitionReaderFactory(
     location: NativeLibraryLocation,
     name: String,
     readerState: Array[Byte],
-    readSchema: StructType)
-  extends ColumnarOnlyPartitionReaderFactory {
+    readSchema: StructType,
+    timeZoneId: String)
+  extends ColumnarOnlyPartitionReaderFactory with SupportsArrowStreamRead {
   override def createColumnarReader(partition: InputPartition): 
PartitionReader[ColumnarBatch] = {
-    new NativePartitionReader(
-      location, name, readerState, 
partition.asInstanceOf[NativeInputPartition].bytes, readSchema)
+    new NativePartitionReader(location, name, NativeLibrary.READ_BATCH, 
readerState,
+      partition.asInstanceOf[NativeInputPartition], readSchema, timeZoneId)
+  }
+
+  override def openArrowStream(partition: InputPartition, streamAddress: 
Long): Unit = {
+    NativePartitionReader.openEngineStream[NativeInputPartition](location, 
name,
+      NativeLibrary.READ_BATCH, partition, readSchema, timeZoneId, 
streamAddress) { partition =>
+      (readerState, partition)
+    }
   }
 }
 
-/** Reads a native data source for a micro-batch streaming scan. */
+/**
+ * Reads a native data source for a micro-batch streaming scan.
+ *
+ * @param timeZoneId the time zone of the timestamps of the schema that the 
stream reader got
+ */
 class NativeDataSourceStreamReader(
     location: NativeLibraryLocation,
     name: String,
     schema: StructType,
-    @transient private val handle: NativeHandle)
+    timeZoneId: String,
+    handle: NativeHandle)
   extends ColumnarStreamReader {
   import NativeCalls._
 
-  // Set on the driver whenever the partitions of a micro-batch are planned, 
and serialized to
-  // the executors with the partitions of that micro-batch.
-  private var readerState: Array[Byte] = Array.emptyByteArray
+  // The ends of the planned ranges that are not committed yet, in the order 
in which they were
+  // planned, with the broadcast of the state of their micro-batch, if it was 
broadcast. Spark plans
+  // each range once (see ColumnarMicroBatchStream), so the scans of a 
micro-batch get the
+  // partitions of a single stream_partitions call, with the state that it 
returned.
+  private val uncommitted = mutable.LinkedHashMap.empty[String, 
Option[Broadcast[Array[Byte]]]]
+  // The context that broadcasts the states, taken on the driver when the 
reader is created, so
+  // that a range can also be planned on a thread without an active session.
+  private val sparkContext =
+    SparkContext.getActive.getOrElse(SparkSession.active.sparkContext)

Review Comment:
   **7. [Nit] The fallback to `SparkSession.active.sparkContext` cannot give a 
running context**
   
   Thank you for taking the context when the reader is created, which answers 
[item 10 of round 
10](https://github.com/apache/spark/pull/59934#discussion_r4474718480). 
`SparkContext.getActive` is only empty when no `SparkContext` runs in the JVM, 
and then the context of any session is stopped too, or `SparkSession.active` 
throws, so `broadcast` (L399) fails either way. Could this keep only one of 
them, such as `SparkSession.active.sparkContext`? `toMicroBatchStream` runs on 
the thread of the query, whose session is active, which 
`ColumnarMicroBatchStream` also relies on for its initial offset log 
(ColumnarTable.scala:251).



##########
sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/v2/ffi/NativeDataSource.scala:
##########
@@ -447,13 +553,31 @@ class NativeDataSourceStreamWriter(
 case class NativeInputPartition(index: Int, bytes: Array[Byte]) extends 
InputPartition
 
 object NativeInputPartition {
-  def from(partitions: Array[Array[Byte]]): Array[InputPartition] = {
+  def from(partitions: Array[Array[Byte]]): Array[NativeInputPartition] = {
     Option(partitions).getOrElse(Array.empty).zipWithIndex.map { case (bytes, 
i) =>
       NativeInputPartition(i, Option(bytes).getOrElse(Array.emptyByteArray))
     }
   }
 }
 
+/**
+ * A partition of a micro-batch, with the state that the stream reader 
returned with the
+ * partitions of the micro-batch, if any. The state changes with each 
micro-batch, so it goes with
+ * the partitions rather than with the reader factory, which an engine may 
keep while Spark plans
+ * the next micro-batch.
+ *
+ * @param state the bytes of the state, empty if the stream reader returned 
none, or for a state
+ *              larger than 64 KiB, their broadcast, so that each executor 
fetches it once for the
+ *              micro-batch rather than with each task
+ */
+case class NativeMicroBatchPartition(
+    partition: NativeInputPartition,
+    state: Either[Array[Byte], Broadcast[Array[Byte]]])
+  extends InputPartition {
+  /** The state to read the partition with, empty if the stream reader 
returned none. */
+  def readerState: Array[Byte] = state.fold(identity, _.value)

Review Comment:
   **1. [Low] The partitions of a committed or stopped micro-batch fail to read 
only when their state was broadcast**
   
   `commit(end)` (L412-L418) and `stop()` (L427-L432) destroy the broadcasts of 
the micro-batches that they end. A partition of such a micro-batch still reads 
when its state stayed inline, but with a state above 64 KiB, `_.value` here, or 
the serialization of the broadcast with a task, fails with the internal error 
"Attempted to use Broadcast(N) after it was destroyed". So the same read 
succeeds or fails depending on the size of the state. The error also comes 
before `call` (L463 for the reader of Spark, L469 for an engine), so it is not 
a `NATIVE_DATA_SOURCE_ERROR` and does not name the data source, and the Javadoc 
of `SupportsArrowStreamRead` does not say how long the partitions of a 
micro-batch can be read.
   
   NativeDataSourceSuite.scala:1757-1769 relies on the small case: it reads the 
partitions of the last micro-batch after `query.stop()`. `MicroBatchExecution` 
commits a micro-batch only when it constructs the next one 
(MicroBatchExecution.scala:1157), so that micro-batch is not committed, and 
`stop()` destroys its broadcast. The test passes because the state of 
`native_counter` stays inline. ([Item 2 of round 
7](https://github.com/apache/spark/pull/59934#discussion_r4463375800) asked 
`stop()` for an `unpersist`, after which the executors could still fetch the 
broadcast from the driver.)
   
   Could the Javadoc say that the partitions of a micro-batch can be read until 
the micro-batch is committed or its query stops, and the test read them before 
`query.stop()`?



##########
sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/v2/ffi/NativeDataSource.scala:
##########
@@ -969,22 +1234,70 @@ 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()`. The keys are weak, 
compared by identity,
+  // so that a context that never ends, such as one that a test sets on its 
thread, does not stay.
+  private val endedTasks = new MapMaker().weakKeys().makeMap[TaskContext, 
AtomicBoolean]()
+
+  /** Returns the flag that is set once the given task failed or completed. */
+  private def endedFlag(taskContext: TaskContext): AtomicBoolean = {
+    Option(endedTasks.get(taskContext)).getOrElse {
+      val ended = new AtomicBoolean(false)
+      Option(endedTasks.putIfAbsent(taskContext, ended)).getOrElse {
+        // A listener runs right away if the task already failed or completed.
+        def end(): Unit = {
+          ended.set(true)
+          endedTasks.remove(taskContext, ended)
+        }
+        taskContext.addTaskFailureListener((_, _) => end())

Review Comment:
   **6. [Nit] A read that starts after its task completed registers another 
failure listener**
   
   Thank you for the shared flag, which answers [item 7 of round 
8](https://github.com/apache/spark/pull/59934#discussion_r4471627323). Once the 
task completed, `end()` has removed its entry, so a read that starts after 
that, such as the next partition of a coalesced scan read by the stdin writer 
thread of a `PipedRDD`, registers a new pair. The completion listener runs 
right away and removes the entry again, but `addTaskFailureListener` only runs 
a listener right away if the task failed (TaskContextImpl.scala:179-185), so 
the failure listener of each such read stays in the context. That is bounded by 
the reads after the end of the task, but the comment above says that a task 
registers them once. Could `endedFlag` return a set flag without listeners when 
the task already failed or completed?



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