spark-test commented on code in PR #59931:
URL: https://github.com/apache/spark/pull/59931#discussion_r4420518881


##########
sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/v2/ffi/NativeDataSource.scala:
##########
@@ -780,6 +822,7 @@ class NativePartitionReader(
       throw error(name, "read", s"The batch has an offset of ${batch.offset}, 
but Spark only " +
         "reads arrays with an offset of 0. Copy sliced arrays before exporting 
them.")
     }
+    checkBuffers(batch, "The batch")

Review Comment:
   **Nit (P3):** Only the column-level call of `checkBuffers` has a test: 
`"short" -> "buffers"` clears the pointer of column `m` only 
(`test_native_datasource.cc:475-477`). The batch struct always gets a valid 
pointer from `initArray`, and `validity()` returns 0 for a NULL one, so 
deleting this line leaves the suite green, and a batch with a NULL `buffers` 
pointer would reach the Arrow import instead of failing with "The batch has 1 
buffers, but a NULL pointer to them." An option of the test library that clears 
the pointer of the batch struct, next to `"children"` in `exportBatch`, and one 
more `check` in "reject the Arrow data that Spark cannot read" would cover it.
   
   **Verification:**
   - **Regression:** Reading a batch whose struct array declares buffers but 
has a NULL pointer to them fails with NATIVE_DATA_SOURCE_ERROR naming the 
batch. This fails if the batch-level check is removed.



##########
sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/v2/ffi/NativeDataSource.scala:
##########
@@ -350,17 +359,16 @@ class NativeDataSourceStreamReader(
   }
 
   override def partitions(start: String, end: String): Array[InputPartition] = 
{
-    val partitions = call(name, "plan a scan of") {
+    val planned = call(name, "plan a scan of") {
       library.streamPartitions(handle.get, start, end)
     }
-    readerState = bytesOrEmpty(
-      call(name, "plan a scan 
of")(optional(library.serializeStreamReader(handle.get))))
-    NativeInputPartition.from(partitions)
+    readerState = bytesOrEmpty(Option(planned.state))

Review Comment:
   **Non-blocking (P2):** Each `stream_partitions` call replaces this single 
`readerState`, and `read` passes it with every partition. The header now 
promises more: `read` gets "the state that partitions or stream_partitions 
returned with the partition" (`spark_datasource.h:421`), and `set_state` 
returns the state "to read these partitions" (`:655-656`).
   
   The two differ when one micro-batch plans the stream twice, as a streaming 
self-union does. `DeduplicateRelations` keeps both branches of a `Union` on one 
`StreamingRelationV2` (projection-based deduplication), so 
`MicroBatchExecution` maps them to one stream, and so to one 
`NativeDataSourceStreamReader`. Each branch still becomes its own 
`MicroBatchScanExec`, which calls `planInputPartitions(start, end)` for the 
same range, and `UnionExec` builds both RDDs before the job runs. The task 
binary then serializes the reader after the second call, and the partitions of 
the first call are read with the state of the second.
   
   The test library returns the same reader-wide state from every call, so no 
test sees this. A library whose state is specific to the call, such as a 
snapshot, or a file listing that its partitions index into, can fail or read 
data that does not match its partitions. The batch path is fine: 
`NativeBatchPlanner` binds the state of its `partitions` call into the reader 
factory of its own plan.
   
   **Recommended change:** Make NativeDataSourceStreamReader plan each range 
once: keep, in transient fields that are not serialized to the executors, the 
start and end offsets of its last stream_partitions call and the partitions 
that it returned. When Spark plans the same range again, return those 
partitions without calling stream_partitions again, and replace readerState 
only when a new range is planned. Add native streaming tests in which the test 
library returns a different state from each stream_partitions call and records 
the state that each read gets, for a query that unions a native source with 
itself.
   
   **Why this works:** All scans of a source in one micro-batch plan the same 
range. With a memo of the last range, they all read the partitions of a single 
stream_partitions call, so readerState holds exactly the state returned with 
those partitions. A new range replaces both together.
   
   **Scope:** Memoize the last planned range in the native stream reader, and 
add a self-union regression test with call-specific states.
   
   **Compatibility:** read keeps getting the state that partitions or 
stream_partitions returned with its partition, as the version 1 header states.
   
   **Risks:** If a future engine path planned two different ranges of one 
stream before the tasks of the first were serialized, a memo of only the last 
range would again pair the earlier partitions with the later state. A memo that 
is not transient would serialize the partitions of the last range into the task 
binary of each micro-batch, with the reader.
   
   **Constraints:** Keep the rule that the partitions of a range read the same 
data each time. A restarted query, with a new stream reader, still calls 
stream_partitions again for the last range that it planned. Leave the batch 
path unchanged: its planner already binds its state into the reader factory of 
its plan.
   
   **Exclusions:**
   - Moving the state into each NativeInputPartition, which would change how 
the state reaches the tasks.
   
   **Success:** In a streaming self-union of a native source, Spark calls 
stream_partitions once for the range of each micro-batch, and every partition 
of both branches is read with the state that this call returned. Streaming 
reads without a self-union keep their stream_partitions calls, start offsets 
and rows.
   
   **Verification:**
   - **Regression:** A streaming query that unions a native micro-batch source 
with itself runs against a library that returns a different state from each 
stream_partitions call. Every partition is read with the state returned with 
it, and the library sees one stream_partitions call per micro-batch.
   - **Behavior:** Native streaming reads, restarts from a checkpoint and 
Trigger.AvailableNow runs keep their start offsets, their rows and the ranges 
that stream_partitions plans.



##########
sql/core/src/test/resources/native-datasource/test_native_datasource.cc:
##########
@@ -1033,58 +1125,86 @@ int planPartitions(void* handle, SparkDsSink 
add_partition, void* sink) {
       int64_t stop = std::min(hi, start + step);
       sendBytes(add_partition, sink, std::to_string(start) + "," + 
std::to_string(stop));
     }
+    sendState(reader->state, set_state, sink, serialize(reader->state));
   });
 }
 
-int serializeReader(void* handle, SparkDsSink set_state, void* sink) {
-  return guarded([&] { sendBytes(set_state, sink, 
serialize(fromHandle<Reader>(handle)->state)); });
-}
-
 void closeReader(void* handle) { delete fromHandle<Reader>(handle); }
 
 #ifndef READ_ONLY
 
-int initialOffset(void*, SparkDsSink set_offset, void* sink) {
-  return guarded([&] { sendBytes(set_offset, sink, "{\"offset\":0}"); });
+// Sends the offset, or, with the option `bad_option`, no offset for "none", a 
pretty-printed one
+// for "multiline", or one that is not JSON for "invalid".
+void sendOffset(const Options& state, const std::string& bad_option, 
SparkDsSink set_offset,
+                void* sink, int64_t offset) {
+  std::string bad_offset = get(state, bad_option, "");
+  if (bad_offset == "none") return;
+  if (bad_offset == "multiline") {
+    // Pretty-printed JSON, as serde_json::to_string_pretty writes it.
+    sendBytes(set_offset, sink, "{\n  \"offset\": " + std::to_string(offset) + 
"\n}");
+  } else if (bad_offset == "invalid") {
+    sendBytes(set_offset, sink, "offset " + std::to_string(offset));
+  } else {
+    sendBytes(set_offset, sink, "{\"offset\":" + std::to_string(offset) + "}");
+  }
 }
 
-// Each call makes `step` more rows available, up to `max_offset`.
-int latestOffset(void* handle, SparkDsSink set_offset, void* sink) {
+int initialOffset(void* handle, const SparkDsInitialOffsetInfo* info, 
SparkDsSink set_offset,
+                  void* sink) {
   return guarded([&] {
-    Options& state = fromHandle<Reader>(handle)->state;
-    int64_t latest = std::min(getLong(state, "max_offset", 10),
-                              getLong(state, "current", 0) + getLong(state, 
"step", 10));
-    state["current"] = std::to_string(latest);
-    std::string bad_offset = get(state, "bad_offset", "");
-    if (bad_offset == "none") return;
-    if (bad_offset == "multiline") {
-      // Pretty-printed JSON, as serde_json::to_string_pretty writes it.
-      sendBytes(set_offset, sink, "{\n  \"offset\": " + std::to_string(latest) 
+ "\n}");
-    } else if (bad_offset == "invalid") {
-      sendBytes(set_offset, sink, "offset " + std::to_string(latest));
-    } else {
-      sendBytes(set_offset, sink, "{\"offset\":" + std::to_string(latest) + 
"}");
+    checkSize("SparkDsInitialOffsetInfo", info->size, 
sizeof(SparkDsInitialOffsetInfo));
+    const Options& state = fromHandle<Reader>(handle)->state;
+    int64_t offset = 0;
+    std::string path = get(state, "initial_offsets_path", "");
+    if (!path.empty()) {
+      FILE* file = std::fopen(path.c_str(), "a+");

Review Comment:
   **Nit (P3):** The count depends on where reading starts in an `"a+"` stream, 
which C leaves implementation-defined (C11 7.21.3p1). glibc starts at the 
beginning of the file, but the BSD-derived libcs, including macOS, start at its 
end. There, the loop sees EOF at once and every call returns `{"offset":0}`, so 
the option no longer "returns the number of calls before it", and in "ask for 
the initial offset once, and keep it in the checkpoint" only the line count 
still tells a reused offset from one asked again. A `std::rewind(file);` before 
the loop makes the count portable: `fputs` still appends, and since it follows 
a read that reached EOF, it needs no positioning call.
   
   **Verification:**
   - **Behavior:** With initial_offsets_path, successive initial_offset calls 
return the offsets 0, 1 and 2, whatever initial read position the platform 
gives append-mode streams.



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