dongjoon-hyun commented on code in PR #59932:
URL: https://github.com/apache/spark/pull/59932#discussion_r4418406852
##########
sql/core/src/main/resources/org/apache/spark/sql/datasource/spark_datasource.h:
##########
@@ -56,47 +56,54 @@
*
* - Batch reads. On the driver, Spark creates a reader with create_reader,
pushes operations down
* by calling, each at most once and in this order, push_limit and
prune_columns, plans the
- * partitions with partitions, serializes the state of the reader with
serialize_reader, and
- * closes it. Then it calls read on the executors, in one task for each
partition. If Spark never
- * plans the partitions, for example to only explain the plan of the query,
it closes the reader
- * once the reader is no longer referenced.
- * - Streaming reads. On the driver, Spark creates a stream reader with
create_stream_reader and
- * keeps it until the streaming query stops. For each micro-batch, it asks
for the latest_offset,
- * plans the partitions with stream_partitions and serializes the state with
- * serialize_stream_reader, then 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, so it rejects an offset that is not valid JSON or contains a
line break. When a
- * streaming query restarts, from its checkpoint, Spark creates a new stream
reader and may call
- * stream_partitions again for the last range that it planned before, so the
partitions of a
- * range must read the same data each time.
+ * partitions with partitions, which also returns the state of the reader,
and closes it. Then it
+ * calls read on the executors, in one task for each partition, with the
schema to read and the
+ * state of the reader. If Spark never plans the partitions, for example to
only explain the plan
+ * of the query, it closes the reader once the reader is no longer
referenced.
+ * - Streaming reads. On the driver, Spark creates a stream reader with
create_stream_reader, with
+ * the location of the checkpoint of the source, and keeps it until the
streaming query stops. For
+ * each micro-batch, or only before the first one with Trigger.AvailableNow,
it asks for the
+ * latest_offset, with the offset that the micro-batch starts after, plans
the partitions with
+ * stream_partitions, which also returns the state of the stream reader,
then 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, so it rejects
an offset that is not
+ * valid JSON or contains a line break. When a streaming query restarts,
from its checkpoint,
+ * Spark creates a new stream reader and may call stream_partitions again
for the last range that
+ * it planned before, so the partitions of a range must read the same data
each time.
* - Writes. On the driver, right before it starts the tasks of the write (or
of a micro-batch),
- * Spark creates a writer with create_writer, and serializes its state with
serialize_writer.
- * With adaptive query execution, Spark first runs the stages of the data to
write that end with
- * a shuffle, and creates no writer if they fail. The rest of the data, such
as its last stage,
- * is computed in the tasks of the write, so a writer may still be aborted
because computing the
- * data failed: do irreversible work, such as removing the existing data for
an overwrite, in
- * commit. On the executors, each task creates a data writer with
create_data_writer, calls write
- * for each batch of at most spark.sql.execution.arrow.maxRecordsPerBatch
rows, which also ends
- * once it holds spark.sql.execution.arrow.maxBytesPerBatch bytes, and calls
commit_data_writer,
- * or abort_data_writer if the task or the commit fails, before it closes
the data writer. Then
- * the driver calls commit with the messages of the tasks if they all
succeeded, or abort
- * otherwise, including when commit fails, and closes the writer. Several
attempts of a task may
- * write the same partition, after a failure or with speculative execution,
each with a data
- * writer of its own. Spark lets the attempts of a partition commit one at a
time, and passes to
- * commit the message of one committed attempt for each partition, so the
write consists of the
- * data of these attempts only. It aborts the data writers of the other
attempts, except on an
- * executor that is lost, where a data writer is neither committed nor
aborted, and may even have
- * committed before its message reached the driver. A streaming write
commits or aborts each
- * micro-batch (epoch). Spark may commit the same epoch more than once, for
example when a
- * streaming query restarts after a failure, so committing an epoch that is
already committed
- * must have no effect, for exactly-once output. The ID of the query, which
create_writer gets for
- * a streaming write, stays the same when the query restarts from its
checkpoint, so the ID of the
- * query and the epoch ID identify the output of a micro-batch, also when
other queries write to
- * the same destination. A micro-batch streaming query creates a new stream
writer for each
- * micro-batch, and Spark closes the previous stream writer of the query
when the query creates a
- * new one. Spark is not told when a streaming query stops, so it closes the
last stream writer of
- * the query once the writer is no longer referenced, or when the query is
restarted. Streaming
- * writes are micro-batch only: a query with continuous processing fails
before create_writer.
+ * Spark creates a writer with create_writer, which also returns the state
of the writer. If Spark
+ * rejects what create_writer returned, such as a state set more than once,
it aborts the writer,
+ * with the field rejected of SparkDsFinishWriteInfo set, before it closes
it. With adaptive query
+ * execution, Spark first runs the stages of the data to write that end with
a shuffle, and
+ * creates no writer if they fail. The rest of the data, such as its last
stage, is computed in
+ * the tasks of the write, so a writer may still be aborted because
computing the data failed: do
+ * irreversible work, such as removing the existing data for an overwrite,
in commit. On the
+ * executors, each task creates a data writer with create_data_writer, with
the schema of the data
+ * and the state of the writer, calls write for each batch of at most
+ * spark.sql.execution.arrow.maxRecordsPerBatch rows, which also ends once
it holds
+ * spark.sql.execution.arrow.maxBytesPerBatch bytes, and calls
commit_data_writer, or
+ * abort_data_writer if the task or the commit fails before
commit_data_writer succeeded, before
+ * it closes the data writer. Then the driver calls commit with the messages
of the tasks if they
+ * all succeeded, or abort otherwise, including when commit fails, and
closes the writer. Several
Review Comment:
**1. [Low] The data writers of a killed attempt can be called after the
commit of the writer**
The Writes paragraph describes the calls on the data writers first, and then
says "Then the driver calls commit ... and closes the writer". The Order of the
calls section says nothing about the order between the data writers and their
writer. A library could read this as: when the writer's `commit` or `abort`
runs, every data writer of the write has finished.
That does not hold with speculative execution. When attempt A of a partition
commits and the last partition finishes, `runJob` returns, and the driver calls
`commit` and `close_writer`. `TaskSetManager` kills the other attempt B of that
partition with `interruptThread = true`. But B keeps running on its executor
until it sees the interrupt, or until `canCommit` denies it. Only then does
`DataWritingSparkTask` call `abort_data_writer` in its catch block and
`close_data_writer` in its finally block. So B can still call `write`,
`abort_data_writer` and `close_data_writer` after the writer was committed and
closed. The same happens when a job fails: the driver aborts the writer while
the other tasks of the stage are still being killed.
Some libraries would break then. For example, a library that removes a
staging directory in `commit` or `abort`, or that counts the open data writers
there. In local mode, a library might also free state at `close_writer` that
its data writers share with the writer. This PR documents the order that a
library can rely on, so could the header say this too? For example, in Order of
the calls: "The data writers of the task attempts that Spark kills, such as
speculative ones, may still be written to, aborted and closed after their
writer is committed or aborted, and closed."
##########
docs/sql-native-data-sources.md:
##########
@@ -101,32 +103,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,
+ plans the partitions with `stream_partitions`, which also returns the state
of the stream reader,
+ 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.
- **Writes**: on the driver, right before the tasks of the write (or of a
micro-batch) start, Spark
creates a writer with `create_writer`, which gets a `SparkDsWriteInfo` with
the mode of the write,
`SPARK_DS_WRITE_APPEND` or `SPARK_DS_WRITE_TRUNCATE`, and the ID of the
query of a streaming
- write. With adaptive query execution, it first runs the shuffle stages of
the data to write, and
- creates no writer if they fail, but the last stage runs in the tasks of the
write, so a writer can
- still be aborted because computing the data failed: do irreversible work,
such as removing the
- existing data for an overwrite, in `commit`. On the executors, each task
creates a data writer
- with `create_data_writer`, writes the batches of its data with `write`, and
calls
- `commit_data_writer`, or `abort_data_writer` if the task or the commit
fails. Then the driver
- calls `commit` with the messages of the tasks, or `abort` if the write
failed. A streaming write
- commits each micro-batch with its epoch ID, and the ID of the query stays
the same when the query
- restarts from its checkpoint: together, they identify the output of a
micro-batch, so that a
- library can skip an epoch that it already committed. A micro-batch streaming
query creates a new
- stream writer for each micro-batch, and Spark closes the previous one when
it creates the next
- one. Because Spark is not told when a streaming query stops, the last stream
writer of a query is
- closed once it is no longer referenced, or when the query is restarted.
+ write, and returns the state of the writer. With adaptive query execution,
it first runs the
+ shuffle stages of the data to write, and creates no writer if they fail, but
the last stage runs
+ in the tasks of the write, so a writer can still be aborted because
computing the data failed: do
+ irreversible work, such as removing the existing data for an overwrite, in
`commit`. On the
+ executors, each task creates a data writer with `create_data_writer`, which
gets the schema of the
+ data and the state of the writer in a `SparkDsDataWriterInfo`, writes the
batches of its data with
+ `write`, and calls `commit_data_writer`, or `abort_data_writer` if the task
or the commit fails.
+ Then the driver calls `commit` with the messages of the tasks, or `abort` if
the write failed.
Review Comment:
**3. [Low] The guide still says that a failed task aborts its data writer**
The header now says two things:
- Spark calls `abort_data_writer` only if the task or the commit fails
before `commit_data_writer` succeeded.
- The writer's `commit` or `abort` cleans up the output of the data writers
whose messages it does not get.
The Writes item of the guide still says "or `abort_data_writer` if the task
or the commit fails", and it does not mention that cleanup. The sentence at
L146-L149 says that Spark never aborts a data writer whose commit succeeded,
but not what a library has to do then. So a reader of the guide could expect
`abort_data_writer` to remove the output of a task that failed after its
commit, and make that output visible in `commit_data_writer`. That output would
then stay next to the output of the attempt that Spark retried. For example:
```suggestion
`write`, and calls `commit_data_writer`, or `abort_data_writer` if the
task or the commit fails
before `commit_data_writer` succeeded. Then the driver calls `commit` with
the messages of the
tasks, or `abort` if the write failed. A data writer whose task failed
after
`commit_data_writer` succeeded is neither aborted nor passed to `commit`,
so `commit` or `abort`
cleans up the output of the data writers whose messages it does not get.
```
##########
sql/core/src/test/resources/native-datasource/test_native_datasource.cc:
##########
@@ -1033,28 +1162,36 @@ 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}"); });
+int initialOffset(void*, const SparkDsInitialOffsetInfo* info, SparkDsSink
set_offset,
Review Comment:
**5. [Nit] `initial_offset` is not tracked, and no test makes a stream
reader fail**
`latest_offset`, `stream_partitions` and `commit_offset` now go through
`tracked`. But `initial_offset` is a function of the same stream reader handle,
and it still uses `guarded`. So it does not record a failure, and it does not
report a call after a failed one.
Also, as far as I can see, the option `fail` has no step for the functions
of a stream reader, or for `push_limit` and `prune_columns`. So the `failed`
path of these functions never runs in a test. No test checks that Spark only
closes a stream reader after one of them failed. The new checks would run if
`initial_offset` were tracked too, and if a step such as `latest_offset` were
added, with a streaming test that expects the query to fail.
##########
sql/core/src/test/scala/org/apache/spark/sql/execution/datasources/v2/ffi/NativeDataSourceTestUtils.scala:
##########
@@ -37,6 +37,29 @@ object NativeDataSourceTestUtils {
private val libraries = new ConcurrentHashMap[String, File]()
+ /**
+ * The file to which the test libraries append the calls that the order of
the calls of the
+ * header rules out, one per line. It does not exist as long as there are
none.
+ */
+ lazy val orderViolations: File = new File(buildDir, "order_violations")
+
+ /**
+ * Runs a test that uses the test libraries, and fails it if the libraries
got calls that the
+ * order of the calls of the header rules out. The calls recorded before,
such as by a task of an
+ * earlier test that was still being killed, are dropped.
+ */
+ def checkingOrderOfCalls[T](f: => T): T = {
+ orderViolations.delete()
+ val result = f
Review Comment:
**4. [Nit] `checkingOrderOfCalls` could wait for the tasks of the test**
This follows my round-2 suggestion. Losing a call that comes after the test
was the trade-off that I accepted there. Two smaller gaps remain, though:
- A late call can still fail an unrelated test, when it comes after the next
test deleted the file. An example is `abort_data_writer` from a task of a
failed job that is still being killed.
- When `f` throws, the violations that it recorded are not reported, and the
next test deletes them. A test that fails because of an order violation then
shows only the error that the violation caused.
The check could wait until no task runs before it reads the file. Late calls
would then fail the test that made them, which closes the first gap. For
example, `eventually` could wait until the `statusTracker` of
`SparkContext.getActive` reports no running task in `getExecutorInfos`, as
`checkDataWritersClosed` already waits for the data writers to close. For the
second gap, the violations could be added to the exception of `f` as suppressed.
##########
sql/core/src/test/resources/native-datasource/test_native_datasource.cc:
##########
@@ -727,6 +776,30 @@ void writeFile(const std::string& path, const std::string&
content) {
std::fclose(file);
}
+// Records a call that the order of the calls of the header rules out. Unlike
the files of the
+// options, a failure to record it is ignored: it may be called outside of
`guarded`, as in the
+// close functions, which cannot fail.
Review Comment:
**8. [Nit] Two comments of the test library do not match the code**
- This comment gives the close functions as an example of a call outside of
`guarded`, but no close function calls `orderViolation`. The calls outside of
`guarded` are the checks at the start of `push_limit`, `prune_columns`,
`partitions`, the stream reader functions, `commit`, `abort`, `write`,
`commit_data_writer` and `abort_data_writer`.
- The description of `ORDER_VIOLATIONS_PATH` at L84-L86 still says "which
the suite checks after each test". Three suites now check it at the end of each
test, with `checkingOrderOfCalls`.
##########
sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/v2/columnar/ColumnarWrite.scala:
##########
@@ -82,18 +82,24 @@ class ColumnarBatchWrite(createWriter: () =>
ColumnarWriter, schema: StructType)
extends BatchWrite {
private var writer: ColumnarWriter = _
+ // Whether the writer committed the write, after which it is not aborted,
even if Spark fails
+ // afterwards: a native data source relies on it.
+ private var committed = false
Review Comment:
**6. [Nit] The scaladoc of `ColumnarBatchWrite` says that any later failure
aborts the write**
L78-L79 still say that a failure in the tasks of the write, "as any later
failure, aborts the write". With this flag, a failure after a successful commit
no longer does. Could it say "as any later failure before the commit succeeds"?
Separately, the new sentence of `ColumnarWriter`, "It does not abort a write
whose commit succeeded.", can be read as if the writer does not abort.
`ColumnarStreamWriter` says "Spark does not abort ...", which is clearer.
##########
sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/v2/columnar/ColumnarWrite.scala:
##########
@@ -115,6 +121,8 @@ class ColumnarStreamingWrite(
extends StreamingWrite {
private var writer: ColumnarStreamWriter = _
+ // The epochs that the writer committed, which are not aborted, as for a
batch write.
+ private val committedEpochs = scala.collection.mutable.Set.empty[Long]
Review Comment:
**7. [Nit] `committedEpochs` holds at most one epoch**
For each micro-batch, `V2Writes` builds a new `MicroBatchWrite(batchId,
write.toStreaming)`, and so a new `ColumnarStreamingWrite`. Continuous
processing fails in `createStreamingWriterFactory`. So this set never has more
than one element. A `private var committed = false`, as in
`ColumnarBatchWrite`, would do the same and keep the two guards alike, without
a mutable set or its fully qualified name.
##########
sql/core/src/test/scala/org/apache/spark/sql/execution/datasources/v2/ffi/NativeDataSourceSuite.scala:
##########
@@ -706,14 +711,87 @@ class NativeDataSourceSuite extends QueryTest with
SharedSparkSession {
"action" -> "write to",
"name" -> "native_sink",
"msg" -> "commit_data_writer returned 2 values instead of one."))
- // The task failed without aborting the committed data writer, which it
closed, and the driver
- // aborted the write.
- assert(!new File(dir, "_ABORTED_COMMITTED_DATA_WRITER").exists())
+ // The task failed without aborting the committed data writer, which the
test library would
+ // record as a violation of the order of the calls, and closed it. The
driver aborted the write.
checkDataWritersClosed(dir)
assert(new File(dir, "_ABORTED").isFile)
assert(!dir.listFiles().exists(_.getName.startsWith("part-")))
}
+ nativeTest("keep the order of the calls whatever the callers of the adapter
do") {
Review Comment:
**2. [Low] The tests of the new guards only run on Java 22 or later**
Thank you for adding this test. It covers these guards:
- the `committed` flag of `ColumnarBatchWrite`;
- `committedEpochs` of `ColumnarStreamingWrite`;
- the `pruned` flag of `ColumnarScanBuilder`;
- `writeOrCommit` of `NativeDataWriter`.
The first three are in the generic adapter. But the test is a `nativeTest`,
so it is skipped unless there is a C++ compiler and Java 22 or later. The PR
builds run on Java 17 (the default `java` input of `build_and_test.yml`), and
the Java 21 builds skip it too. So only the scheduled Java 25 builds run it. A
change that drops the `!committed` check or the `pruned` guard would pass the
checks of its PR. My round-2 suggestion pointed at the native classes, so this
is partly on me.
The three adapter guards could also be tested in `ColumnarDataSourceSuite`,
which runs on any Java, next to "build and plan a batch scan once".
`TestSinkWriter` records the aborted tables in `TestColumnarSink.aborted`. So a
test could commit and then abort a `ColumnarBatchWrite` and a
`ColumnarStreamingWrite`. It could also prune a `ColumnarScanBuilder` twice,
with a counter of the `pruneColumns` calls added to the planner of
`TestRangeDataSource`. This test would then keep checking the side of the
library.
--
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]