Repository navigation
[SPARK-60101][SQL] Support native data sources implemented in Rust or C++ with the FFM API - #59424
HyukjinKwon wants to merge 28 commits into
Conversation
…(Rust, C++) data sources This adds the columnar data source API, a simpler way to plug a data source into Spark than implementing the Data Source V2 interfaces directly. It follows the Python Data Source API, but data is exchanged only as columnar batches: readers return ColumnarBatches, and writers receive Arrow-backed ColumnarBatches. Spark adapts a data source to Data Source V2, with batch and micro-batch reads, batch and streaming writes, and predicate, column and limit pushdown. A data source is implemented either on the JVM, with the interfaces of org.apache.spark.sql.datasource and a DataSourceProvider, or in native code such as Rust or C++, as a shared library that implements the native methods of NativeBridge as JNI functions and exchanges data through the Arrow C data interface. Spark loads each library into its own copy of NativeBridge, so libraries exporting the same functions can be loaded side by side, and Spark itself ships no native code. A native data source is distributed as a native data source package, a zip file with the extension .sparkpkg that contains a manifest and the library for each platform. Packages are added with spark.addArtifact, in Scala and Python, classic and Spark Connect, or found under spark.sql.dataSource.native.paths; executors get them through the artifacts of the session. The change also adds the spark.sql.dataSource.native.enabled and spark.sql.dataSource.native.paths configurations, the native data source error conditions, the arrow-c-data dependency, and a documentation page with examples in Scala, Rust and C++. Co-authored-by: Isaac <no-reply@databricks.com>
Downstream data sources are only native, so the JVM interfaces of the columnar data source API (DataSource, DataSourceReader, DataSourceWriter, DataSourceStreamReader, DataSourceStreamWriter and DataSourceProvider) are not public anymore. They become the internal ColumnarDataSource traits that the Data Source V2 adapter and the native data sources share, and the Javadoc of NativeBridge now specifies the whole interface on its own. The documentation page is renamed to "Native Data Sources". Co-authored-by: Isaac <no-reply@databricks.com>
A table created with CREATE TABLE ... USING ... OPTIONS (...) passes its options to TableProvider.getTable, while its scans and writes only get the options of their query. The columnar table now merges the options of the table under the options of each scan and write, so that SELECT and INSERT INTO on such a table use them. Co-authored-by: Isaac <no-reply@databricks.com>
…cally Like the Python data sources installed in the Python path, the native data source packages in the native-datasources directory of SPARK_HOME are now found without any configuration. The packages of a session, added with spark.addArtifact or under spark.sql.dataSource.native.paths, take precedence over the installed ones, like the Python data sources registered at runtime take precedence over the installed ones. Co-authored-by: Isaac <no-reply@databricks.com>
- Exclude the `org.apache.spark.sql.datasource` package, which has no
counterpart in the Spark Connect client, from the client compatibility
check, like the other packages specific to classic Spark.
- Escape the Rust and C++ examples of the native data sources page from
Liquid, which failed to parse `{{0, end / 2}, ...}` in the C++ example.
Co-authored-by: Isaac <no-reply@databricks.com>
Instead of the native-datasources directory of SPARK_HOME, find the library of a native data source where native libraries are usually installed, by its file name: the library spark_datasource_<name>, such as libspark_datasource_<name>.so on Linux, implements the data source <name>. Spark looks for it in the native library path: the directories of java.library.path, which include LD_LIBRARY_PATH on Linux, and then the lib directory of each installation prefix in PATH, such as /usr/local/lib for /usr/local/bin. The executors load the library from the same path as the driver, or else find it in their own native library path. The packages of a session still take precedence. Also: - Keep the error of a library that is loaded but rejected, because it cannot be loaded again in another class loader: using it again failed with "already loaded in another classloader". - Add NativeDataSourceConnectSuite, which tests native data sources with a Spark Connect client and server, and move the helpers that build the test library to NativeDataSourceTestUtils. Co-authored-by: Isaac <no-reply@databricks.com>
The Scala linter checks the format of the Spark Connect modules with scalafmt. Co-authored-by: Isaac <no-reply@databricks.com>
…d lifecycle - Skip and log invalid packages during the lookup, unless the manifest of the invalid package lists the requested data source. - Distribute a package when its reader or writer is created, in the active session, which is the cloned session of a streaming query; executors also check the copies in the files of the other sessions by checksum. - Route .sparkpkg files to the JVM in classic addArtifacts regardless of the flags, also in mixed lists, as Spark Connect does. - Do not push predicates on columns with non-binary collated strings, and document the Spark SQL semantics (NaN, -0.0, nulls) of fully evaluated predicates in NativeBridge. - Report a clear conflict when a different package with the same file name is already an artifact of the session. - Close the previous stream writer of a streaming query when it creates a new one, and document the stream writer lifecycle. - Keep the data writer handle when commitDataWriter fails, so that Spark aborts it with abortDataWriter. - Reuse the lookup of DataSource.lookupDataSource in NativeDataSourceV2. - Skip native data sources when registering a Python data source, which takes precedence over them. - Describe the scan by the name of the data source. Co-authored-by: Isaac <no-reply@databricks.com>
…m writers - Prefer a usable package or an installed library over an invalid package or a package of an unsupported interface version that lists the same data source, and only report those when nothing else provides it. - Clear the lookup kept for the provider at the start of every DataSource.lookupDataSource, so a provider never takes an earlier lookup. - Bound the cache of read packages, and forget the packages of a session when its artifacts are cleaned up. - Record writer closes in the test library and check end to end that each micro-batch closes the stream writer of the previous one, and that each micro-batch creates its stream writer with the ID of the query. Co-authored-by: Isaac <no-reply@databricks.com>
…batching - Resolve the provider of classic DataFrameWriter commands and of DataStreamWriter sinks with the session of the Dataset active, so that the lookups of Python and native data sources use that session. - Distribute a package that is not an artifact of the session as a copy named after its file name and checksum, which the lookups do not count as a package of the session: a configured package can be replaced in place, and packages with the same file name no longer collide. Remove the then unreachable NATIVE_DATA_SOURCE_PACKAGE_FILE_NAME_CONFLICT error. - Log a warning when a package is distributed without an active session. - Skip, and log, a configured directory that cannot be read. - Compare the data with the read schema as it round-trips through Arrow, which does not carry collations, interval fields and user-defined types. - Also end the write batches at spark.sql.execution.arrow.maxBytesPerBatch. - Document that a failed batch commit is aborted, which closes the writer, why NativeBridge.load is called reflectively, and the security of discovering libraries by file name. Co-authored-by: Isaac <no-reply@databricks.com>
…h limits once - Read the Arrow stream of a native library array by array, and reject an array with a non-zero offset at any level, which Arrow Java would read from the start of its buffers, with a NATIVE_DATA_SOURCE_ERROR that names the column. - Name the fields of the entries of maps `key` and `value` before creating the vectors, as ArrowColumnVector looks them up by name, so that maps built by arrow-rs can be read; reject decimal256, and report any other column that ArrowColumnVector cannot read with its name. - Create the writer of a batch or streaming write with its writer factory, after the stages that the write depends on ran, so that Spark commits or aborts each writer it creates, also when such a stage fails with adaptive execution. - Ask the reader for a limit only once, as NativeBridge documents, although Spark pushes the limit of LIMIT ... OFFSET twice. - Document these in NativeBridge and the native data sources page. Co-authored-by: Isaac <no-reply@databricks.com>
- Before importing a batch from a native library, check that each column has at least as many values as the batch has rows, each struct field as many as its struct, and the elements of each list or map as many as its last offset says. Arrow Java's import sets the length of every array to what its parent refers to and extends a shorter array with nulls, so such a column read as nulls, and only the lengths that the library exported show the mismatch. - Document in NativeBridge and the native data sources page that the buffers must hold all the values of their arrays, which Spark cannot check because the C data interface does not carry the sizes of buffers. Co-authored-by: Isaac <no-reply@databricks.com>
…f native data sources - NativeBridge states its compatibility rules, the guarantees of task attempts, epoch commits and replayed stream ranges, blocking reads, and the threads that release handles. - Only a fixed, documented set of predicates is pushed down. - List views are rejected, since their lengths are not checked. - Executors only load an installed library with the checksum of the driver's file. - Classic PySpark addArtifact passes URIs of packages to the JVM as is. - New NativeDataSourceClusterSuite, in local-cluster mode. Co-authored-by: Isaac <no-reply@databricks.com>
Replace the JNI interface, NativeBridge, with a plain C interface, spark_datasource.h, which Spark calls through the Foreign Function and Memory API. Native data sources then require Java 22 or later; with an earlier version, a query that uses one fails with NATIVE_DATA_SOURCE_UNSUPPORTED_JAVA_VERSION. Co-authored-by: Isaac <no-reply@databricks.com>
- Report a library that cannot be loaded because native access is denied, or because the FFM API cannot be initialized, as a load error with its root cause. - Report an empty spark_ds_last_error message with the error code. - Never throw from the sink upcall, and remove an unused field. - Document in spark_datasource.h that the close functions cannot fail, that NULL handles are errors, and the name passed for an installed library. - Show how to get spark_datasource.h from the spark-sql JAR, and catch all C++ exceptions in the C++ example. Co-authored-by: Isaac <no-reply@databricks.com>
…c-data arrow-c-data calls the callbacks of the Arrow C structs through a JNI library that it bundles for a few platforms only. Spark now exports and imports the schemas and the arrays, and reads the streams of native data sources, with the FFM API itself, in ArrowCData: arrays are still imported without copying their buffers, through ForeignAllocation, and the release callbacks of the structs that Spark exports are FFM upcalls. The FFM plumbing moves to Ffm, shared with NativeLibrary. This removes the arrow-c-data dependency that this change added, and rejects a stream that the library did not initialize instead of calling its NULL callbacks. Co-authored-by: Isaac <no-reply@databricks.com>
…t arrow-c-data" This reverts commit f55105d, to keep using the implementation of the Arrow C data interface that Arrow maintains, arrow-c-data, rather than one of Spark's own. Co-authored-by: Isaac <no-reply@databricks.com>
Arrow calls the callbacks of an Arrow C stream without checking them, so a library whose spark_ds_read returns success without initializing the stream would crash the JVM. Spark now reports it as an error instead. Co-authored-by: Isaac <no-reply@databricks.com>
…t line limit The Scala linter failed because a line of the doc comment had 99 characters, more than the 98 columns that scalafmt allows in sql/connect. Co-authored-by: Isaac <no-reply@databricks.com>
Mirrors aa0f3ba and e3ff80c of apache#59313, in the terms of spark_datasource.h: - Spark evaluates all the pushed predicates again after reading. The header states the rule for skipping data, and the jar contains predicate-cases.json, generated by a test from Spark's own evaluation. The JSON of the predicates is ASCII. - spark_ds_create_stream_writer gets the ID of the streaming query. - Offsets must be compact JSON on a single line. - An installed library replaced in place fails with INVALID_NATIVE_DATA_SOURCE_LIBRARY.REPLACED, and the caches identify files with their file key too. - Concurrent distributions of a configured package are serialized, a scan planned again after a failure fails with the same error, unsupported Arrow types and the link errors of Arrow are reported as NATIVE_DATA_SOURCE_ERROR, the column vectors of a read are not freeable between batches, packages of an unsupported version are logged, and PySpark addArtifact only passes file: URIs as they are. - Docs: the writer lifecycle, close functions, Arrow variants, a Memory section, broadcasts with AQE, replaced libraries, and the addArtifact Scaladoc. NativeLibrary already rejected NULL handles and missing offsets, so their tests use its errors. Co-authored-by: Isaac <no-reply@databricks.com>
The header is published like the headers of other C interfaces: a binary distribution contains it in include/, as a JDK contains include/jni.h, and libraries can copy it into their source tree, since it only depends on the C standard library. The spark-sql JAR still contains it, and the docs describe both ways to get it. Co-authored-by: Isaac <no-reply@databricks.com>
LuciferYang
left a comment
There was a problem hiding this comment.
Thanks for the FFM version. I compared the 30 bindings in NativeLibrary with spark_datasource.h, the test library and the Rust and C++ examples in the docs, and they match. The in-place replacement of an installed library, from my last review on #59313, now fails with INVALID_NATIVE_DATA_SOURCE_LIBRARY.REPLACED. I left one inline comment, on a failure path introduced by returning the commit message through the sink.
On testing: the tests that load a library need Java 22 or later, so on the Java 17 CI of a PR they are all canceled by assume, and the JUnit reports list them as passed. No FFM call in NativeLibrary runs on a PR, and a regression in the bindings would only show up in the scheduled Java 25 build after the merge. The Java 25 build with Maven job already builds Spark on Java 25, and it ran on this PR. Could it also run NativeDataSourceSuite, NativeDataSourceClusterSuite and NativeDataSourceConnectSuite? On the PR head, with the test JVM on JDK 26, NativeDataSourceSuite and NativeDataSourceClusterSuite pass, except the Java version test, which is canceled as expected. The GitHub runner has a C++ compiler.
…urce tests on Java 25 - spark_ds_commit_data_writer releases the data writer when it returns 0, but Spark then checks the message that it passed to the sink, and failed the task with the handle still set, so the task aborted the released data writer. The handle is now cleared as soon as the library returns 0, before the message is checked. - The Java 25 Maven job of a pull request also runs NativeDataSourceSuite, NativeDataSourceClusterSuite and NativeDataSourceConnectSuite: the other jobs, on Java 17, skip the tests that load a library. Co-authored-by: Isaac <no-reply@databricks.com>
|
@LuciferYang Thanks for checking the bindings against the header and the examples. On testing, good point: in e5352e1, the |
|
Thanks for moving to the C header, and for documenting the lifecycle so carefully. I have one concern, about how the header grows. I think it's worth settling before 4.4.0: version 1 is what library binaries get built against, and this is the one change that can't come later as an optional function. The problem: the header grows by one function per feature.
Proposal: freeze the C core, and move everything that grows into versioned messages. The header is already halfway there: 14 of its 30 functions pass bytes or JSON (partitions, state, commit messages, offsets, predicates). This finishes the move, with one schema. A sketch, with placeholder names: int32_t spark_ds_abi_version(void);
const char* spark_ds_last_error(void);
/* Opens a data source, reader, writer or data writer. The request says which, with what it
* needs, plus Spark's protocol version and features. The reply has the library's version and
* what this object supports. */
int spark_ds_open(const SparkDsHost* host, const uint8_t* request, int64_t length,
SparkDsSink reply, void* sink, void** handle);
/* Every control operation: schema, pushdown, partitions, offsets, commit, abort. */
int spark_ds_call(void* handle, const uint8_t* request, int64_t length,
SparkDsSink reply, void* sink);
/* Every data operation, as Arrow C streams: a read fills `out`, a write consumes `in`. */
int spark_ds_stream(void* handle, const uint8_t* request, int64_t length,
struct ArrowArrayStream* in, struct ArrowArrayStream* out);
/* Releases any handle. Nothing else does. */
void spark_ds_close(void* handle);
How the planned additions would land:
What it buys:
What it costs:
Precedents:
Could version 1 take this shape? I'm happy to write the |
…lities Follows the review of the shape of version 1, so that it can grow without new exported functions or signatures: - A library exports only spark_ds_init, which gets SparkDsHost (the newest ABI version that Spark supports, the version of Spark, and a log function) and returns its function table, SparkDsLibrary. A library leaves the functions it does not implement NULL. - The table and the structs that Spark passes start with their size, and only get fields appended within an ABI version: Spark only reads the functions that fit in the size of a table, and a library only reads the fields that fit in the size of a struct. - read and create_data_writer get SparkDsTaskContext, with the IDs of the task and is_interrupted, valid until the stream is released or the data writer is closed. create_writer, which replaces create_writer and create_stream_writer, gets SparkDsWriteInfo, with the write mode (SPARK_DS_WRITE_APPEND or SPARK_DS_WRITE_TRUNCATE) and the query ID. - create returns the capabilities of each data source. Spark reports an operation that a data source does not declare as unsupported without calling the library, only passes SPARK_DS_WRITE_TRUNCATE with SPARK_DS_TRUNCATE, and fails on the driver if a declared capability needs a NULL function. - push_predicates answers SPARK_DS_PREDICATE_UNUSED, PARTIAL or COMPLETE for each predicate. Spark shows the used predicates as pushed, and still evaluates all of them. - Each handle is released by the close function of its kind only: commit_data_writer no longer releases the data writer, and Spark calls the new close_data_writer after the commit or abort. The docs and their Rust and C++ examples use the table. New tests check the layout of the header against the bindings, the capabilities, a table that ends early, the host, the task context, a read that only ends once is_interrupted is true, and libraries that spark_ds_init does not initialize. Co-authored-by: Isaac <no-reply@databricks.com>
|
@tdcmeehan Thank you for the detailed proposal, and for offering to write the
Your table, in these terms:
Why I would keep typed functions rather than messages:
Two costs that messages would avoid remain: a new operation is still a new function to specify, and a host that runs a library in another process would marshal each function instead of forwarding bytes. Tests: Does this address your concern? If you still think the control calls should be messages, could you point to the addition that you think does not fit a table? |
…efixed struct create was the one entry point whose inputs could only grow with a second function, since it took the name and the options as separate arguments. It now takes a SparkDsCreateInfo, which starts with its size like the other structs that Spark passes, so that later releases can append fields, for example references to credentials instead of the credentials themselves in the options. The test library and the examples in the docs read the struct, and the layout test covers it. Co-authored-by: Isaac <no-reply@databricks.com>
|
Thanks for fixing the data writer so quickly, with a test, and for running the native suites on Java 25 in the CI of pull requests. The function table also makes the header easier to build against than JNI: the compiler checks every function a library provides. Speaking as one of the Lance maintainers, I'd like to share how this looks from lance-spark, since Lance is one of the motivating examples in the description, and hear your thoughts. For reads and writes, lance-spark already goes through the Rust core of Lance: the Lance Java bindings fill an Most of what users get from lance-spark is beyond reads and writes: the This is also where our cost of supporting several Spark releases is. lance-spark has a module for each Spark version from 3.4 to 4.2, but the read and write code is shared. What differs between versions is mainly the SQL extensions (parser and optimizer rules) and the row-level operations. For example, Spark 4.2 plans an insert-only Where native data sources look like a clear win to me is a source that only has a native client and only needs reads and writes, such as an in-house storage system in C++ or a vendor's C SDK. Could the description and the docs lead with that kind of source, and with a concrete one if this work started from one? Your reply to @tdcmeehan lists what could be appended to the table later, such as statistics, aggregate and top-N pushdown, and overwrite by filter. For a source like Lance, table functions and row-level operations matter as much. Do you see them in scope for the table later, or out of scope on purpose? Saying so in the docs would help library authors tell early whether it fits their source. None of this blocks the implementation; it is only about how the feature is positioned. |
…cope A native data source fits a source whose client exists only in native code, and that Spark needs to read and write. The docs now lead with that in a new "When to Use a Native Data Source" section, which also says that extensions outside of Data Source V2, such as table functions and SQL commands, are out of the scope of the interface. The Limitations section also lists top-N pushdown, row-level operations and metadata columns. Co-authored-by: Isaac <no-reply@databricks.com>
|
@LuciferYang Thank you, the comparison with lance-spark is very helpful. I agree with the positioning. In 51af65c, the docs have a new "When to Use a Native Data Source" section, which leads with a source whose client exists only in native code, such as an in-house storage system in C++ or the C SDK of a vendor, and that Spark needs to read and write. The PR description leads with it too, and no longer lists Lance among the motivating examples. This work did not start from one specific source: the goal was to let a data source be written only in native code, which is why the examples in the docs read a range of numbers. On scope, the line I would draw is Data Source V2 itself:
|
|
Moving my comment from #59313 here, updated for the C header. Thanks for this. The plain C ABI also makes native data sources a good fit for native execution engines running inside Spark, such as Apache Gluten (Velox) and Comet (DataFusion): as the description says, other hosts can load the same libraries. The library reads the data, and the engine runs the rest of the query natively. What's missing is on the Spark side. Today, a query on a native data source with Gluten enabled looks like this: That's two extra copies, so turning on a native engine can make these queries slower. With a small hook, the data could stay columnar in native memory from the source to the shuffle: A source library written once would then run on vanilla Spark and get native downstream execution under Gluten or Comet, without per-source connector work in each engine. Two small requests:
Optional, could be a follow-up: a columnar write entry point (the internal If this direction sounds reasonable, I'm happy to help review. |
|
I opened a companion Rust SDK prototype in #59520 for this PR's C ABI, pinned to The SDK handles the function table, plan serialization, provider errors and unwinding panics, and Arrow ownership. It includes a Range example, packaging instructions, and passing standalone Rust/C tests. End-to-end Spark validation is still pending. Feedback on the connector API and lifecycle is welcome! |
|
Following up on my two DSv2 adapter suggestions on #59313, after checking this PR at
These are internal adapter suggestions. The Rust-side planner/factory split in #59520 complements them, but does not change these Scala classes. |
… data source in its table Following the review: a batch scan of a ColumnarDataSource is planned by a driver-only ColumnarBatchPlanner, which gets the pushdown, and whose plan() returns the partitions with a serializable reader factory. For a native data source, the planner holds the reader of the library, and the reader factory only has the location of the library, the name of the data source, the read schema and the state of the reader. A scan builder builds its scan once, which takes over the planner, and a batch still plans once, keeping a failure to throw it again. ColumnarTable.capabilities() asks the data source with the options of the table. A native data source reports BATCH_WRITE and TRUNCATE as it declares them, so Data Source V2 rejects an unsupported batch write or overwrite while it analyzes the query, before it computes the data to write. It always reports reads and streaming writes, which Data Source V2 would otherwise handle as a Data Source V1 source. Each operation still checks the capabilities with its own options. The header also allows the callbacks of the stream of read to be called from any thread, never concurrently. Co-authored-by: Isaac <no-reply@databricks.com>
|
@gengliangwang Thank you for both suggestions, and for the Rust SDK prototype in #59520.
The test |
|
@zhouyuan Thank you, running a native data source under Gluten or Comet without copies is a good use of the C ABI.
Columnar write entry point: also a follow-up. The data writers of the adapter already take Arrow-backed I'd be glad to have your review on both follow-ups. |
|
@dongjoon-hyun @sarutak @gerashegalov You approved #59313, which this PR replaces, so the approvals there do not carry over here. Could you take another look? Since #59313, which this PR includes up to e3ff80c, the changes are:
The "Differences from #59313" table at the top of the description summarizes the interface. CI is green on 534dfca. |
This is what @zhztheplayer's proposal SPARK-57468 tries to solve exactly. We still need couple of PRs in Spark + Gluten to achieve this. I implemented a prototype here |
zhztheplayer
left a comment
There was a problem hiding this comment.
@HyukjinKwon Thanks for working on this. Glad to see the community getting more active on native support.
Worth to mention or not, but I found that this PR combines 2 responsibilities:
- Native library infrastructure: package discovery, platform selection, distribution, checksum verification, extraction, and loading.
- Data source integration: provider names, capabilities, schema, pushdown, and reader/writer lifecycle.
Do you think it's possible to make the first part (native library infrastructure) general-purpose? Perhaps also keep the .sparkpkg infrastructure as a separate feature so that third-party plugins like Gluten can directly leverage it, regardless of whether they use FFM or JNI to implement the bindings.
Also, building on this PR’s columnar table and writer support, do you think refactoring V2TableWriteExec to accept columnar input directly would be a desirable follow-up? That would let upstream columnar operators feed batches into the native writer without going through rows and rebuilding Arrow batches. It’s a long-standing need for Gluten as well.
| array.release() | ||
| throw e | ||
| } | ||
| call(name, "read")(Data.importIntoVectorSchemaRoot(allocator, array, root, dictionaries)) |
There was a problem hiding this comment.
I felt this may cause bug since reusing a fixed VectorSchemaRoot can cause data corruption when the output of NativeDataSource is accumulated by the downstream operators (example), since the new data will replace the content of the old vectors in early ColumnarBatch instances.
It might be worth to try the root-free Data.importVectorSchemaRoot APIs that creates fresh roots internally?
There was a problem hiding this comment.
Thanks, this is the bug that @FelixYBW reported. A fresh root for each batch, as Data.importVectorSchemaRoot creates, would not fix it alone: the reader would still have to release each root at some point, and nothing tells it when a consumer is done with a batch. ColumnarBatch lets a reader reuse its vectors, and @sarutak showed that ArrowCachedBatchSerializer, which creates a root for each batch and closes the previous one in next(), fails in the same way. So the fix is in the consumer, which kept references to the pass-through columns: SPARK-60294, #59551, copies them. This PR keeps one root, and adds a test of Arrow UDFs over a native data source in 74aa0df.
Yes, I am working on the PR #59154 as a fix. It will help Spark wire legacy columnar operators and Arrow-based operators (like what this PR is adding) correctly. |
sarutak
left a comment
There was a problem hiding this comment.
Thanks for the FFM version, @HyukjinKwon. I reviewed 534dfca by building it with Java 22 and running two libraries against it: the test library of this PR, and a small probe library written against spark_datasource.h that logs what Spark passes to it and returns some malformed data on purpose. Besides the item below, I left inline comments on:
- Data that Spark accepts but reads wrongly, without an error: a batch with
n_children = 0, and data with nulls in a column thatschemadeclares not nullable. - The predicate contract: how float and string literals are encoded, and the rule for using a part of a predicate.
- Two version 1 decisions that are hard to change after 4.4.0: the
abiVersionof the manifest, and the set of Arrow types that reach a library.
Arrow UDFs over a native data source
I reproduced @FelixYBW's report on 534dfca with the test library (native_range, one partition). With end=200, batch_size=100, the query fails with IndexOutOfBoundsException: index: 0, length: 1 (expected: range(0, 0)), and with end=2000000, batch_size=1000, the check fails with MISMATCH id 111000 uid 0. With spark.sql.execution.arrow.pythonUDF.columnarInput.enabled=false, the results are correct.
The root cause is not in this PR but in the existing columnar input path of ArrowEvalPythonExec; this PR adds a source that runs into it with the default configuration. The Arrow columnar path of ColumnarArrowEvalPythonEvaluatorFactory (SPARK-56350, in 4.2.0, enabled by default) keeps the pass-through ColumnVectors of each input batch in passThruQueue until the Python result of that batch comes back, based on the assumption in its comment that "Arrow vectors are independently allocated per batch". A columnar PartitionReader does not promise that. ColumnarDataSource.scala:100 in this PR says that the batches "only need to be valid until the next one", and NativePartitionReader imports every batch into the same VectorSchemaRoot (NativeDataSource.scala:633) and closes it as soon as DataSourceRDD sees the end of the partition.
The same failure happens without a native data source. With spark.sql.cache.serializer=org.apache.spark.sql.execution.columnar.ArrowCachedBatchSerializer, which closes the previous root in next(), spark.range(0, 2000000, 1, 1).cache() with spark.sql.inMemoryColumnarStorage.batchSize=1000 and the same UDF fails with the same IndexOutOfBoundsException on the same build. (200 rows in batches of 100 worked there; I did not look into how far the input runs ahead in that case.)
So I think the fix belongs in the columnar input path, in a separate JIRA (I could not find an existing one): it must not keep references to an input batch after the producer has moved on, for example by transferring or copying the pass-through vectors, or by falling back to the row queue path. cc @viirya, since this is the path of SPARK-56350. As far as I can see, #59154 (SPARK-57468), which @zhztheplayer mentioned above, is about the conventions of the batches and the transitions between operators, and does not change this path, so the lifetime of the input batches would still need its own fix.
For this PR, two questions:
- Should it wait for that fix, or send the batches of a native data source to the row queue path until then? With the default configuration, any scalar Arrow or pandas UDF on a column of a native data source runs into this today.
- Could it add a test that runs an Arrow UDF over a native data source, with one batch and with several, and checks the pass-through columns against the UDF results? No test of this PR goes through
ArrowEvalPythonExec, which is why this was not caught.
| 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.") | ||
| } | ||
| children(batch).zip(root.getSchema.getFields.asScala).foreach { case (column, field) => |
There was a problem hiding this comment.
checkArrays only checks the children that an array has, so a batch with n_children = 0 skips the checks of all its columns. Arrow Java does not catch it either: ArrayImporter.doImport only compares the number of children with the vector when there is at least one child. I tried it with a library that returns a batch of 4 rows with n_children = 0: the scan returns 4 rows in which every column is null, without an error. These are the same silent nulls as the ones that the length check of a8f178b prevents.
Could the checks of the batch and of each column also require n_children to be the number of fields of the type, and the child pointers to be non-NULL, before children reads them? With an n_children larger than the array of pointers, children would also read past that array before Arrow gets to see it.
There was a problem hiding this comment.
Thanks, fixed in 74aa0df. checkArrays now requires the batch, and each array at every level, to have as many children as its type has fields, with non-NULL pointers, before it reads them, so it does not read past the array of pointers either. The test library can now export batches without their columns (short=children), and reject the Arrow data that Spark cannot read checks that the read fails with The batch has 0 child arrays, but the schema has 7 columns.
| val matches = actual.length == expected.length && actual.zip(expected).forall { | ||
| case (actualField, field) => | ||
| actualField.name == field.name && | ||
| DataType.equalsIgnoreNullability(actualField.dataType, field.dataType) |
There was a problem hiding this comment.
The nullability of the columns comes from schema of the library (NativeDataSource.schema), and this comparison ignores nullability, so nothing checks that the data agrees with the schema. I tried a library whose schema returns id as not nullable, while its stream declares id nullable and has nulls in 2 of 4 rows:
collect()fails withjava.lang.IllegalStateException: Value at index is nullfromBigIntVector.get, which does not say what is wrong.filter(isnull(col("id"))).count()returns 0 instead of 2, without an error, since the optimizer foldsisnullof a non-nullable column to false.- With
-Darrow.enable_null_check_for_get=false, which @FelixYBW suggests on [SPARK-60101][SQL] Support native data sources implemented in Rust or C++ #59313 for performance,collect()no longer fails: it returns the values in the data buffer for the null rows (101 and 103 with my library), without an error.
Could Spark fail with NATIVE_DATA_SOURCE_ERROR when a column that the schema to read declares not nullable has a null_count other than 0 (it is in the array, so the check is cheap; a null_count of -1 needs a pass over the validity buffer)? Or make the inferred schema nullable. Either way, the header could say that the data must agree with the nullability of the schema.
There was a problem hiding this comment.
Thanks, fixed in 74aa0df. After importing a batch, Spark now checks that the data agrees with the nullability of the schema to read, at every level: a column, a struct field, an element or a map value that the schema declares not nullable must have no null where its parent is not null, and map keys none at all. It starts from the null counts of the vectors, so only a column that has nulls is scanned further. A null fails the read with NATIVE_DATA_SOURCE_ERROR, such as The column name has a null, but the schema to read declares it not nullable., and the header says that the data must agree with the nullability of the schema. I checked the data rather than making the inferred schema nullable, since a user can also declare a column NOT NULL in the schema to read, and the schema of a table in a catalog also applies to its writes. Test: reject the data that has nulls where the schema to read has none.
| case (n: Short, ShortType) => Some(node.put("value", n)) | ||
| case (n: Int, IntegerType | DateType) => Some(node.put("value", n)) | ||
| case (n: Long, LongType | TimestampType | TimestampNTZType) => Some(node.put("value", n)) | ||
| case (n: Float, FloatType) if !n.isNaN && !n.isInfinite => Some(node.put("value", n)) |
There was a problem hiding this comment.
Two literal encodings let a library that follows the header skip rows that match:
- A float literal is written as the shortest decimal of the float:
col("f") == lit(0.1).cast("float")pushes{"type":"literal","dataType":"float","value":0.1}. A reader that parses JSON numbers as doubles, as most JSON parsers do, and widens the float column to compare it, finds0.10000000149011612 = 0.1false and skips the matching row. Could the header say that a literal must be read at the precision of itsdataType, binary32 for float, and that the values of the integer types, dates and timestamps are exact 64-bit integers, which a double does not hold above 2^53?predicate-cases.jsonhas no float literal, so it does not catch this. s.toStringreplaces invalid UTF-8 with U+FFFD:col("s") == expr("cast(X'FF' as string)")pushes"value":"\uFFFD". Spark compares the bytes, so a reader that comparesEF BF BDwith its statistics can skip a file whose strings contain the byteFF. Could a string literal that is not valid UTF-8 (!s.isValid) make the predicate not pushed, as for the other values that cannot be expressed?
There was a problem hiding this comment.
Thanks, both fixed in 74aa0df:
- A float literal is now written as the double of the same value,
0.10000000149011612for0.1f, which a reader that parses JSON numbers as doubles gets exactly, and which a reader that parses it as a float reads back as the same float. The header says to read a literal at the precision of its data type, and that the values of the integer types, dates and timestamps are exact 64-bit integers.predicate-cases.jsonhas a float column now, withf = CAST(0.1 AS FLOAT)andf > CAST(0.1 AS FLOAT), and writes the floats of its rows the same way. - A string literal that is not valid UTF-8 is no longer pushed, and the header says so.
| * - To use only a part of a predicate, a reader may take the parts that it does not support as | ||
| * true, but not under a NOT, where that would drop rows. |
There was a problem hiding this comment.
The rule for using a part of a predicate is only safe under AND and OR, but Spark also pushes predicates as children of = and <=>. (col("id") > 101) == (col("id") < 103) is pushed as {"type":"function","name":"=","children":[{..."name":">"...},{..."name":"<"...}]}, and (col("id") > 101).eqNullSafe(lit(True)) as <=> with a predicate child. Taking a part as true there can drop rows. For (id > 101) = (id < 101), the row with id = 101 matches (false = false), but a reader that takes id < 101 as true evaluates false = true and skips it.
Could the rule say that a reader may only take a part as true when every function between that part and the root is AND or OR, and must otherwise not use the predicate? predicate-cases.json could have one such case.
There was a problem hiding this comment.
Thanks, fixed in 74aa0df. The rule now says that a reader may only take a part as true when every function between that part and the root of the predicate is AND or OR, with your example, and predicate-cases.json has (id > 3) = (id < 3), which is true for the row whose id is 3.
| .map(NativeDataSourcePackage.tryRead).partitionMap(identity) | ||
| // A package of a version of the interface that Spark does not support is unusable too. | ||
| val (unsupported, usable) = valid.partitionMap { pkg => | ||
| if (pkg.manifest.abiVersion == NativeLibrary.ABI_VERSION) Right(pkg) else Left(pkg) |
There was a problem hiding this comment.
A package declares a single abiVersion, and Spark only uses a package whose abiVersion is the version of Spark, while the header lets a library implement several versions and choose one in spark_ds_init (spark_datasource.h L676). Once there is a version 2, a library that implements both cannot say so in its manifest. With "abiVersion": 2, Spark 4.4 reports UNSUPPORTED_ABI_VERSION without calling spark_ds_init, although the library would return a version 1 table. With 1, the field no longer means "the version that the library implements", as the docs say. Installed libraries have no manifest, so only packages have this problem.
The manifest of 4.4.0 is what all later packages have to work with, so could it be settled now? For example, the manifest could list the versions that the library implements, such as "abiVersions": [1], and Spark would use a package if it supports one of them. Or the docs could define abiVersion as the oldest version that the library implements, so that a later release can add a field for the others: parseManifest already ignores fields that it does not know, which the docs could also promise.
There was a problem hiding this comment.
Thanks, settled in 74aa0df with your second option: abiVersion is the oldest version that the library implements. Spark uses a package whose abiVersion is at most the newest version that it supports, and spark_ds_init returns a table of a version that both support. So a library that implements 1 and 2 declares 1, and a later Spark that supports only 2 and 3 still loads it, and gets a table of version 2. The docs promise that Spark ignores the fields of the manifest that it does not know, so a later release can add one, for example for the newest version. The manifest must also have a positive abiVersion, and names without U+0000.
| * parent refers to, such as a column shorter than its batch. The buffers of an array must hold | ||
| * all of its values, as the C data interface requires: it does not carry the sizes of the | ||
| * buffers, so Spark cannot check them, and reads past the end of a buffer that is too short. | ||
| * - Schemas. Spark types map to Arrow types as in spark.sql.execution.arrow: for example, bigint |
There was a problem hiding this comment.
The Arrow types are defined "as in spark.sql.execution.arrow", which is internal and keeps growing, and NativeArrow.exportSchema has no allowlist, so types that the header does not describe reach the library. With a library that logs the schema that create_writer gets:
- a variant column arrives as a struct
+sof twozfields,valueandmetadata, with the metadatavariant=trueonmetadata; - a
timecolumn arrives asttn, with the metadataSPARK::time::precision=6; - a day-time interval arrives as
tDu.
The header does not tell a library what these mean, and once 4.4.0 is released, these encodings are part of version 1, also when ArrowUtils changes them for Python. Could the header list the Spark types of version 1 with their Arrow types, and could Spark reject the other types on the driver with NATIVE_DATA_SOURCE_ERROR before it passes a schema to the library, in create_reader and create_writer? A type could then be added later, with a capability.
There was a problem hiding this comment.
Thanks, done in 74aa0df. The header now lists the Spark types of version 1, with their Arrow types and formats: boolean, the integer types, float and double, decimal(p, s) as decimal128, string, binary, date, timestamp, timestamp_ntz, and array, map and struct. Spark fails an operation whose schema has another type with NATIVE_DATA_SOURCE_ERROR before it passes the schema to the library: the schema to read before create_reader and create_stream_reader, the data to write before create_writer, and a schema that schema returns. So variants, times and intervals do not reach a library, and a later version can add them for the data sources that declare a capability for them. Test: reject the types and the strings that a library cannot get, with a day-time interval in a read, and an array of variants in a write, which fails before create_writer.
| } | ||
|
|
||
| /** Copies the string as a NUL-terminated UTF-8 string. */ | ||
| long string(String value) { |
There was a problem hiding this comment.
Nit: string copies value.getBytes(UTF_8), so a NUL in a string ends it early for the library: an option value "ab\u0000cd" reaches create as "ab", and "\u0000" as an empty string, without an error. Could Spark reject option keys and values that contain U+0000 with NATIVE_DATA_SOURCE_ERROR, or the header say what happens to them?
There was a problem hiding this comment.
Thanks, fixed in 74aa0df. create now fails with NATIVE_DATA_SOURCE_ERROR when an option key or value contains U+0000, and so does an operation whose schema has a column name with it. The data source names of a manifest cannot contain it either. The header says so.
…pes and literals of version 1 Following the review: - checkArrays requires the batch, and each array at every level, to have as many children as the fields of its type, with non-NULL pointers, before it reads them: Arrow Java imported a batch with n_children = 0 as columns of nulls. - After importing a batch, Spark checks that the data agrees with the nullability of the schema to read, at every level, and fails the read otherwise: a column declared not nullable with nulls was read wrongly, or folded away by the optimizer. - The header lists the Spark types of version 1 with their Arrow types. Spark fails an operation whose schema has another type, or a column name with U+0000, before it passes the schema to the library, and fails create for option keys and values with U+0000. - A float literal is written as the double of the same value, a string literal that is not valid UTF-8 is not pushed, and the header says to read literals at the precision of their type. A reader may only take a part of a predicate as true under AND and OR. predicate-cases.json has a float column, and a predicate under =. - The abiVersion of a manifest is the oldest version that the library implements: Spark uses a package whose version is at most the newest that it supports, and ignores the fields of a manifest that it does not know. - A test runs an Arrow UDF over a native data source. It needs SPARK-60294. Co-authored-by: Isaac <no-reply@databricks.com>
|
@sarutak Thank you for the thorough review, with a probe library. All the inline comments are addressed in 74aa0df, with replies there. On Arrow UDFs: I agree that the root cause is in the columnar input path, and thanks for showing it with
@zhztheplayer Thanks for the review.
|
… by default ### What changes were proposed in this pull request? This PR disables `spark.sql.execution.arrow.pythonUDF.columnarInput.enabled` by default, and makes `ArrowEvalPythonExec.doExecute` check it too. With it enabled, the Arrow columnar path of `ColumnarArrowEvalPythonEvaluatorFactory`, added by SPARK-56350, keeps the pass-through `ColumnVector`s of each input batch until the Python result of that batch comes back, which can be many batches later. That is only correct if the batch stays valid until then, which a columnar `PartitionReader` does not promise: `get()` only returns the same value until `next()` is called. A reader may refill the same vectors for every batch, or release them in `close()`, which `DataSourceRDD` calls as soon as the reader is exhausted, since SPARK-55715. With columnar input disabled, `ColumnarToRow` converts the batches to rows before the UDFs, as before SPARK-56350. But `ArrowEvalPythonExec.doExecute` took the columnar input path whenever its child supported columnar, whatever the configuration: a child that also returns rows, such as the scan of the Arrow cache, gets no `ColumnarToRowExec`, so the Arrow cache failed even with columnar input disabled. `doExecute` now checks `supportsColumnar`, which includes the configuration, so that such a child returns rows. As discussed in the review, the Arrow columnar path will instead respect the vectors that their producer reuses, with a marker interface that viirya proposed, which can enable columnar input again on master. This PR is the fix for the maintenance branches, and for master until then. It also adds two options to the test source `ArrowBackedDataSourceV2`: `reuseVectors`, under which its reader refills the same vectors for every batch and closes them when it is closed, and `batchSize`. ### Why are the changes needed? With columnar input enabled, a scalar pandas or Arrow UDF on a column of a source whose Arrow vectors are refilled or released fails with `IndexOutOfBoundsException: index: 0, length: 1 (expected: range(0, 0))`, or pairs the pass-through values of a later batch with the UDF results of an earlier one. It was reported by FelixYBW and reproduced by sarutak in #59424 with native data sources, whose reader imports every batch into the same vectors, and also without them, with `ArrowCachedBatchSerializer`: `spark.range(0, 2000000, 1, 1).cache()` and the same UDF, over 200 batches of 10000 rows (`spark.sql.execution.arrow.maxRecordsPerBatch`), fails with the same exception, also with columnar input disabled before this PR. As sarutak found, the Arrow columnar case of the benchmark of SPARK-56350, `bench_arrow_columnar_udf.py`, fails the same way on master and on v4.2.0, since SPARK-55715: its source frees its vectors only when it is closed, which `DataSourceRDD` now does before the last results come back from Python. ### Does this PR introduce _any_ user-facing change? Yes. By default, Python UDFs on the columns of an Arrow-backed source no longer fail or return wrong results: Spark converts the batches to rows before the UDFs, as before 4.2.0. Setting `spark.sql.execution.arrow.pythonUDF.columnarInput.enabled` to true enables columnar input again, for upstream operators whose batches stay valid until their results come back. The migration guide says so, for 4.2.1 and 4.4. ### How was this patch tested? - A new test in `ArrowColumnarPythonUDFSuite`, `Arrow-backed source that reuses its vectors: correct with the default configuration`, runs a UDF on a string column, over 2 batches and over 2000 batches of 100 rows of a source that refills its vectors. It checks that `ArrowEvalPythonExec` does not take columnar input, and the pass-through columns of every row against its UDF result. With columnar input enabled, it fails with the `IndexOutOfBoundsException` above in both cases. - A new suite, `ArrowCachedPythonUDFSuite`, runs a UDF over 200 batches of 1000 rows of the Arrow cache with the default configuration, and checks that `ArrowEvalPythonExec` reads rows although its child supports columnar, and the pass-through column of every row against its UDF result. Without the change to `doExecute`, it fails with the `IndexOutOfBoundsException` above. - The other tests of `ArrowColumnarPythonUDFSuite`, which test the columnar input path, enable it, as does `ArrowPythonUDFCachedInputTests` in PySpark, which tests it over the Arrow cache. Both suites pass (8 tests), as do `scalastyle` and `ruff`. ### Was this patch authored or co-authored using generative AI tooling? Generated-by: Claude Code (Claude Opus 5.5) This pull request and its description were written by Isaac. Closes #59551 from HyukjinKwon/SPARK-60294. Authored-by: Hyukjin Kwon <gurwls223@apache.org> Signed-off-by: Hyukjin Kwon <hyukjin.kwon@databricks.com>
… by default ### What changes were proposed in this pull request? This PR disables `spark.sql.execution.arrow.pythonUDF.columnarInput.enabled` by default, and makes `ArrowEvalPythonExec.doExecute` check it too. With it enabled, the Arrow columnar path of `ColumnarArrowEvalPythonEvaluatorFactory`, added by SPARK-56350, keeps the pass-through `ColumnVector`s of each input batch until the Python result of that batch comes back, which can be many batches later. That is only correct if the batch stays valid until then, which a columnar `PartitionReader` does not promise: `get()` only returns the same value until `next()` is called. A reader may refill the same vectors for every batch, or release them in `close()`, which `DataSourceRDD` calls as soon as the reader is exhausted, since SPARK-55715. With columnar input disabled, `ColumnarToRow` converts the batches to rows before the UDFs, as before SPARK-56350. But `ArrowEvalPythonExec.doExecute` took the columnar input path whenever its child supported columnar, whatever the configuration: a child that also returns rows, such as the scan of the Arrow cache, gets no `ColumnarToRowExec`, so the Arrow cache failed even with columnar input disabled. `doExecute` now checks `supportsColumnar`, which includes the configuration, so that such a child returns rows. As discussed in the review, the Arrow columnar path will instead respect the vectors that their producer reuses, with a marker interface that viirya proposed, which can enable columnar input again on master. This PR is the fix for the maintenance branches, and for master until then. It also adds two options to the test source `ArrowBackedDataSourceV2`: `reuseVectors`, under which its reader refills the same vectors for every batch and closes them when it is closed, and `batchSize`. ### Why are the changes needed? With columnar input enabled, a scalar pandas or Arrow UDF on a column of a source whose Arrow vectors are refilled or released fails with `IndexOutOfBoundsException: index: 0, length: 1 (expected: range(0, 0))`, or pairs the pass-through values of a later batch with the UDF results of an earlier one. It was reported by FelixYBW and reproduced by sarutak in #59424 with native data sources, whose reader imports every batch into the same vectors, and also without them, with `ArrowCachedBatchSerializer`: `spark.range(0, 2000000, 1, 1).cache()` and the same UDF, over 200 batches of 10000 rows (`spark.sql.execution.arrow.maxRecordsPerBatch`), fails with the same exception, also with columnar input disabled before this PR. As sarutak found, the Arrow columnar case of the benchmark of SPARK-56350, `bench_arrow_columnar_udf.py`, fails the same way on master and on v4.2.0, since SPARK-55715: its source frees its vectors only when it is closed, which `DataSourceRDD` now does before the last results come back from Python. ### Does this PR introduce _any_ user-facing change? Yes. By default, Python UDFs on the columns of an Arrow-backed source no longer fail or return wrong results: Spark converts the batches to rows before the UDFs, as before 4.2.0. Setting `spark.sql.execution.arrow.pythonUDF.columnarInput.enabled` to true enables columnar input again, for upstream operators whose batches stay valid until their results come back. The migration guide says so, for 4.2.1 and 4.4. ### How was this patch tested? - A new test in `ArrowColumnarPythonUDFSuite`, `Arrow-backed source that reuses its vectors: correct with the default configuration`, runs a UDF on a string column, over 2 batches and over 2000 batches of 100 rows of a source that refills its vectors. It checks that `ArrowEvalPythonExec` does not take columnar input, and the pass-through columns of every row against its UDF result. With columnar input enabled, it fails with the `IndexOutOfBoundsException` above in both cases. - A new suite, `ArrowCachedPythonUDFSuite`, runs a UDF over 200 batches of 1000 rows of the Arrow cache with the default configuration, and checks that `ArrowEvalPythonExec` reads rows although its child supports columnar, and the pass-through column of every row against its UDF result. Without the change to `doExecute`, it fails with the `IndexOutOfBoundsException` above. - The other tests of `ArrowColumnarPythonUDFSuite`, which test the columnar input path, enable it, as does `ArrowPythonUDFCachedInputTests` in PySpark, which tests it over the Arrow cache. Both suites pass (8 tests), as do `scalastyle` and `ruff`. ### Was this patch authored or co-authored using generative AI tooling? Generated-by: Claude Code (Claude Opus 5.5) This pull request and its description were written by Isaac. Closes #59551 from HyukjinKwon/SPARK-60294. Authored-by: Hyukjin Kwon <gurwls223@apache.org> Signed-off-by: Hyukjin Kwon <hyukjin.kwon@databricks.com> (cherry picked from commit 2487c98) Signed-off-by: Hyukjin Kwon <hyukjin.kwon@databricks.com>
| override def capabilities(): util.Set[TableCapability] = { | ||
| val library = NativeLibraries.get(location) | ||
| val declared = withDataSource(library, "check the capabilities of")(_.capabilities) | ||
| val capabilities = util.EnumSet.of(BATCH_READ, MICRO_BATCH_READ, STREAMING_WRITE) |
There was a problem hiding this comment.
The docs say continuous streaming is not supported, but nothing stops it: this table always reports STREAMING_WRITE, and Spark accepts any such sink with Trigger.Continuous. I ran rate into native_sink with Trigger.Continuous("1 second") on Java 25, and it ran:
- One stream writer was created and used for every epoch.
- When the query stopped, the data writers of the last epoch were closed without a commit or an abort. Their files were left in
_temporary/.
The header promises a library that a data writer is always committed or aborted before it is closed, so a library can't rely on that here.
Could ColumnarStreamingWrite.createStreamingWriterFactory fail when the write runs in continuous mode (for example, when the local property ContinuousExecution.EPOCH_COORDINATOR_ID_KEY is set), with a test?
There was a problem hiding this comment.
Thanks, fixed in 840971d. ColumnarStreamingWrite.createStreamingWriterFactory now fails with the new DATA_SOURCE_CONTINUOUS_WRITE_NOT_SUPPORTED when the local property ContinuousExecution.EPOCH_COORDINATOR_ID_KEY is set, which only continuous processing sets, right before it runs the write. So Spark creates no writer, and a library is never left with data writers that are closed without a commit or an abort. Continuous reads already fail when the query starts, since a native scan does not report CONTINUOUS_READ. The header now says that streaming writes are micro-batch only, and the docs list continuous processing among the limitations. Test: continuous processing fails with a native data source writes rate to native_sink with Trigger.Continuous, and checks that the library created no writer.
| case Seq() => None | ||
| case Seq(result) => Some(result) | ||
| case _ => | ||
| throw QueryCompilationErrors.nativeDataSourcePackageConflictError( |
There was a problem hiding this comment.
The docs suggest putting the version in the file name of a package. But if a session adds rs-1.0.sparkpkg and then rs-1.1.sparkpkg, every later query on that source fails with NATIVE_DATA_SOURCE_PACKAGE_CONFLICT, which says to remove all but one. A session has no way to remove an artifact, and adding rs-1.0.sparkpkg again with new contents fails with ARTIFACT_ALREADY_EXISTS. So the session is stuck until it is closed. I checked this on Java 25.
The same conflict happens when an admin's package in spark.sql.dataSource.native.paths and a user's added package provide the same name.
Could Spark choose one, for example the package the session added last, then the configured ones? Or else the docs could say that an upgrade needs a new session, and stop suggesting versioned file names.
There was a problem hiding this comment.
Thanks, fixed in 840971d with the first option, which is what Spark does for Python data sources: one registered again replaces the previous one, and a statically registered one, with a warning. The package that a session added last now takes precedence over the packages it added before and over the configured ones, and Spark logs once that it replaces them. So a session upgrades rs-1.0.sparkpkg by adding rs-1.1.sparkpkg, and a package that a user adds replaces an admin's package in spark.sql.dataSource.native.paths for that session. Two configured packages that provide the same data source still fail with NATIVE_DATA_SOURCE_PACKAGE_CONFLICT, whose message now names the configuration: they are files on the driver that can be removed, and their file names do not tell which one is newer. The docs describe the order and how to upgrade a package. Test: the package that a session added last replaces the other packages.
| } | ||
| } | ||
|
|
||
| private def encodeColumn(reference: NamedReference, schema: StructType): Option[ObjectNode] = { |
There was a problem hiding this comment.
This passes a column of any type, so s1 = s2 on two struct columns, and a1 < a2 on two array columns, are pushed to the library as they are. I checked both with V2ExpressionBuilder and toJson.
The rules in the header only cover single values. For example, they say comparisons with null are null. But Spark treats nulls inside structs and arrays as equal to each other and smaller than other values, so array(1, null) = array(1, null) is true. A library that follows the header can skip those rows, and Spark can't get them back by checking the predicates again after the scan.
Could comparisons and IN only be pushed for columns of simple types (null checks are fine for any type)? Or else the header could describe the rules for nested values. It could also say that binary values compare as unsigned bytes.
There was a problem hiding this comment.
Thanks, fixed in 840971d. Comparisons and IN are now only pushed when none of their operands is an array, a map or a struct, so s1 = s2 and a1 < a2 are no longer pushed. IS NULL and IS NOT NULL still are, for any type, and so are the comparisons of a field of a struct that has a simple type, such as s.x = 1. The header says both, and that binary values compare as unsigned bytes. predicate-cases.json now has a binary column, with cases for which unsigned and signed comparisons differ: b > X'7F' matches X'80', X'FF' and X'7F00'. Test: comparisons and IN on arrays and structs are not pushed.
| checkColumn(child, childField, column) | ||
| } | ||
| } | ||
| if (batch.offset != 0) { |
There was a problem hiding this comment.
The checks here look at the offset and the children of the batch, but not its null_count. As far as I can tell, Arrow Java ignores the validity of the top-level struct when it imports a batch. So if a library marks some rows of the batch itself as null, Spark reads them as ordinary rows built from the child values, with no error. Arrow C++ rejects such a batch on import.
Could this also require null_count == 0, or -1 with no validity buffer? It is a constant-time check.
There was a problem hiding this comment.
Thanks, fixed in 840971d. checkArrays now rejects a batch whose struct marks rows as null, with The struct of the batch marks 1 of its rows as null, but Spark only reads batches without null rows: mark the values of the columns as null instead.
Two details differ from the suggestion. I checked them with ImportRecordBatch of Arrow C++ 24, on a struct of 2 rows:
null_count |
validity buffer | Arrow C++ | Spark now |
|---|---|---|---|
| -1 | no nulls | imported | read |
| -1 | first row null | ArrowArray struct has non-zero null count, cannot be imported as RecordBatch |
rejected |
| 1 | first row null | same | rejected |
| -1 | none | imported | rejected |
- When the library did not count the nulls, with a
null_countof -1, Spark counts them in the validity buffer, as Arrow C++ does, rather than rejecting a batch that has none. This readslength / 8bytes, which the buffer must hold anyway. - A
null_countof -1 without a validity buffer is not allowed by the C data interface: "The buffer pointers MAY be null only in two situations: 1. for the null bitmap buffer, if ArrowArray.null_count is 0". Arrow Java takes it as a mix of nulls and values, and fails to import it withCould not load buffers for field ...: Cannot invoke "org.apache.arrow.memory.ArrowBuf.getReferenceManager()". So Spark now rejects it, for every array and not only the batch, withThe batch has a null_count of -1 but no validity buffer, which the C data interface only allows with a null_count of 0.
The header now says both. The test library has a new option null_rows, and reject the Arrow data that Spark cannot read checks each row of the table, and a column with a null_count of -1 but no validity buffer.
| } | ||
| dictionaries.close() | ||
| // Releases the stream of the library, unless it was not exported. | ||
| stream.release() |
There was a problem hiding this comment.
stream.release() runs after root.close() and dictionaries.close() in the same block, while task.close() is in the finally. If either Arrow close throws, the library never gets release, but the task context is freed anyway. A library thread that still checks task->is_interrupted then reads freed memory. The header says the task stays valid until the stream is released.
Unlikely, but easy to rule out: could the stream be released in its own finally, before task.close()?
There was a problem hiding this comment.
Thanks, fixed in 840971d: the stream is now released in a finally of closing the root and the dictionaries, and the task is closed after it, so a failure there no longer skips the release. The data writer was already in that order: close_data_writer is the first thing that its close does, before the task is closed.
| </dependency> | ||
| <dependency> | ||
| <groupId>org.apache.arrow</groupId> | ||
| <artifactId>arrow-c-data</artifactId> |
There was a problem hiding this comment.
Spark calls the library with FFM, but it still exchanges Arrow data through arrow-c-data, which loads Arrow's own JNI library, libarrow_cdata_jni. That library is only shipped for Linux and macOS on x86_64 and aarch64, and for Windows on x86_64. On ppc64le, s390x or Alpine (musl), a correctly installed native data source fails on its first read, with an Arrow error that doesn't mention native data sources.
Could the docs list the supported platforms, and Spark fail early with a clear error elsewhere? The platform names in the manifest also can't tell glibc from musl, which will be hard to change once 4.4.0 is out.
There was a problem hiding this comment.
Thanks, fixed in 840971d:
- Before it loads a library, on the driver and on the executors, Spark now checks that the JNI library of the C data interface of Arrow Java loads, and otherwise fails with the new
NATIVE_DATA_SOURCE_UNSUPPORTED_PLATFORM. The error names the platform and keeps Arrow's error as its cause: anIllegalStateExceptionwhen Arrow Java has no library for the architecture, as on ppc64le or s390x, or anUnsatisfiedLinkErrorwhen the library does not link, as with musl. - The docs list the platforms for which Arrow Java 19 ships the library: Linux with glibc and macOS on x86_64 and aarch64, and Windows on x86_64.
- In the manifest,
linux-<arch>now means Linux with glibc, and Linux with musl islinux-<arch>-musl, as the Rust targets for musl end with-musl. Spark detects musl from the C library mapped in its own process (/proc/self/maps), rather than from files that a glibc system may also have, so it never picks a glibc build on musl, and a package can add a musl build once native data sources run there.
Tests: fail clearly where the C data interface of Arrow Java does not load, and the musl cases of platform names.
…he last added package win - A continuous query fails with DATA_SOURCE_CONTINUOUS_WRITE_NOT_SUPPORTED before a writer is created, so a library never has data writers closed without a commit or an abort. - The package that a session added last replaces its other packages and the configured ones, so a session upgrades a package by adding a new version; configured packages still conflict. - Comparisons and IN are not pushed on arrays, maps or structs, and binary values compare as unsigned bytes; predicate-cases.json has a binary column. - A batch must have no null rows, counted when its null_count is -1, and an array without a validity buffer must have a null_count of 0, which Arrow Java needs to import it. - The read stream is released before its task context is freed, also if closing the vectors fails. - NATIVE_DATA_SOURCE_UNSUPPORTED_PLATFORM where the JNI library of the C data interface of Arrow Java does not load, and linux-<arch>-musl names Linux with musl in the manifest. Co-authored-by: Isaac <no-reply@databricks.com>
What changes were proposed in this pull request?
This is the FFM version of #59313, which it replaces, following the review of #59313: the same feature, with the binary interface as a plain C ABI called through the Foreign Function and Memory (FFM) API instead of JNI. It includes all the changes of #59313, up to e3ff80c, changes how Spark calls the libraries, and, following the review of this PR, gives the interface a shape that can grow without new exported functions: a function table, structs that Spark passes to the library, and capabilities for each data source (see Shape of the interface below). Native data sources then require Java 22 or later, where the FFM API is final; with Java 17 and 21, Spark compiles and runs as before, and a query that uses a native data source fails with
NATIVE_DATA_SOURCE_UNSUPPORTED_JAVA_VERSION.Like #59313, this PR adds native data sources: data sources implemented in native code, such as Rust or C++, without any JVM code. A native data source is a shared library that implements a binary interface. Spark finds it automatically when it is installed like other native libraries, or in a package file added to a session, and loads it when a query uses it. Spark adapts it to Data Source V2, with batch and micro-batch streaming reads, batch and streaming writes, and predicate, column and limit pushdown. Data is exchanged only as columnar batches, through the Arrow C data interface.
Differences from #59313:
org.apache.spark.sql.datasource.NativeBridge, a Java class whosenativemethods the library implementsspark_datasource.h, a C header (sql/core/src/main/resources/org/apache/spark/sql/datasource/, also in thespark-sqlJAR)Java_org_apache_spark_sql_datasource_NativeBridge_createDataSource, ... (29 functions)spark_ds_init, which returns a table of function pointers,SparkDsLibraryjni.h, or thejnicrate#[repr(C)]declarations of its structsThrowNew)last_errorfunction of the tablebyte[],byte[][],Stringbuilt by the librarySparkDsSinkcallback, which copies themNativeBridgeper library, in its own class loaderSymbolLookup.libraryLookupfindsspark_ds_initin each)createreturns for each data sourcePublic API: a single C header,
spark_datasource.h, whose comments are the specification of the interface, with the same content as the Javadoc ofNativeBridgein #59313: the lifecycle, the handles, the ownership of the Arrow structs and of the memory that Spark passes, errors (non-zero return values, with the message oflast_error, reported asNATIVE_DATA_SOURCE_ERROR), the JSON format of the pushed predicates, which Spark evaluates again after reading, so that a library only uses them to skip data and must never skip a row for which they are true under the semantics of Spark SQL (the jar of Spark SQL contains test cases for libraries,org/apache/spark/sql/datasource/predicate-cases.json), the threads, and the compatibility rules. Version 1 of the interface,SPARK_DS_ABI_VERSION. Theorg.apache.spark.sql.datasource.NativeBridgeclass of #59313 is removed: there is no JVM API for native data sources.Shape of the interface, following @tdcmeehan's review, so that version 1 can grow without new exported functions or signatures:
spark_ds_init(const SparkDsHost* host, const SparkDsLibrary** library)is the only function that a library exports. Spark calls it once in each JVM, when it loads the library, and it returns the table of the functions of the library,SparkDsLibrary. A library sets the functions of the operations it supports, and leaves the others NULL.host->abi_versionis the newest version that Spark supports, the table declares the version that it implements, and Spark rejects a table of a version that it does not support (UNSUPPORTED_ABI_VERSION).SparkDsHost, valid until the JVM exits, with the version of Spark and alogfunction that writes to the log of the driver or the executor, with the path of the library;SparkDsCreateInfo, passed tocreate, with the name of the data source and the options of the user, so that later releases can pass more, such as references to credentials instead of the credentials themselves in the options;SparkDsTaskContext, passed toreadandcreate_data_writerand valid until the stream is released or the data writer is closed, with the IDs of the stage, partition, attempt and task attempt, andis_interrupted, which tells a read or a write that its task was killed, since Spark cannot interrupt native code; andSparkDsWriteInfo, passed tocreate_writer, which replacesspark_ds_create_writerandspark_ds_create_stream_writer, with the mode of the write,SPARK_DS_WRITE_APPENDorSPARK_DS_WRITE_TRUNCATE, and the ID of the query of a streaming write.createreturns the capabilities of each data source, which may depend on its name and options:SPARK_DS_BATCH_READ,SPARK_DS_MICRO_BATCH_READ,SPARK_DS_BATCH_WRITE,SPARK_DS_STREAMING_WRITEandSPARK_DS_TRUNCATE. Spark reports an operation that a data source does not declare as unsupported, without calling the library, and only passesSPARK_DS_WRITE_TRUNCATEto a data source withSPARK_DS_TRUNCATE. The Data Source V2 table of a data source reportsBATCH_WRITEandTRUNCATEas the data source declares them with the options of the table, so Data Source V2 rejects a batch write or overwrite that it does not support while it analyzes the query (UNSUPPORTED_FEATURE.TABLE_OPERATION), before it computes the data to write. The table always reports the reads and the streaming writes, which Data Source V2 would otherwise hand to Data Source V1, whose errors do not say why: they fail withDATA_SOURCE_*_NOT_SUPPORTEDwhen Spark plans them. Each operation checks the capabilities again with its own options, which can override the options of a table in a catalog. If a declared capability needs a function that the table leaves NULL, the operation fails on the driver withNATIVE_DATA_SOURCE_ERRORand the name of the function, instead of being silently unsupported.push_predicatessets, for each predicate, how the reader uses it:SPARK_DS_PREDICATE_UNUSED,SPARK_DS_PREDICATE_PARTIAL(only to skip data) orSPARK_DS_PREDICATE_COMPLETE. Spark shows the predicates that the reader uses as pushed in the plan, and still evaluates all of them after reading.close,close_reader,close_stream_reader,close_writerorclose_data_writer, and by no other function.commit_data_writerno longer releases the data writer: Spark callsclose_data_writerafter the commit or the abort.readinitializes can be called from any thread, but never concurrently, like the functions ofSparkDsHostandSparkDsTaskContext, so that an engine that reads the partitions natively can call them from its own threads.NATIVE_DATA_SOURCE_ERRORbefore it passes the schema to the library, so that a later version can add types for the data sources that declare a capability for them.create,closelast_error,schemaSPARK_DS_BATCH_READcreate_reader,partitions,read,close_readerpush_predicates,push_limit,prune_columns,serialize_readerSPARK_DS_MICRO_BATCH_READcreate_stream_reader,initial_offset,latest_offset,stream_partitions,read,close_stream_readerserialize_stream_reader,commit_offsetSPARK_DS_BATCH_WRITE,SPARK_DS_STREAMING_WRITEcreate_writer,commit,abort,close_writer,create_data_writer,write,commit_data_writer,abort_data_writer,close_data_writerserialize_writerHow Spark calls the libraries (
execution.datasources.v2.ffi.NativeLibrary): Spark still compiles for Java 17, where the FFM API does not exist, soNativeLibraryreachesjava.lang.foreignonly through method handles, looked up once when the first library is loaded, and only on Java 22 or later. Spark callsspark_ds_initonce for each library in a JVM, and binds each function of the table that is not NULL, within the size of the table, to a downcall handle adapted to take and return primitive values only, with pointers aslongaddresses, so each call is a plaininvokeExactwithout reflection, which the JIT compiles like a direct FFM call. The memory that Spark passes to a function is allocated withPlatform, at the offsets of the structs of the header, and freed after the call, exceptSparkDsHost, kept until the JVM exits, andSparkDsTaskContext, freed once the stream is released or the data writer is closed. The upcall stubs,SparkDsSink, which copies the byte arrays that the library returns,SparkDsTaskContext.is_interrupted, andSparkDsHost.log, one for each library, never throw, since an exception thrown by an upcall would crash the JVM. Spark already runs with--enable-native-access=ALL-UNNAMED, which the restricted methods of the FFM API need to avoid warnings; a library that cannot be loaded, including when native access is denied, is reported asINVALID_NATIVE_DATA_SOURCE_LIBRARY.CANNOT_LOADorINVALID_NATIVE_DATA_SOURCE_PACKAGE.INVALID_LIBRARY, as before. Everything aboveNativeLibraryis the same as in #59313, except for the capabilities, the task contexts, the write modes, the uses of the predicates and the planning of batch scans (see Other changes): the Scala classes catchNativeLibrary.MissingFunctionExceptionandNativeLibrary.LoadExceptioninstead ofUnsatisfiedLinkError.Arrow data: Spark still uses
arrow-c-data, as in #59313, to export and import the schemas and the arrays as Arrow C structs, without copying their buffers.arrow-c-datacalls the callbacks of the C structs through the JNI library that it bundles, for Linux and macOS on x86_64 and aarch64 and Windows on x86_64: theget_schema,get_next,get_last_errorandreleasecallbacks of the streams that the libraries export, thereleasecallbacks of the schemas and arrays that Spark imports, and thereleasecallbacks of the schemas and arrays that Spark exports, which are functions of that JNI library. So Spark calls the libraries with FFM, and the Arrow callbacks go through JNI, insidearrow-c-data, once for each batch or schema rather than for each row. Library authors only see the C interface either way. An FFM implementation of the Arrow C data interface, for example in Arrow Java, could replace this JNI library later.Finding native data sources: when no Java or Python data source has the requested name, Spark looks for a native data source with that name, in two places:
Installed libraries, automatically: like the Python data sources installed in the Python path, a library installed like other native libraries is found without any configuration, by its file name. The library of the data source
rust_rangeisspark_datasource_rust_range:libspark_datasource_rust_range.soon Linux,libspark_datasource_rust_range.dylibon macOS andspark_datasource_rust_range.dllon Windows. Spark checks whether the file exists in the native library path, which consists of:java.library.path, where the JVM itself finds native libraries. They includeLD_LIBRARY_PATHon Linux,DYLD_LIBRARY_PATHon macOS andPATHon Windows, and sospark.driver.extraLibraryPathandspark.executor.extraLibraryPath;libdirectory of each installation prefix inPATH:<prefix>/libfor each<prefix>/bin.So libraries are found where native libraries are usually installed:
/usr/local/lib(make install, CMake, cargo-c, Homebrew on Intel macs),/opt/homebrew/lib,$CONDA_PREFIX/liband$VIRTUAL_ENV/libin an activated environment, and/usr/lib. A library that implements several data sources is installed under each of their names, for example with symbolic links. The executors load the library from the same path as the driver, or else find it in their own native library path, and only load the same build as the driver, which they check with the SHA-256 checksum of the file.Packages of a session: a native data source package is a zip file with the extension
.sparkpkg, with a manifest,spark-native-datasource.json, that lists the ABI version, the names of the data sources, and the library for each platform such aslinux-x86_64orosx-aarch64(linuxis Linux with glibc, and Linux with musl islinux-<arch>-musl). A session adds packages withspark.addArtifact(Scala and Python, classic and Spark Connect:.sparkpkgfiles are added as file artifacts), or withspark.sql.dataSource.native.paths. They take precedence over the installed libraries, and the package that a session added last over its other packages and the configured ones, as a Python data source registered again replaces the previous one: a session cannot remove an artifact, so it upgrades a package by adding the new version under another file name. Two configured packages that provide the same data source fail withNATIVE_DATA_SOURCE_PACKAGE_CONFLICT. The executors get them through the artifacts of the session, except in local mode, where they read them in place: a configured package is copied there under a name with its checksum, so it can be replaced in place. Spark ignores, and logs, a package that it cannot read or whose ABI version it does not support, unless its manifest lists the requested data source and no other package or installed library provides it.Short names: no JVM class is needed for each data source. After the Java data sources (
DataSourceRegister) and the Python ones,DataSource.lookupDataSourcelooks the name up in the packages of the session and in the native library path, case-insensitively, and resolves it to a single provider class,NativeDataSourceV2. The caller then gives it the requested name, the wayPythonDataSourceV2already works: a smallNamedTableProvidertrait generalizes howPythonDataSourceV2gets its short name, so the native provider shares the same call sites. The provider reuses the result of the lookup that found its class, instead of looking the name up again. A Python data source can still be registered with the name of a native data source, and takes precedence over it. Lookups only read manifests and check whether files exist; Spark loads a library when a query uses it. Native data sources work withspark.read/write,readStream/writeStream, and SQL tables (CREATE TABLE ... USING rust_range OPTIONS (...),SELECTandINSERT INTO).Other changes, besides those of #59313:
NATIVE_DATA_SOURCE_UNSUPPORTED_JAVA_VERSION(0A000), raised when a query uses a native data source on Java 21 or earlier, andNATIVE_DATA_SOURCE_UNSUPPORTED_PLATFORM(0A000), raised where the JNI library of the C data interface of Arrow Java does not load: Arrow Java ships it for Linux with glibc and macOS on x86_64 and aarch64, and for Windows on x86_64. The lookups still find the data source, so the errors say why it cannot be used rather than that it does not exist.DATA_SOURCE_CONTINUOUS_WRITE_NOT_SUPPORTED(0A000), raised when a continuous query writes to a native data source, before Spark creates a writer, likeDATA_SOURCE_CONTINUOUS_SCAN_NOT_SUPPORTEDfor reads.readreturns success without initializing the stream is reported as an error:arrow-c-datacalls the callbacks of a stream without checking them, and calling NULL would crash the JVM.test_native_datasource.ccimplements the function table, and the tests compile it withspark_datasource.hinstead of the JNI headers. Compiled with defines, it also provides the libraries that the tests need: a read-only library, a table that ends beforeread, a table of version 2, a failingspark_ds_init, no table, and nospark_ds_init.CheckConnectJvmClientCompatibilityexclusion oforg.apache.spark.sql.datasource, added by [SPARK-60101][SQL] Support native data sources implemented in Rust or C++ #59313 forNativeBridge, is not needed.Java 25 build with Mavenjob of a pull request also runsNativeDataSourceSuite,NativeDataSourceClusterSuiteandNativeDataSourceConnectSuite, with the C++ compiler of the runner, since the other jobs, on Java 17, skip the tests that load a library.NATIVE_DATA_SOURCE_ERROR.abiVersionof a manifest is the oldest version of the interface that the library implements. Spark uses a package whose version is at most the newest that it supports, and ignores the fields of a manifest that it does not know.ReusedArrowColumnVector, whosecloseIfFreeabledoes nothing, which the columnar input path can respect once it is enabled again with a marker for the vectors that their producer reuses.execution.datasources.v2.columnar, plans a batch scan with a driver-onlyColumnarBatchPlanner, which gets the pushdown, and whoseplanreturns the partitions with a serializable reader factory. For a native data source, the planner holds the reader of the library, and the reader factory only has the location of the library, the name of the data source, the read schema and the state thatserialize_readerreturned. A scan builder builds its scan once, and the scan takes over the planner, or creates one if the operations were not pushed down, so a streaming scan never creates one. A batch is still planned once, when Spark first asks for its partitions, and a planning failure is kept and thrown again. The reader is released once the scan is planned, or by aCleanerif the scan is never planned, as before.commit_data_writerreturns its message through the sink. Spark never aborts a data writer once the library returns 0, even if it then rejects the message, and it always callsclose_data_writerafterwards, which is the only function that releases a data writer.dev/make-distribution.shcopiesspark_datasource.hto theincludedirectory of a binary distribution, as a JDK shipsinclude/jni.h, and thespark-sqlJAR contains it too. The header only depends on the C standard library, so a library can also copy it into its source tree and build without Spark, as for the Arrow C data interface and ADBC'sadbc.h; the docs say so, with the URL of the header at the tag of a release.The other changes are those of #59313: the configurations
spark.sql.dataSource.native.enabledandspark.sql.dataSource.native.paths, the error conditions, thearrow-c-datadependency, the "Native Data Sources" documentation page (with the examples below), and:spark_datasource.hstates the rule for skipping data (NaN,-0.0, nulls, the UTF-8 order of strings, parts of predicates), and the jar containspredicate-cases.json, generated by a test from the evaluation of Spark. The JSON of the predicates is ASCII.create_writergets the ID of the streaming query,SparkDsWriteInfo.query_id, which stays the same when the query restarts from its checkpoint.INVALID_NATIVE_DATA_SOURCE_LIBRARY.REPLACED, and the caches identify files by their file key too.NATIVE_DATA_SOURCE_ERROR, the column vectors of a read are not freeable between batches (which broke struct columns), packages of an unsupported version are logged, PySparkaddArtifactonly passesfile:URIs as they are, and the docs describe the lifecycle of writers and the memory of native data sources.NativeLibrary, and a missing offset as well, since the library returns it through the sink.DataSource.lookupDataSourcelooks up the native data sources after the Python ones, andDataSourceRegistrationskips them when it registers a Python data source.ArtifactManagerlists the native data source packages of a session, and forgets the cached packages read from the artifacts of a session when it cleans them up.DataFrameWriterbuilds its commands, andDataStreamWriterresolves its sink, with the session of the Dataset active, as the analysis of a query does: the lookups of the Python and native data sources use the active session.addArtifactsadds.sparkpkgfiles to the session whatever the flags: in classic mode through the JVM session, and with Spark Connect as file artifacts.Examples
Both examples implement a read-only data source that returns the numbers
[0, end)as a columnid, in two partitions, with the optionend(10 by default). They declare the capabilitySPARK_DS_BATCH_READ, and only implement the functions needed for batch reads, pluslast_error, leaving the others NULL in their table.Rust (with arrow-rs only)
Cargo.toml:src/lib.rs:Build and install, here on macOS:
Or package, to add it to a session with
spark.addArtifact:C++ (with Arrow C++)
cpp_range.cc:Get
spark_datasource.h, which is in thespark-sqlJAR, then build (Arrow C++ 24 requires C++20) and install, or package as above:Usage is the same as in #59313, on Java 22 or later, in classic PySpark or with Spark Connect. Once installed, the data sources are found automatically, without
spark.addArtifactor any configuration:Or added to a session as a package:
Why are the changes needed?
Some data systems only have a client in native code, such as an in-house storage system written in C++ or the C SDK of a vendor, and Spark only needs to read and write them. Using them from Spark today requires writing a JVM Data Source V2 connector with its own JNI glue. With this change, such a source implements a stable binary interface once, in Rust, C++ or any language that can export C functions, is installed like any other native library or distributed as a single file, keeps working with the later releases of Spark that support its version of the interface, and works with every Spark API, including Spark Connect clients in any language, without any JVM code.
A source that needs what version 1 does not cover yet, such as row-level operations, or extensions outside of Data Source V2, such as table functions and SQL commands, is better served by a JVM connector, which can still read and write through a native library with the Arrow C stream interface, as lance-spark does with Lance. The docs say so in a new "When to Use a Native Data Source" section.
With FFM, the interface is also a plain C ABI, so a library needs no JVM headers or bindings, and other hosts than Spark could load the same libraries.
Does this PR introduce any user-facing change?
Yes. It adds native data sources, with the
spark_datasource.hinterface, the configurations and the error conditions above, on Java 22 or later. Spark finds the native data source libraries installed in the native library path, andspark.addArtifactnow accepts native data source packages (.sparkpkgfiles). Also, in classic mode, writing with a session that is not the active one now finds the data sources of that session, such as its Python data sources, becauseDataFrameWriterandDataStreamWriterresolve the data source with that session active.How was this patch tested?
All the tests of #59313, ported to the C interface:
NativeDataSourceSuite, with the dependency-free C++ test library (sql/core/src/test/resources/native-datasource/test_native_datasource.cc), now implementingspark_datasource.h, which the tests compile with the C++ compiler of the machine against the header from the classpath. It covers inferred and user-specified schemas, partitions and batches, the supported types, predicate, column and limit pushdown, batch writes (append and overwrite), aborted writes, streaming reads and writes over several micro-batches with offset commits, SQL tables withOPTIONS(SELECTandINSERT INTO), errors reported by native code, missing functions, several libraries exporting the same functions side by side,spark.addArtifact, packages in directories, package conflicts, the precedence of Java data sources, invalid packages, installed libraries and invalid installed libraries, plus the unit tests for the manifest, the platform names, the native library path and the JSON encoding of predicates.NativeDataSourceSuiteunless noted:commitDataWriteraborts the data writer; a failedcommitaborts the write, then closes the writer; the test library records when a writer is closed, which checks that a batch write closes its writer, and that each micro-batch of a streaming write closes the writer of the previous one;ColumnarDataSourceSuitechecks that each micro-batch creates its stream writer with the ID of the query, and that batches end atspark.sql.execution.arrow.maxBytesPerBatch.NativeDataSourceClusterSuiteruns inlocal-cluster[2,1,1024], where the executors are separate JVMs: batch reads, writes and streaming with a package added withspark.addArtifact, and a configured package that the executors can only load from its distributed copy (named by checksum, under the UUID of the session), which they check by its checksum.spark_datasource.hare pushed down, not arithmetic or other functions.spark.addArtifactin classic PySpark acceptsfile://URIs and paths relative to the working directory of Python.decimal256and list views are rejected with the name of the column, maps whose entry fields are namedkeysandvalues(as arrow-rs names them) are read, andlimit(...).offset(...)asks the library for the limit once.ColumnarDataSourceSuite).stop()closing the stream reader; NULL handles from each of the five create functions; concurrent distributions of a configured package; a valid package with another checksum; the logging of packages of an unsupported version; an installed library replaced in place; link errors; andaddArtifactwith a file name that contains a colon.NativeDataSourceConnectSuite(Spark Connect),NativeDataSourceClusterSuite(local-cluster, executors in separate JVMs) andColumnarDataSourceSuite.New tests:
native data sources require Java 22 or later: on Java 21 or earlier, a query that uses an installed native data source fails withNATIVE_DATA_SOURCE_UNSUPPORTED_JAVA_VERSION, before Spark loads the library. It runs only on Java 21 or earlier, so it runs in the default CI on Java 17.report the errors of native code: a library withoutlast_errorreportscreate failed with the error code 1., and a library whosereadreturns success without initializing the stream fails withThe library did not initialize the stream, or a callback of the stream is NULL.New tests for the shape of the interface, in
NativeDataSourceSuite:the bindings match the layout of spark_datasource.h: compiles a program that prints the size of each struct of the header and the offset of each field, and compares them with the offsets thatNativeLibraryuses.operations that a data source does not support: a read-only library, whose data sources only declareSPARK_DS_BATCH_READ, fails batch writes while Spark analyzes the query, withUNSUPPORTED_FEATURE.TABLE_OPERATION, before a shuffle stage of the data to write fails, and streaming reads and streaming writes withDATA_SOURCE_*_NOT_SUPPORTED; a data source whose library implements batch reads but that only declares streaming reads fails batch reads; a declared capability with a NULL function fails with the name of the function; an overwrite withoutSPARK_DS_TRUNCATEfails, in batch while Spark analyzes the query, and in streaming with the output modecomplete; and a write to a table in a catalog whose options remove a capability of the table fails when Spark plans it, withDATA_SOURCE_BATCH_WRITE_NOT_SUPPORTED, orNATIVE_DATA_SOURCE_ERRORfor an overwrite.ColumnarDataSourceSuite:the table reports the capabilities of the data source, where a batch write or overwrite that the data source does not support fails while Spark analyzes it, without a writer, andbuild and plan a batch scan once, where a scan builder builds one scan, whose batch is planned once, and a failed planning is not retried but fails again with the same error.reject the data that has nulls where the schema to read has none;reject the types and the strings that a library cannot get, with an interval in a read, an array of variants in a write, which fails beforecreate_writer, and an option with U+0000;reject the Arrow data that Spark cannot read, with batches without their columns;parse manifests, with anabiVersionthat is not positive;encode predicates as JSON, with float literals and strings that are not valid UTF-8; andpredicate cases, with a float column and a predicate under=.continuous processing fails with a native data source, for writes and reads;the package that a session added last replaces the other packages;comparisons and IN on arrays and structs are not pushed, while null checks and the comparisons of struct fields of simple types are;fail clearly where the C data interface of Arrow Java does not load;reject the Arrow data that Spark cannot read, with batches whose struct marks rows as null, counted or not, and arrays with anull_countof -1 but no validity buffer, which Arrow Java cannot import;predicate cases, with a binary column, which compares as unsigned bytes; andplatform names, with musl.Arrow UDFs on the columns of a native data source: a pandas UDF over one batch, two and 2,000, which checks the pass-through columns of each row against the result of the UDF. Locally, it fails without [SPARK-60294][SQL] Disable Arrow-backed columnar input to Python UDFs by default #59551 and passes with it. The CI of pull requests skips it, since the job that runs the native data source suites has no pandas.read only the functions that fit in the table of a library: a table that ends beforeread, like the table of a library built against an earlier header, makesreadunsupported rather than read past its end.pass the version of Spark, and log with the log of Spark: the library checksSparkDsHost, and its messages reach the log of Spark at their level, with the path of the library.pass the task to reads and data writers: each read and each data writer gets the IDs of its task.tell a read that its task was killed: a read that waits untilis_interruptedreturns true ends once its job is canceled.reject the libraries that spark_ds_init does not initialize: a failingspark_ds_init, which logs why once, a library that returns no table, and one that does not exportspark_ds_init, with the same error when they are used again, since Spark initializes a library only once.push down predicateschecks the answers ofpush_predicates, includingSPARK_DS_PREDICATE_PARTIALand a value that Spark does not know, andinvalid installed librariesandinvalid packagescheck a table of version 2, which Spark rejects withUNSUPPORTED_ABI_VERSION.The tests that load a library are skipped before Java 22, like they are skipped without a C++ compiler. So the jobs on Java 17 only run the tests that do not load a library, and the
Java 25 build with Mavenjob of a pull request runs the native data source suites, as do the scheduled Java 25 builds.do not abort a data writer that committed with an invalid messagechecks that a data writer released by its commit is not aborted. Locally on macOS (osx-aarch64):NativeDataSourceSuite,NativeDataSourceClusterSuite(with the executors on Java 26 too),ColumnarDataSourceSuiteandNativeDataSourceConnectSuitepass. With the executors oflocal-clusteron Java 17, the tasks fail withNATIVE_DATA_SOURCE_UNSUPPORTED_JAVA_VERSION, as expected.NativeDataSourceSuitepasses, with the tests that load a library skipped, and the Java version test run.-std=c++20; both export onlyspark_ds_init), installed in a directory ofjava.library.path, and read by Spark on Java 26:spark.read.format("rust_range").option("end", 100).load()and the same withcpp_rangereturn the 100 rows in two partitions, andoption("end", "x")fails withNATIVE_DATA_SOURCE_ERRORand the message of the library (invalid digit found in stringandstoll: no conversion).includedirectory of a distribution: the header copied asdev/make-distribution.shcopies it, then the documentedc++command with-I"$SPARK_HOME/include".-Wall -Wextra), in all its variants.SparkThrowableSuite, for the new error conditions.checkstyleandscalastyleof the changed modules.Was this patch authored or co-authored using generative AI tooling?
Generated-by: Claude Code (Claude Opus 5.5)
This pull request and its description were written by Isaac.