dongjoon-hyun commented on code in PR #59932:
URL: https://github.com/apache/spark/pull/59932#discussion_r4417356082
##########
sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/v2/ffi/NativeDataSource.scala:
##########
@@ -880,18 +925,14 @@ class NativeDataWriter(
val root = new VectorSchemaRoot(
vectors.map(_.getField).asJava, vectors.asJava, batch.numRows())
val array = ArrowArray.allocateNew(ArrowUtils.rootAllocator)
- val arrowSchema = ArrowSchema.allocateNew(ArrowUtils.rootAllocator)
try {
- Data.exportVectorSchemaRoot(ArrowUtils.rootAllocator, root, null, array,
arrowSchema)
- call(name, "write to") {
- library.write(handle, array.memoryAddress(),
arrowSchema.memoryAddress())
- }
+ // Only the array: the data writer got the schema when it was created.
+ Data.exportVectorSchemaRoot(ArrowUtils.rootAllocator, root, null, array)
+ call(name, "write to")(library.write(handle, array.memoryAddress()))
Review Comment:
**2. [Medium] The adapter does not enforce the order after a failed call**
With this PR, "after a function on a handle fails, Spark calls no other
function on the handle than its close function" becomes part of version 1,
whose semantics never change, so libraries will drop their own guards. For the
data writer, it holds only because `DataWritingSparkTask` calls it in the right
order. `finished` stays false when `commit_data_writer` fails, so that `abort`
still runs, but then the `finished || closed` checks of `write` and `commit`
also let another `write` or `commit` reach the library. If a caller ever
retries a commit, or writes after a failure, the library gets a call that the
header rules out, and Spark reports nothing.
Could the data writer record that a call failed, for example with a flag set
when `write` or `commitDataWriter` throws, so that `write` and `commit` throw
an internal error after it and only `abort` and `close` go through? The same
idea applies to the other handles: see item 1 for the stream writer and item 4
for `prune_columns`.
##########
sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/v2/ffi/NativeDataSource.scala:
##########
@@ -426,7 +447,8 @@ class NativeDataSourceStreamWriter(
partitionId: Int,
taskId: Long,
epochId: Long): DataWriter[ColumnarBatch] = {
- new NativeDataWriter(location, name, writerState, partitionId, taskId,
epochId)
+ new NativeDataWriter(
+ location, name, schema, timeZoneId, writerState, partitionId, taskId,
epochId)
}
override def commit(epochId: Long, messages: Array[WriterCommitMessage]):
Unit = {
Review Comment:
**1. [Medium] A stream writer can be aborted after its commit succeeded**
The new convention says that Spark aborts a writer whose commit failed, but
not whether it can abort one whose commit succeeded, and here it can.
`NativeDataSourceWriter.commit` closes the handle once the library commits, so
a later `abort` fails on `handle.get` before it reaches the library. This
`commit` neither closes the handle nor records that it succeeded.
In `V2TableWriteExec.writeWithV2`, the code after
`batchWrite.commit(messages)` returns is still inside the `try` (a `logInfo`
and `commitProgress = Some(...)`), and `case cause: Throwable` then calls
`batchWrite.abort(messages)`, which `MicroBatchWrite` forwards to the `abort`
below, for the epoch that the library just committed. Only a fatal error, such
as an `OutOfMemoryError`, gets there today, but a sink that removes the output
of an epoch on abort would then lose committed data.
Could `commit` record its success, and `abort` skip the library after it, as
`NativeDataWriter` does with `finished`? The header could then say for writers
what it says for `commit_data_writer`: if commit succeeds, Spark does not call
abort.
##########
sql/core/src/test/scala/org/apache/spark/sql/execution/datasources/v2/ffi/NativeDataSourceSuite.scala:
##########
@@ -1114,6 +1185,19 @@ class NativeDataSourceSuite extends QueryTest with
SharedSparkSession {
message.contains("a message from native code") &&
message.contains(System.mapLibraryName("test_native_datasource"))
})
+
+ // is_log_enabled follows the level of the log of Spark: the library gets
whether Spark logs
+ // each level, from error to trace.
+ val levels = new File(Utils.createTempDir(), "levels")
+ Seq(
+ org.apache.logging.log4j.Level.DEBUG -> "1,1,1,1,0",
+ org.apache.logging.log4j.Level.ERROR -> "1,0,0,0,0").foreach { case
(level, expected) =>
Review Comment:
**3. [Medium] The test cannot tell WARN, INFO and DEBUG apart**
At DEBUG every level from error to debug is enabled, and at ERROR every
level from warn on is disabled, so these two cases only check error against the
rest and debug against trace. If `isLogEnabled` mapped `2` to
`isInfoEnabled()`, or `3` to `isWarnEnabled()`, both cases would still pass,
although libraries would get wrong answers at INFO, the default level. Checking
the other levels too would cover each case of the switch:
```suggestion
org.apache.logging.log4j.Level.TRACE -> "1,1,1,1,1",
org.apache.logging.log4j.Level.DEBUG -> "1,1,1,1,0",
org.apache.logging.log4j.Level.INFO -> "1,1,1,0,0",
org.apache.logging.log4j.Level.WARN -> "1,1,0,0,0",
org.apache.logging.log4j.Level.ERROR -> "1,0,0,0,0").foreach { case
(level, expected) =>
```
`NativeLibrary` has no logger configuration of its own, so `withLogAppender`
sets these levels on the root logger configuration: TRACE applies to all of
Spark while `range` runs. It can be left out if it makes the test slow.
##########
sql/core/src/main/resources/org/apache/spark/sql/datasource/spark_datasource.h:
##########
@@ -115,8 +117,14 @@
* released once they are no longer referenced (see Lifecycle above). A
reader or writer handle
* must not depend on the data source handle it was created from, because
Spark closes the data
* source handle right after creating it.
- * - Memory. What Spark passes to a function, such as strings, byte arrays,
SparkDsCreateInfo and
- * SparkDsWriteInfo, is only valid during the call, except for SparkDsHost
and
+ * - Order of the calls. After a function on a handle fails, Spark calls no
other function on the
+ * handle than its close function, except that it aborts a writer whose
commit failed and a data
+ * writer whose write or commit failed. It calls each function of a batch
reader at most once. It
+ * calls commit and abort at most once each for a writer, and
commit_data_writer and
+ * abort_data_writer at most once each for a data writer, with all the calls
to write before
+ * them.
Review Comment:
**5. [Low] No test checks the order of the calls**
Since the convention becomes part of version 1, a test would keep a later
change in Spark from breaking it silently, for example a retry in
`DataWritingSparkTask`, or a stream writer aborted after its commit (item 1).
The test library could keep a small state for each handle (failed, committed,
aborted, planned) and report any call that the convention rules out, such as
"abort_data_writer called after commit_data_writer succeeded", for instance by
recording it in a file that the suite checks. The existing tests that inject
failures with the option `fail` would then check the convention without new
test cases.
##########
sql/core/src/main/resources/org/apache/spark/sql/datasource/spark_datasource.h:
##########
@@ -284,6 +294,13 @@ typedef struct SparkDsHost {
* of the driver or of the executor, with the path of the library.
*/
void (*log)(int32_t level, const char* message);
+ /*
+ * Returns whether log writes the messages of one of the SPARK_DS_LOG_*
levels, so that the
+ * library only formats the messages that Spark writes, and gets those that
Spark writes at the
+ * levels that a library would not log by default, such as debug. It follows
the configuration of
+ * the log of Spark, which can change while Spark runs.
+ */
+ bool (*is_log_enabled)(int32_t level);
Review Comment:
**7. [Low] Levels outside `SPARK_DS_LOG_*` are left unspecified**
`isLogEnabled` answers for any other level as for `SPARK_DS_LOG_INFO`, like
`log`, which logs such messages as info, but neither comment says so. Since
version 1 is frozen once 4.4.0 ships, it may help to state it for both
functions (for example "other levels are treated as SPARK_DS_LOG_INFO"), so
that SDKs know what a level of 0 or 6 does and a later release cannot change it.
Two smaller points about the same comment: it could say that the pointer is
never NULL in a struct large enough to hold it, and "whether log writes the
messages" is a little stronger than what Spark checks, which is the level of
its logger: a filter on an appender can still drop a message for which this
returns true.
##########
sql/core/src/main/resources/org/apache/spark/sql/datasource/spark_datasource.h:
##########
@@ -115,8 +117,14 @@
* released once they are no longer referenced (see Lifecycle above). A
reader or writer handle
* must not depend on the data source handle it was created from, because
Spark closes the data
* source handle right after creating it.
- * - Memory. What Spark passes to a function, such as strings, byte arrays,
SparkDsCreateInfo and
- * SparkDsWriteInfo, is only valid during the call, except for SparkDsHost
and
+ * - Order of the calls. After a function on a handle fails, Spark calls no
other function on the
+ * handle than its close function, except that it aborts a writer whose
commit failed and a data
+ * writer whose write or commit failed. It calls each function of a batch
reader at most once. It
Review Comment:
**4. [Low] `prune_columns` is called at most once only because Spark calls
`pruneColumns` once**
"It calls each function of a batch reader at most once" holds today:
`ColumnarScanBuilder.pushLimit` asks the reader only once (`limitResult`),
`ColumnarBatchScan` plans the partitions once, and `NativeHandle` releases the
reader once. `ColumnarScanBuilder.pruneColumns`, though, forwards every call to
`batchPlanner.pruneColumns`, so the guarantee rests on `V2ScanRelationPushDown`
pruning each scan builder once. It does so now, but the Data Source V2 API does
not promise it. A once-flag like `limitResult` would keep the convention true
whatever the optimizer does.
##########
docs/sql-native-data-sources.md:
##########
@@ -70,7 +70,9 @@ A native data source library exports a single C function,
`spark_ds_init`, which
of function pointers, a `SparkDsLibrary`, that implements the data source: it
needs no JVM headers
or bindings. Each library has its own table, so any number of native data
sources can be loaded
side by side. When Spark loads a library, it calls `spark_ds_init` with a
`SparkDsHost`, which
-gives the library the version of Spark and a function to log messages to the
log of Spark.
+gives the library the version of Spark, a function to log messages to the log
of Spark, and a
+function that tells whether Spark logs the messages of a level, so that a
library follows the log
+level of Spark.
Review Comment:
**8. [Nit] The guide does not mention the order of the calls**
This paragraph gets the new function, but the convention, which the
description presents as what lets SDKs drop their guards, is only in the
header. The guide already states the other conventions further down ("Each
handle that a `create` function returns is released exactly once, by the
`close` function of its kind"), so a sentence there would also reach the
readers of the Rust and C++ examples.
##########
sql/core/src/main/java/org/apache/spark/sql/execution/datasources/v2/ffi/NativeLibrary.java:
##########
@@ -491,6 +546,25 @@ private static void log(String path, int level, long
message) {
}
}
+ /**
+ * Called by the library, through {@code SparkDsHost.is_log_enabled}, with
the level of the
+ * messages of {@link #log}, which logs the levels that it does not know as
info. It must not
+ * throw: an exception thrown by an upcall crashes the JVM.
+ */
+ private static boolean isLogEnabled(int level) {
+ try {
+ return switch (level) {
Review Comment:
**9. [Nit] `isLogEnabled` repeats the level mapping of `log`**
Both switches map 1, 2, 4 and 5 and treat the rest as info, so they have to
change together, and with the current test (item 3) a mismatch would go
unnoticed. Could they share the mapping, for example through a small helper
that turns the level into an enum, or at least point at each other in a comment?
##########
sql/core/src/test/resources/native-datasource/test_native_datasource.cc:
##########
@@ -648,14 +656,34 @@ thread_local std::string last_error;
// Spark, as spark_ds_init got it.
const SparkDsHost* host = nullptr;
-// Logs with the log of Spark. A library must only use the fields that fit in
the size of the
-// struct that Spark passes, which may be of another version of the header.
+// Whether the struct that Spark passes has is_log_enabled, which an earlier
version of the header
+// does not have.
+bool hasIsLogEnabled() {
+ return host->size >= static_cast<int64_t>(offsetof(SparkDsHost,
is_log_enabled) +
+ sizeof(host->is_log_enabled));
+}
+
+// Logs with the log of Spark, unless it does not write the level. A library
must only use the
+// fields that fit in the size of the struct that Spark passes, which may be
of another version of
+// the header.
void hostLog(int32_t level, const std::string& message) {
+ if (hasIsLogEnabled() && !host->is_log_enabled(level)) return;
Review Comment:
**6. [Low] No test checks that `hostLog` skips the levels that Spark does
not log**
The description says that the test library now skips those levels, but the
only check of `hostLog` logs at WARN with the logger at INFO, where WARN is
enabled, so removing this line would not fail any test. Logging with the option
`log` while the logger is at ERROR, and checking that the appender gets no WARN
event, would cover it.
##########
sql/core/src/main/resources/org/apache/spark/sql/datasource/spark_datasource.h:
##########
@@ -284,6 +294,13 @@ typedef struct SparkDsHost {
* of the driver or of the executor, with the path of the library.
*/
void (*log)(int32_t level, const char* message);
+ /*
+ * Returns whether log writes the messages of one of the SPARK_DS_LOG_*
levels, so that the
+ * library only formats the messages that Spark writes, and gets those that
Spark writes at the
+ * levels that a library would not log by default, such as debug. It follows
the configuration of
+ * the log of Spark, which can change while Spark runs.
Review Comment:
**10. [Nit] Each check is an upcall, and the comment discourages caching it**
"which can change while Spark runs" tells a library to ask before every
message, but each call goes from native code into the JVM. An SDK whose
`log::Log::enabled` (in Rust) or logging macro calls it before every `debug!`
in a loop over batches pays that transition even when debug is off, which is
not free either. It may help to say that a library may cache the answer and
refresh it from time to time, so that SDKs can keep hot paths free of upcalls.
--
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]