Repository navigation
[SPARK-60101][SQL] Support native data sources with a frozen C core and versioned messages - #59498
Closed
HyukjinKwon wants to merge 27 commits into
Closed
HyukjinKwon wants to merge 27 commits into
HyukjinKwon wants to merge 27 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>
…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>
…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>
…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>
…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>
A draft for comparison with the function table of apache#59424, following the proposal on it: a library exports six functions that never change, spark_ds_abi_version, spark_ds_last_error, spark_ds_open, spark_ds_call, spark_ds_stream and spark_ds_close, and everything that grows is in the Protocol Buffers messages of spark_datasource.proto: - OpenRequest opens a data source, a batch or stream reader, a writer, a partition reader or a data writer, with everything that it needs. The OpenReply of a data source declares its capabilities. - CallRequest and CallReply carry the control operations. An empty reply is valid for every request. - StreamRequest exchanges the data as Arrow C streams. A data writer gets the data of its task as one stream, from DataWriter.writeAll, which the library reads on the thread of the task. - SparkDsHost has the log and is_interrupted callbacks, and schemas are Spark SQL types. The messages use the lite runtime: the full C++ runtime registers them in a pool of the process, where a second copy of a library aborts the process. The C++ test library implements the messages with a minimal codec, and the docs and their Rust and C++ examples use prost and protobuf-lite. Co-authored-by: Isaac <no-reply@databricks.com>
…he streaming protos dev/streaming-gen-protos.sh generates the Python code of every message of sql/core with buf, and the CI checks that it matches python/pyspark/sql/streaming/proto. PySpark does not use the messages of native data sources, so the buf module of sql/core excludes them. Co-authored-by: Isaac <no-reply@databricks.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What changes were proposed in this pull request?
This is a draft for comparison with #59424, following @tdcmeehan's proposal on how the interface of native data sources grows. It is #59424 up to 51af65c, with its C interface, a table of typed functions, replaced by a frozen C core and versioned Protocol Buffers messages. Everything else is the same as in #59424: finding the libraries, the packages, the Data Source V2 adapter, the Arrow data path, the checks of the data, the docs and the tests.
The C core:
spark_datasource.hdeclares six functions, which never change:and
SparkDsHost, which starts with its size and only gets fields appended, with the callbacks of Spark:log, which writes to the log of the driver or the executor, andis_interrupted(task_attempt_id), which tells a read or a write that its task was killed.The messages: everything else is in
spark_datasource.proto(sql/core/src/main/protobuf/org/apache/spark/sql/datasource/, also copied to theincludedirectory of a distribution):OpenRequestopens a data source, a batch reader, a stream reader, a writer, a partition reader or a data writer, with everything that it needs: the name and the options of the data source, the schema, the write mode and the ID of a streaming query, theTaskContext, or the serialized state and partition. It also carries the version of Spark and of the messages. TheOpenReplyof a data source declares its capabilities, and Spark reports the operations that a data source does not declare as unsupported, without opening a reader or a writer.CallRequestandCallReplycarry every control operation:InferSchema,PushPredicates(with the answer of the reader for each predicate,PredicateUse),PushLimit,PruneColumns,PlanPartitions,Serialize,InitialOffset,LatestOffset,PlanStreamPartitions,CommitOffset,Commit,Abort,CommitDataWriterandAbortDataWriter. An empty reply is valid for every request: a reader without pushdown replies toPushPredicateswith nothing.StreamRequestexchanges the data, as Arrow C streams, without copying:Readinitializes the output stream of a partition reader, andWritegives a data writer the data of its task as an input stream, which the library reads during the call, on the thread of the task. Spark exports the rows of the task as one stream, fromDataWriter.writeAll, in batches that end atspark.sql.execution.arrow.maxRecordsPerBatchrows orspark.sql.execution.arrow.maxBytesPerBatchbytes.optimize_for = LITE_RUNTIME. With the full C++ runtime, the generated code registersspark_datasource.protoin a pool of the process: the C++ example of the docs, linked to the libprotobuf of Homebrew, aborted the JVM withFile already exists in database: spark_datasource.protowhen Spark loaded a second copy of it, the library of a package after the installed one. Lite messages register nothing. Libraries built against different versions of Protocol Buffers still conflict if they link it dynamically, so the docs ask for static linking, with hidden symbols.Compatibility: the six functions and the fields of
SparkDsHostnever change. The messages only grow: a later release may add fields, messages and values of enums, but never changes or reuses the number of a field, both sides ignore the fields that they do not know, and Spark only sends a new kind of request to an object whoseOpenReplydeclares that it supports it.SPARK_DS_ABI_VERSIONstays 1.How Spark calls the libraries:
NativeLibrary.javabinds the six functions by name with the FFM API, as #59424 does, checks the ABI version before it binds the others, allocates oneSparkDsHostfor each library, and never throws from the upcalls (SparkDsSink,logandis_interrupted). The tasks that use a library are registered by their attempt ID foris_interrupted.NativeDataSource.scalabuilds the requests and reads the replies, andNativeSchemasconverts the schemas. The columnar adapter getsArrowStreamDataWriterandArrowRowReader, so that a native data writer gets the input of a task as one Arrow stream; an error of the rows, which the stream only reports as a message, is rethrown as it is.spark_ds_init, which returns a table of 29 functionsspark_datasource.proto.proto, and a Protocol Buffers compiler and runtime, such as prost or protobuf C++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 only open data sources, batch readers and partition readers, and reply to the other requests, such as the pushdown, with empty replies. They are the examples of the docs.Rust (with arrow-rs and prost)
Cargo.toml:build.rs, which generates the code of the messages from theincludedirectory of a Spark distribution withprost-build, which runsprotoc:src/lib.rs:Build and install, here on macOS:
Or package, to add it to a session with
spark.addArtifact:C++ (with Arrow C++ and protobuf-lite)
cpp_range.cc:Generate the messages, then build (Arrow C++ 24 requires C++20) and install, or package as above:
Usage is the same as in #59424, 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?
To compare the two shapes of version 1 of the interface, which library binaries get built against, before Spark 4.4.0: see the discussion in #59424. A library that runs in another process, as the UDF worker protocol (SPARK-55278) does for UDFs, could get the same control messages, while #59424 would need a message for each of its functions.
Does this PR introduce any user-facing change?
The same as #59424, with this interface: native data sources implement
spark_datasource.handspark_datasource.proto, which a Spark distribution contains in itsincludedirectory.How was this patch tested?
All the tests of #59424, adapted to the messages, with the C++ test library implementing them with its own minimal Protocol Buffers codec, so that it still needs nothing but a C++ compiler. New tests:
ignore the fields of messages that they do not know: Spark ignores a field of a later version in every reply of a library, and a library ignores a field of a later version in a request.write the data of a task in one stream: a data writer gets the 10 rows of its task in one stream of 4 batches, withspark.sql.execution.arrow.maxRecordsPerBatchat 3.report the errors of the data that a library writes: a UDF that fails while the library reads the rows fails the write with the error of the UDF, not with the error that the library returns, and the write is aborted.reject the libraries that do not export the functions of the interface: a library withoutspark_ds_open.Locally on macOS (osx-aarch64), with Java 26 (Spark built with Java 17):
NativeDataSourceSuite,NativeDataSourceClusterSuite(executors on Java 26 too),ColumnarDataSourceSuiteandNativeDataSourceConnectSuitepass, and with Java 17,NativeDataSourceSuitepasses with the tests that load a library skipped. The Rust example of the docs (arrow-rs 58, prost 0.14) and the C++ example (Arrow C++ 24, protobuf-lite 35) were built with the documented commands and read by Spark on Java 26: installed and in a package, with errors of the library reported asNATIVE_DATA_SOURCE_ERROR. The C++ test library builds without warnings with Apple clang (-Wall -Wextra) in all its variants.checkstyleandscalastyleof the changed modules pass.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.