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


##########
sql/core/src/main/resources/org/apache/spark/sql/datasource/spark_datasource.h:
##########
@@ -304,6 +310,74 @@ typedef struct SparkDsCreateInfo {
   int64_t num_options;
 } SparkDsCreateInfo;
 
+/* A batch scan, as create_reader gets it. */
+typedef struct SparkDsReaderInfo {
+  /* The size of this struct in the header that Spark was built with, as in 
SparkDsHost. */
+  int64_t size;
+} SparkDsReaderInfo;
+
+/* A request for the partitions of a batch scan, as partitions gets it. */
+typedef struct SparkDsPartitionsInfo {
+  /* The size of this struct in the header that Spark was built with, as in 
SparkDsHost. */
+  int64_t size;
+} SparkDsPartitionsInfo;
+
+/* 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
+   * fully qualified Hadoop path, whose characters are not escaped as in a 
URI: a space stays a
+   * space, and % stays %. Its file system may be one that the library cannot 
access, such as s3a
+   * or hdfs.
+   */
+  const char* checkpoint_location;
+} SparkDsStreamReaderInfo;
+
+/*
+ * A request for the initial offset of a stream reader, as initial_offset gets 
it. Spark may call
+ * initial_offset more than once for a query, for example when it restarts 
before its first
+ * micro-batch is committed, so it must return the same offset each time: a 
library whose initial
+ * offset depends on when it is asked, such as the latest data, keeps it under 
checkpoint_location.

Review Comment:
   **2. [Low] A library may not be able to keep its initial offset under 
`checkpoint_location`**
   
   This asks a library whose initial offset depends on when it is asked to keep 
it under `checkpoint_location`, but 
[`SparkDsStreamReaderInfo`](https://github.com/apache/spark/blob/d597801d15f4c8ff773f46917f797c7ff466cb41/sql/core/src/main/resources/org/apache/spark/sql/datasource/spark_datasource.h#L329-L337)
 says that its file system may be one that the library cannot access, such as 
s3a or hdfs. A library that starts from the latest data, with a checkpoint on 
S3 and no S3 client of its own, then cannot return the same offset when the 
query restarts before its first micro-batch is committed, and Spark plans that 
micro-batch again from a newer start up to the end in the offset log.
   
   Spark can already write there: `ColumnarMicroBatchStream` gets the location 
in `toMicroBatchStream`, and could keep the first initial offset in a metadata 
log, as 
[`KafkaMicroBatchStream.getOrCreateInitialPartitionOffsets`](https://github.com/apache/spark/blob/d597801d15f4c8ff773f46917f797c7ff466cb41/connector/kafka-0-10-sql/src/main/scala/org/apache/spark/sql/kafka010/KafkaMicroBatchStream.scala#L366-L395)
 does for Kafka, under a name that the header reserves for Spark. Every library 
would then get a stable initial offset without accessing the checkpoint itself, 
and the header would not need this requirement. If that is out of scope for 
version 1, could the header at least say that a library can only meet it when 
it can access the file system of the checkpoint?



##########
sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/v2/columnar/ColumnarTable.scala:
##########
@@ -189,11 +189,52 @@ 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 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 latest offset 
that the first
+ * micro-batch got, rather than wrapping the stream in one that asks for the 
latest offset from
+ * the initial offset.
+ */
+class ColumnarMicroBatchStream(reader: ColumnarStreamReader)
+  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.
+  private lazy val initial = ColumnarStreamOffset(reader.initialOffset())

Review Comment:
   **3. [Low] No test fails if the initial offset is asked twice again**
   
   The `lazy val` fixes the two calls of the first micro-batch, but 
[`initialOffset`](https://github.com/apache/spark/blob/d597801d15f4c8ff773f46917f797c7ff466cb41/sql/core/src/test/resources/native-datasource/test_native_datasource.cc#L1081-L1087)
 of the test library ignores its reader and returns the same offset each time, 
so the suites pass with or without it. Could `native_counter` record its calls 
of `initial_offset`, for example by appending a line to a file given by an 
option, as `starts_path` does for `latest_offset`, and a test check that a 
query that starts without a checkpoint calls it once?



##########
docs/sql-native-data-sources.md:
##########
@@ -101,32 +101,38 @@ Spark calls the functions of the table as follows:
   that the data source does not support before it computes the data to write. 
When the user does
   not specify a schema, Spark asks for it with `schema`.
 - **Batch reads**: on the driver, Spark creates a reader with `create_reader`, 
pushes operations
-  down with `push_limit` and `prune_columns`, plans the partitions with 
`partitions`, and
-  serializes the state of the reader with `serialize_reader`. Then it calls 
`read` on the
-  executors, in one task for each partition, and `read` exports an 
`ArrowArrayStream`. Its
-  callbacks can be called from any thread, but never concurrently.
-- **Streaming reads**: on the driver, Spark creates a stream reader with 
`create_stream_reader`.
-  For each micro-batch, it asks for the `latest_offset`, plans the partitions 
with
-  `stream_partitions`, and calls `read` on the executors. Offsets are defined 
by the library, as
-  compact JSON on a single line: Spark stores each offset as a line of the 
offset log of the
-  checkpoint, and rejects an offset that is not valid JSON or contains a line 
break, such as
-  pretty-printed JSON.
+  down with `push_limit` and `prune_columns`, and plans the partitions with 
`partitions`, which
+  also returns the state of the reader. Then it calls `read` on the executors, 
in one task for each
+  partition, with a `SparkDsReadInfo` that has the partition, the schema to 
read and the state of
+  the reader, and `read` exports an `ArrowArrayStream`. Its callbacks can be 
called from any
+  thread, but never concurrently.
+- **Streaming reads**: on the driver, Spark creates a stream reader with 
`create_stream_reader`,
+  which gets the location of the checkpoint of the source in a 
`SparkDsStreamReaderInfo`. For each
+  micro-batch, it asks for the `latest_offset`, with the offset that the 
micro-batch starts after,

Review Comment:
   **4. [Nit] The guide does not have the two new rules of the streaming reads**
   
   The header now says that with `Trigger.AvailableNow`, Spark asks for the 
`latest_offset` only once, before the first micro-batch that it plans, and that 
`initial_offset` must return the same offset each time. This paragraph still 
says that Spark asks for the `latest_offset` for each micro-batch, and does not 
mention `initial_offset`. Since a library that only reads the guide could 
compute its initial offset from the latest data each time it is asked, could it 
mention both?



##########
sql/core/src/main/resources/org/apache/spark/sql/datasource/spark_datasource.h:
##########
@@ -350,16 +451,62 @@ typedef struct SparkDsWriteInfo {
   const char* query_id;
 } SparkDsWriteInfo;
 
+/* The data writer of a task, as create_data_writer gets it, on an executor. */
+typedef struct SparkDsDataWriterInfo {
+  /* The size of this struct in the header that Spark was built with, as in 
SparkDsHost. */
+  int64_t size;
+  /*
+   * The schema of the data to write, as create_writer got it. Each array that 
write gets has this
+   * schema, so the library keeps it, or what it needs of it, to read the 
arrays.
+   */
+  struct ArrowSchema* schema;
+  /* The state that create_writer returned, or empty. */
+  const uint8_t* writer_state;
+  int64_t writer_state_length;
+  /* The ID of the micro-batch for a streaming write, or -1 for a batch write. 
*/
+  int64_t epoch_id;
+} SparkDsDataWriterInfo;
+
+/* The end of a write, as commit and abort get it. */
+typedef struct SparkDsFinishWriteInfo {
+  /* The size of this struct in the header that Spark was built with, as in 
SparkDsHost. */
+  int64_t size;
+  /*
+   * The ID of the micro-batch for a streaming write, or -1 for a batch write, 
and for a writer
+   * whose create_writer Spark rejected, before it knew the micro-batch.
+   */
+  int64_t epoch_id;
+  /*
+   * The num_messages messages returned by commit_data_writer of each task. 
For abort, a message
+   * is NULL if its task did not commit.
+   */
+  const uint8_t* const* messages;
+  const int64_t* message_lengths;
+  int64_t num_messages;
+  /*
+   * For abort, whether Spark rejected what create_writer returned, such as a 
state set more than
+   * once: no task ran, so there are no messages.
+   */
+  bool rejected;

Review Comment:
   **1. [Low] `rejected` leaves 7 bytes of padding at the end of the struct, 
where a later field would fit without changing `size`**
   
   `SparkDsFinishWriteInfo` ends with a `bool` at offset 40, and its size is 48 
because the struct is aligned to 8 bytes. A `bool` or an `int32_t` appended 
later within version 1 would be at offset 41 or 44, inside these 48 bytes, so 
`size` would stay 48. A library built against that header checks that `size` 
covers the new field, which also passes with the 48 that Spark 4.4.0 passes, 
and then reads a padding byte. That only works because `NativeLibrary` zeroes 
the struct, which the header does not promise, and only for a field whose zero 
means that it is absent.
   
   The other structs that Spark passes all end with an 8-byte field, so this is 
the only one. Could `rejected` be an `int64_t`, 0 or 1, so that each appended 
field grows `size`? Otherwise, the Compatibility section could say that Spark 
zeroes the struct up to `size`, and that a field appended later must mean its 
default when it is 0.



##########
sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/v2/columnar/ColumnarTable.scala:
##########
@@ -189,11 +189,52 @@ 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 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 latest offset 
that the first
+ * micro-batch got, rather than wrapping the stream in one that asks for the 
latest offset from

Review Comment:
   **5. [Nit] The Scaladoc still says that the run ends at the latest offset 
that the first micro-batch got**
   
   As the header now says, Spark asks for the latest offset before the first 
micro-batch that it plans, which is not the first micro-batch of the run when 
the query restarts with a micro-batch that was not committed: Spark runs that 
one again without asking. Maybe "up to the first latest offset that Spark asks 
for", as the comment of `availableNowEnd` says.



##########
sql/core/src/test/scala/org/apache/spark/sql/execution/datasources/v2/ffi/NativeDataSourceSuite.scala:
##########
@@ -735,7 +735,7 @@ class NativeDataSourceSuite extends QueryTest with 
SharedSparkSession {
         "name" -> "native_sink",
         "msg" -> "injected failure in write"))
     // Spark aborted the first epoch, with a null message for its task, which 
did not commit.
-    assert(Files.readString(new File(dir, "_ABORTED_0").toPath) == "1")
+    assert(Files.readString(new File(dir, "_ABORTED_0").toPath) == "1,1,0")

Review Comment:
   **6. [Nit] The tests of failed batch writes do not check the new content of 
`_ABORTED`**
   
   Only this streaming test and the rejected writers compare `_ABORTED` with 
the number of NULL messages, the number of messages and `rejected`. [The batch 
writes that 
fail](https://github.com/apache/spark/blob/d597801d15f4c8ff773f46917f797c7ff466cb41/sql/core/src/test/scala/org/apache/spark/sql/execution/datasources/v2/ffi/NativeDataSourceSuite.scala#L617-L715)
 only check that `_ABORTED` is a file, so a batch abort that set `rejected` or 
lost its messages would pass them. Could they compare the content too, for 
example `"2,2,0"` when the two data writers fail to commit? The abort after a 
failed `commit` matters most, since all its messages are set and the library 
must remove the data that they point to: its first and last numbers would be 0.



##########
sql/core/src/main/java/org/apache/spark/sql/execution/datasources/v2/ffi/NativeLibrary.java:
##########
@@ -750,43 +950,46 @@ void write(long dataWriter, long arrayAddress, long 
schemaAddress) throws Throwa
    */
   byte[] commitDataWriter(long dataWriter, Runnable committed) throws 
Throwable {
     MethodHandle f = function(Function.COMMIT_DATA_WRITER);
-    try (Sink sink = new Sink()) {
-      check((int) f.invokeExact(dataWriter, Ffm.SINK, sink.id), 
Function.COMMIT_DATA_WRITER);
+    try (Memory memory = new Memory(); Sink sink = new Sink()) {
+      long info = memory.zeroed(FINISH_DATA_WRITER_INFO_SIZE);
+      Platform.putLong(null, info, FINISH_DATA_WRITER_INFO_SIZE);
+      check((int) f.invokeExact(dataWriter, info, Ffm.SINK, sink.id),
+        Function.COMMIT_DATA_WRITER);
       committed.run();
       byte[] message = single(sink, Function.COMMIT_DATA_WRITER);
       return message == null ? new byte[0] : message;
     }
   }
 
   void abortDataWriter(long dataWriter) throws Throwable {
-    check((int) function(Function.ABORT_DATA_WRITER).invokeExact(dataWriter),
-      Function.ABORT_DATA_WRITER);
+    try (Memory memory = new Memory()) {
+      long info = memory.zeroed(FINISH_DATA_WRITER_INFO_SIZE);
+      Platform.putLong(null, info, FINISH_DATA_WRITER_INFO_SIZE);

Review Comment:
   **7. [Nit] The structs that only hold their size are allocated for each 
call**
   
   `SparkDsReaderInfo`, `SparkDsPartitionsInfo`, `SparkDsInitialOffsetInfo` and 
`SparkDsFinishDataWriterInfo` only hold their own size, which does not change, 
but each call allocates, zeroes and frees one, and `commit_data_writer` and 
`abort_data_writer` now do it for each task. Until fields are added to them, a 
single struct of each kind, allocated once and never freed as `Ffm.host` does 
for `SparkDsHost`, would do, since the library gets a `const` pointer, and 
`abortDataWriter` would stay a single call. This is minor next to the cost of 
the call itself.



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