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


##########
sql/core/src/main/resources/org/apache/spark/sql/datasource/spark_datasource.h:
##########
@@ -304,6 +308,31 @@ typedef struct SparkDsCreateInfo {
   int64_t num_options;
 } SparkDsCreateInfo;
 
+/* A micro-batch streaming scan, as create_stream_reader gets it. */
+typedef struct SparkDsStreamReaderInfo {
+  /* The size of this struct in the header that Spark was built with, as in 
SparkDsHost. */
+  int64_t size;
+  /*
+   * The location of a directory in the checkpoint of the streaming query that 
is reserved for this
+   * source, such as file:/tmp/checkpoint/sources/0, where the library may 
keep what it needs to
+   * recover from a failure. It stays the same when the query restarts from 
its checkpoint. It is a
+   * URI, possibly of a file system that the library cannot access, such as 
s3a or hdfs.

Review Comment:
   **3. [Low] `checkpoint_location` is the string of a Hadoop `Path`, not an 
escaped URI**
   
   The location is `checkpointDir.toString` from 
[`ResolveWriteToStream`](https://github.com/apache/spark/blob/f4a4ed242333c305e6125961d6e894d3e0674a41/sql/core/src/main/scala/org/apache/spark/sql/execution/streaming/runtime/ResolveWriteToStream.scala#L152-L153),
 to which 
[`StreamingUtils.getMetadataPath`](https://github.com/apache/spark/blob/f4a4ed242333c305e6125961d6e894d3e0674a41/sql/core/src/main/scala/org/apache/spark/sql/execution/streaming/utils/StreamingUtils.scala#L71)
 appends `/sources/0`. `Path.toString` has a scheme, but its characters are not 
percent-encoded. For a checkpoint at `/data/my ckpt%41`, the library gets 
`file:/data/my ckpt%41/sources/0`. A library that parses it as a URI, as this 
comment suggests, fails on the space or reads `%41` as `A`, and keeps its 
recovery data in another directory.
   
   Could it say that this is a fully qualified Hadoop path, whose characters 
are not escaped?



##########
sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/v2/columnar/ColumnarTable.scala:
##########
@@ -189,11 +189,31 @@ class ColumnarBatchScan(planner: ColumnarBatchPlanner) 
extends Batch {
 
 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 data source gets the offset that each micro-batch starts after: its 
default read limit reads
+ * all the available data, which Spark then always asks for.
+ */
+class ColumnarMicroBatchStream(reader: ColumnarStreamReader)
+  extends MicroBatchStream with SupportsAdmissionControl {

Review Comment:
   **2. [Medium] Implementing `SupportsTriggerAvailableNow` here would keep 
`start` exact for every trigger**
   
   The stream already gets the start through `latestOffset(start, limit)`. If 
it also implemented `SupportsTriggerAvailableNow`, Spark would neither wrap it 
nor fall back to a single batch. `prepareForTriggerAvailableNow` could only set 
a flag, and the first `latestOffset(start, limit)` would ask the library with 
the real start and keep that offset as the end of the run. This is the shape of 
`PythonMicroBatchStreamWithTriggerAvailableNow` for Python data sources.
   
   The header would then not need an exception for `Trigger.AvailableNow` (item 
1), and `start` would mean the same for every trigger, which admission control 
will also rely on once it is added. If this is out of scope here, the header 
could at least avoid promising the behavior of the wrapper.



##########
sql/core/src/test/scala/org/apache/spark/sql/execution/datasources/v2/ffi/NativeDataSourceSuite.scala:
##########
@@ -911,6 +946,29 @@ class NativeDataSourceSuite extends QueryTest with 
SharedSparkSession {
         "msg" -> "create failed with the error code 1."))
   }
 
+  nativeTest("reject a state returned more than once") {
+    def check(action: String, name: String, function: String)(f: SparkSession 
=> Unit): Unit = {
+      val e = intercept[Exception](f(newSession(defaultPackage)))
+      checkError(
+        exception = findError(e, "NATIVE_DATA_SOURCE_ERROR"),
+        condition = "NATIVE_DATA_SOURCE_ERROR",
+        parameters = Map("action" -> action, "name" -> name,
+          "msg" -> s"$function returned 2 states instead of one."))
+    }
+    check("plan a scan of", "native_range", "partitions") { session =>
+      range(session, "states" -> "2").collect()
+    }
+    check("plan a scan of", "native_counter", "stream_partitions") { session =>
+      val query = session.readStream.format("native_counter").option("states", 
"2").load()
+        .writeStream.format("noop").start()
+      try query.processAllAvailable() finally query.stop()
+    }
+    check("plan a write to", "native_sink", "create_writer") { session =>
+      session.range(2).write.format("native_sink").option("path", 
Utils.createTempDir().getPath)
+        .option("states", "2").mode("append").save()
+    }

Review Comment:
   **6. [Low] The test does not check that the rejected writer is released**
   
   This only matches the error message. If the `try`/`catch` around 
`state(sink, Function.CREATE_WRITER)` in `NativeLibrary.createWriter` were 
dropped, the handle would leak, since it is not in a `NativeHandle` yet, and 
this test would still pass. The test library only writes a close marker for a 
writer that was committed or aborted (`closed_marker`). A marker for a writer 
that is closed without either would let this test check the release, and would 
also show the case of item 5.



##########
sql/core/src/main/java/org/apache/spark/sql/execution/datasources/v2/ffi/NativeLibrary.java:
##########
@@ -569,41 +615,75 @@ void schema(long dataSource, long schemaAddress) throws 
Throwable {
   }
 
   long createReader(long dataSource, long schemaAddress) throws Throwable {
-    return createWithSchema(Function.CREATE_READER, dataSource, schemaAddress);
-  }
-
-  long createStreamReader(long dataSource, long schemaAddress) throws 
Throwable {
-    return createWithSchema(Function.CREATE_STREAM_READER, dataSource, 
schemaAddress);
+    MethodHandle f = function(Function.CREATE_READER);
+    try (Memory memory = new Memory()) {
+      long out = memory.zeroed(8);
+      check((int) f.invokeExact(dataSource, schemaAddress, out), 
Function.CREATE_READER);
+      return readHandle(out, Function.CREATE_READER);
+    }
   }
 
-  private long createWithSchema(Function function, long dataSource, long 
schemaAddress)
+  /**
+   * Creates a stream reader.
+   *
+   * @param checkpointLocation the location of the checkpoint of the source
+   */
+  long createStreamReader(long dataSource, long schemaAddress, String 
checkpointLocation)
       throws Throwable {
-    MethodHandle f = function(function);
+    MethodHandle f = function(Function.CREATE_STREAM_READER);
     try (Memory memory = new Memory()) {
+      long info = memory.zeroed(STREAM_READER_INFO_SIZE);
+      Platform.putLong(null, info, STREAM_READER_INFO_SIZE);
+      Platform.putLong(
+        null, info + STREAM_READER_INFO_CHECKPOINT_LOCATION, 
memory.string(checkpointLocation));
       long out = memory.zeroed(8);
-      check((int) f.invokeExact(dataSource, schemaAddress, out), function);
-      return readHandle(out, function);
+      check((int) f.invokeExact(dataSource, schemaAddress, info, out),
+        Function.CREATE_STREAM_READER);
+      return readHandle(out, Function.CREATE_STREAM_READER);
+    }
+  }
+
+  /** A writer handle, with the state of the writer that the data writers 
need. */
+  static final class WriterHandle {
+    final long handle;
+    /** The state of the writer, or null if it has none. */
+    final byte[] state;
+
+    WriterHandle(long handle, byte[] state) {
+      this.handle = handle;
+      this.state = state;
     }
   }
 
   /**
-   * Creates a writer.
+   * Creates a writer. If the library returns an invalid state, the writer is 
closed.
    *
    * @param mode {@link #WRITE_APPEND} or {@link #WRITE_TRUNCATE}
    * @param queryId the ID of the streaming query of a streaming write, or 
null for a batch write
    */
-  long createWriter(long dataSource, long schemaAddress, int mode, String 
queryId)
+  WriterHandle createWriter(long dataSource, long schemaAddress, int mode, 
String queryId)
       throws Throwable {
     MethodHandle f = function(Function.CREATE_WRITER);
-    try (Memory memory = new Memory()) {
+    try (Memory memory = new Memory(); Sink sink = new Sink()) {
       long info = memory.zeroed(WRITE_INFO_SIZE);
       Platform.putLong(null, info, WRITE_INFO_SIZE);
       Platform.putInt(null, info + WRITE_INFO_MODE, mode);
       Platform.putLong(
         null, info + WRITE_INFO_QUERY_ID, queryId == null ? 0 : 
memory.string(queryId));
       long out = memory.zeroed(8);
-      check((int) f.invokeExact(dataSource, schemaAddress, info, out), 
Function.CREATE_WRITER);
-      return readHandle(out, Function.CREATE_WRITER);
+      check((int) f.invokeExact(dataSource, schemaAddress, info, 
Ffm.STATE_SINK, sink.id, out),
+        Function.CREATE_WRITER);
+      long handle = readHandle(out, Function.CREATE_WRITER);
+      try {
+        return new WriterHandle(handle, state(sink, Function.CREATE_WRITER));
+      } catch (Throwable e) {
+        try {
+          closeWriter(handle);

Review Comment:
   **5. [Low] A writer whose state is rejected is closed without `commit` or 
`abort`**
   
   When `create_writer` succeeds but calls `set_state` twice, this closes the 
writer. Since `ColumnarBatchWrite` never got a writer, Spark does not call 
`abort` either. The rewritten Writes paragraph of the header still says that 
the driver calls `commit` or `abort` and then closes the writer. A library that 
prepares something in `create_writer`, such as a staging directory (the test 
library writes `_CREATED`), then has no call in which to undo it, except 
`close_writer`, which cannot fail.
   
   This was already the case when `serialize_writer` returned more than one 
value. Now that the state comes from `create_writer` itself, could Spark call 
`abort` with no messages before it closes the writer, or the header say that 
Spark may close a writer without `commit` or `abort` when it rejects what 
`create_writer` returned?



##########
sql/core/src/main/resources/org/apache/spark/sql/datasource/spark_datasource.h:
##########
@@ -487,26 +558,22 @@ typedef struct SparkDsLibrary {
   /*
    * Returns the most recent offset available.
    *
+   * info: the offset that the next micro-batch starts after
    * set_offset: called once with the offset, as compact JSON on a single line
    */
-  int (*latest_offset)(void* reader, SparkDsSink set_offset, void* sink);
+  int (*latest_offset)(void* reader, const SparkDsLatestOffsetInfo* info, 
SparkDsSink set_offset,
+                       void* sink);
 
   /*
    * Plans the partitions that read the data after `start` up to and including 
`end`, two offsets
    * as JSON.
    *
    * add_partition: called with each partition, serialized in a format defined 
by the library
+   * set_state: called once with the state of the stream reader that read 
needs on the executors
+   *            to read these partitions, or not at all if there is none
    */
   int (*stream_partitions)(void* reader, const char* start, const char* end,
-                           SparkDsSink add_partition, void* sink);
-
-  /*
-   * Serializes the state of the stream reader that read needs on the 
executors. Spark calls it
-   * after each call to stream_partitions.
-   *
-   * set_state: called once with the state, or not at all if there is none
-   */
-  int (*serialize_stream_reader)(void* reader, SparkDsSink set_state, void* 
sink);
+                           SparkDsSink add_partition, SparkDsSink set_state, 
void* sink);

Review Comment:
   **4. [Low] `stream_partitions`, `commit_offset`, `partitions` and 
`initial_offset` still take fixed arguments**
   
   This PR gives `create_stream_reader`, `latest_offset`, `read` and 
`create_data_writer` a struct that starts with its size, so that they can get 
new inputs within version 1. These four functions keep fixed arguments. 
Anything they may need later, such as the ID of the micro-batch for 
`stream_partitions` and `commit_offset` if Spark passes it one day, would then 
need a new function or version 2.
   
   Is that intended? Since this is the last chance to change their signatures 
within version 1, it may be worth giving them an `Info` struct as well, even 
one that only has its `size` for now.



##########
sql/core/src/main/java/org/apache/spark/sql/execution/datasources/v2/ffi/NativeLibrary.java:
##########
@@ -789,6 +904,16 @@ private static byte[] single(Sink sink, Function function) 
throws Throwable {
     return values.length == 0 ? null : values[0];
   }
 
+  /** Returns the state that a function passed to the set_state sink, or null 
if none. */
+  private static byte[] state(Sink sink, Function function) throws Throwable {

Review Comment:
   **10. [Nit] `state` repeats `single`, and `Sink.states` repeats `Sink.all`**
   
   They differ only in the list they read and in a word of the message. A 
single helper that takes the arrays and that word, such as 
`atMostOne(sink.states(), function, "states")`, would keep one copy of the 
check, and `Sink` could have one accessor that rethrows `error`.



##########
sql/core/src/test/scala/org/apache/spark/sql/execution/datasources/v2/columnar/ColumnarDataSourceSuite.scala:
##########
@@ -353,6 +388,13 @@ class TestRangeStreamReader(end: Long, schema: StructType) 
extends ColumnarStrea
   }
 }
 
+object TestRangeStreamReader {
+  // By the option "table" of the read: the checkpoint location of its stream 
reader, and the
+  // start offsets that the stream reader got for the latest offset.
+  val checkpointLocations = new ConcurrentHashMap[String, String]()
+  val starts = new ConcurrentHashMap[String, Seq[String]]()

Review Comment:
   **9. [Nit] These maps are never cleared**
   
   `starts` keeps the start offsets of earlier runs under the same `table`, and 
the new test asserts on the whole list. If the test runs twice in the same JVM, 
for example from an sbt shell, the second run appends `{"offset":0}` after the 
`{"offset":5}` of the first run, and `starts.tail.forall(...)` fails. Removing 
the entries of the table at the start of the test, or using a table name of its 
own for each run, would avoid that.



##########
sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/v2/ffi/NativeDataSource.scala:
##########
@@ -88,48 +89,56 @@ class NativeDataSource(
     if ((declared & NativeLibrary.BATCH_WRITE) != 0) {
       capabilities.add(BATCH_WRITE)
     }
-    if ((declared & NativeLibrary.TRUNCATE) != 0) {
+    if ((declared & NativeLibrary.BATCH_TRUNCATE) != 0) {
       capabilities.add(TRUNCATE)
     }
     capabilities
   }
 
+  // The readers and the writers export the schemas with the time zone of the 
session on the
+  // driver, also on the executors, where they pass the same schemas to the 
library again.
+
   override def batchPlanner(schema: StructType): ColumnarBatchPlanner = {
     NativeArrow.checkSupported(schema, name, "plan a scan of")
     val executorLocation = NativeDataSourceRegistry.distribute(location)
     val library = NativeLibraries.get(location)
+    val timeZoneId = SQLConf.get.sessionLocalTimeZone

Review Comment:
   **11. [Nit] The time zone is read in four places, and again by 
`ArrowBatchingOptions`**
   
   `batchPlanner`, `streamReader`, `writer` and `streamWriter` each read 
`SQLConf.get.sessionLocalTimeZone`. For a write, the schema that 
`create_data_writer` gets uses this value, but `ArrowBatchingDataWriter` builds 
the arrays that `write` gets with the time zone that 
[`ArrowBatchingOptions`](https://github.com/apache/spark/blob/f4a4ed242333c305e6125961d6e894d3e0674a41/sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/v2/columnar/ColumnarWrite.scala#L181)
 reads separately. They are equal today, because both are read in the same 
`createBatchWriterFactory` call, but nothing ties them together. Reading the 
time zone once, for example in a field of `NativeDataSource`, or passing the 
one of `ArrowBatchingOptions` to the writer, would keep a single source.



##########
sql/core/src/main/resources/org/apache/spark/sql/datasource/spark_datasource.h:
##########
@@ -304,6 +308,31 @@ typedef struct SparkDsCreateInfo {
   int64_t num_options;
 } SparkDsCreateInfo;
 
+/* A micro-batch streaming scan, as create_stream_reader gets it. */
+typedef struct SparkDsStreamReaderInfo {
+  /* The size of this struct in the header that Spark was built with, as in 
SparkDsHost. */
+  int64_t size;
+  /*
+   * The location of a directory in the checkpoint of the streaming query that 
is reserved for this
+   * source, such as file:/tmp/checkpoint/sources/0, where the library may 
keep what it needs to
+   * recover from a failure. It stays the same when the query restarts from 
its checkpoint. It is a
+   * URI, possibly of a file system that the library cannot access, such as 
s3a or hdfs.
+   */
+  const char* checkpoint_location;
+} SparkDsStreamReaderInfo;
+
+/* A request for the latest offset of a stream reader, as latest_offset gets 
it. */
+typedef struct SparkDsLatestOffsetInfo {
+  /* The size of this struct in the header that Spark was built with, as in 
SparkDsHost. */
+  int64_t size;
+  /*
+   * The offset, as JSON, that the next micro-batch starts after: the end of 
the last micro-batch,
+   * or the initial offset before the first one. With Trigger.AvailableNow, 
Spark asks for the
+   * latest offset only once, when the query starts, with the initial offset.

Review Comment:
   **1. [Medium] The sentence about `Trigger.AvailableNow` only holds for a new 
query, or with an internal flag**
   
   By default, `ColumnarMicroBatchStream` does not implement 
`SupportsTriggerAvailableNow`, so 
[`MicroBatchExecution.getTrigger`](https://github.com/apache/spark/blob/f4a4ed242333c305e6125961d6e894d3e0674a41/sql/core/src/main/scala/org/apache/spark/sql/execution/streaming/runtime/MicroBatchExecution.scala#L152-L181)
 runs `Trigger.AvailableNow` as a single batch (`SingleBatchExecutor`), and 
[`constructNextBatch`](https://github.com/apache/spark/blob/f4a4ed242333c305e6125961d6e894d3e0674a41/sql/core/src/main/scala/org/apache/spark/sql/execution/streaming/runtime/MicroBatchExecution.scala#L1085-L1091)
 passes the real start, from 
[`getStartOffset`](https://github.com/apache/spark/blob/f4a4ed242333c305e6125961d6e894d3e0674a41/sql/core/src/main/scala/org/apache/spark/sql/execution/streaming/runtime/MicroBatchExecution.scala#L1043-L1054).
 So Spark asks once, but a query that restarts from its checkpoint gets the end 
of its last micro-batch, not the initial offset.
   
   Only the internal `spark.sql.streaming.triggerAvailableNowWrapper.enabled` 
passes the initial offset: 
[`AvailableNowDataStreamWrapper.prepareForTriggerAvailableNow`](https://github.com/apache/spark/blob/f4a4ed242333c305e6125961d6e894d3e0674a41/sql/core/src/main/scala/org/apache/spark/sql/execution/streaming/runtime/AvailableNowDataStreamWrapper.scala#L59-L66)
 calls `latestOffset(initialOffset, ...)`, also after a restart, and returns 
that offset for every batch. `start` is then not the offset that the next 
micro-batch starts after, and the offset of a library that computes from 
`start`, as `native_counter` now does, goes backwards. With the last committed 
end at `{"offset":12}` and a step of 5, it returns `{"offset":5}`. Spark plans 
the range from 12 to 5, which `stream_partitions` reads as empty, and commits 
5, so the next run reads the rows 5 to 12 again.
   
   Since this sentence is frozen with version 1, could it describe the default 
instead: once, with the end of the last micro-batch, or the initial offset for 
a new query? It could then promise nothing for the wrapper, or the wrapper 
could be avoided as in item 2. No test runs `Trigger.AvailableNow` on a native 
source, so a test with a restart would pin whichever behavior is chosen.



##########
sql/core/src/test/resources/native-datasource/test_native_datasource.cc:
##########
@@ -1252,20 +1298,24 @@ int createDataWriter(const SparkDsTaskContext* task, 
const uint8_t* writer_state
   });
 }
 
-int writeBatch(void* handle, struct ArrowArray* array, struct ArrowSchema* 
schema) {
+int writeBatch(void* handle, struct ArrowArray* array) {
   return guarded([&] {
     auto* writer = fromHandle<DataWriter>(handle);
     // The batch is released here, so Spark does not release it after the call.
     std::unique_ptr<ArrowArray, void (*)(ArrowArray*)> array_owner(
         array, [](ArrowArray* a) { a->release(a); });
-    std::unique_ptr<ArrowSchema, void (*)(ArrowSchema*)> schema_owner(
-        schema, [](ArrowSchema* s) { s->release(s); });
     maybeFail(writer->state, "write");
+    const ArrowSchema& schema = writer->schema;
+    if (array->n_children != schema.n_children) {

Review Comment:
   **7. [Low] `writeBatch` only compares the number of columns with the schema 
of `create_data_writer`**
   
   The comment at the top of the file says that `write` fails unless its 
batches have the columns of the schema that `create_data_writer` got, but this 
only compares `n_children`. An `ArrowArray` has no names or formats, so the 
arrays alone cannot show more. The read side compares the schema of `read` with 
the columns that the reader recorded in its state, and the write side could do 
the same. `createWriter` could record the names and formats of its schema in 
the writer state, and `createDataWriter` could compare them with 
`info->schema`. That would catch a schema that differs from the one 
`create_writer` got, for example in its time zone (item 11).



##########
sql/core/src/test/resources/native-datasource/test_native_datasource.cc:
##########
@@ -1048,13 +1069,22 @@ int initialOffset(void*, SparkDsSink set_offset, void* 
sink) {
   return guarded([&] { sendBytes(set_offset, sink, "{\"offset\":0}"); });
 }
 
-// Each call makes `step` more rows available, up to `max_offset`.
-int latestOffset(void* handle, SparkDsSink set_offset, void* sink) {
+// Makes `step` more rows available after the start of the micro-batch, up to 
`max_offset`.

Review Comment:
   **8. [Nit] A comment of `NativeDataSourceSuite` still describes the previous 
`latest_offset`**
   
   `latest_offset` now returns the start plus `step`, but the test "streaming 
read and write" still says that [each call to `latestOffset` makes 5 more rows 
available](https://github.com/apache/spark/blob/f4a4ed242333c305e6125961d6e894d3e0674a41/sql/core/src/test/scala/org/apache/spark/sql/execution/datasources/v2/ffi/NativeDataSourceSuite.scala#L792).
 It could say that each micro-batch makes 5 more rows available after its start.



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