Skip to content

[SPARK-60101][SQL] Support native data sources with a frozen C core and versioned messages - #59498

Closed
HyukjinKwon wants to merge 27 commits into
apache:masterfrom
HyukjinKwon:SPARK-60101-msg
Closed

HyukjinKwon wants to merge 27 commits into
apache:masterfrom
HyukjinKwon:SPARK-60101-msg

Conversation

@HyukjinKwon

@HyukjinKwon HyukjinKwon commented Oct 15, 2026 •

Copy link
Copy Markdown
Member

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.h declares six functions, which never change:

int32_t spark_ds_abi_version(void);
const char* spark_ds_last_error(void);
int spark_ds_open(const SparkDsHost* host, const uint8_t* request, int64_t length,
                  SparkDsSink reply, void* sink, void** handle);
int spark_ds_call(void* handle, const uint8_t* request, int64_t length,
                  SparkDsSink reply, void* sink);
int spark_ds_stream(void* handle, const uint8_t* request, int64_t length,
                    struct ArrowArrayStream* in, struct ArrowArrayStream* out);
void spark_ds_close(void* handle);

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, and is_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 the include directory of a distribution):

  • OpenRequest opens 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, the TaskContext, or the serialized state and partition. It also carries the version of Spark and of the messages. The OpenReply of 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.
  • CallRequest and CallReply carry 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, CommitDataWriter and AbortDataWriter. An empty reply is valid for every request: a reader without pushdown replies to PushPredicates with nothing.
  • StreamRequest exchanges the data, as Arrow C streams, without copying: Read initializes the output stream of a partition reader, and Write gives 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, from DataWriter.writeAll, in batches that end at spark.sql.execution.arrow.maxRecordsPerBatch rows or spark.sql.execution.arrow.maxBytesPerBatch bytes.
  • Schemas are Spark SQL types, as in Spark Connect, so that every control message can be forwarded unchanged to a library that runs in another process, for example to isolate it, while the data is converted between Arrow C streams and Arrow IPC.
  • The messages use optimize_for = LITE_RUNTIME. With the full C++ runtime, the generated code registers spark_datasource.proto in a pool of the process: the C++ example of the docs, linked to the libprotobuf of Homebrew, aborted the JVM with File already exists in database: spark_datasource.proto when 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 SparkDsHost never 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 whose OpenReply declares that it supports it. SPARK_DS_ABI_VERSION stays 1.

How Spark calls the libraries: NativeLibrary.java binds the six functions by name with the FFM API, as #59424 does, checks the ABI version before it binds the others, allocates one SparkDsHost for each library, and never throws from the upcalls (SparkDsSink, log and is_interrupted). The tasks that use a library are registered by their attempt ID for is_interrupted. NativeDataSource.scala builds the requests and reads the replies, and NativeSchemas converts the schemas. The columnar adapter gets ArrowStreamDataWriter and ArrowRowReader, 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.

#59424 (function table) This PR (messages)
What a library exports spark_ds_init, which returns a table of 29 functions 6 functions, which never change
What grows functions appended to the table, fields appended to its size-prefixed structs fields and messages of spark_datasource.proto
What checks it the C, C++ or Rust compiler, for each function the generated code of the messages; the compiler checks the 6 functions
What a library needs the header the header, the .proto, and a Protocol Buffers compiler and runtime, such as prost or protobuf C++
Libraries in one process share nothing but the header each has its own Protocol Buffers runtime: lite messages, linked statically with hidden symbols for C++
A host in another process maps each function to a message of its own forwards the control messages unchanged
The binding of Spark a method handle for each function, and the structs 6 method handles; the requests and replies in Scala
The C++ test library, without dependencies fills the table a minimal Protocol Buffers codec written by hand, of about 200 lines

Examples

Both examples implement a read-only data source that returns the numbers [0, end) as a column id, in two partitions, with the option end (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:

[package]
name = "rust_range"
version = "0.1.0"
edition = "2021"

[lib]
name = "spark_datasource_rust_range"
crate-type = ["cdylib"]

[dependencies]
arrow-array = { version = "58", features = ["ffi"] }
arrow-schema = { version = "58", features = ["ffi"] }
prost = "0.14"

[build-dependencies]
prost-build = "0.14"

build.rs, which generates the code of the messages from the include directory of a Spark distribution with prost-build, which runs protoc:

// Generates the code of the messages from spark_datasource.proto, in the include directory of a
// Spark distribution. prost-build runs protoc, which must be installed.
fn main() -> std::io::Result<()> {
    let include = format!("{}/include", std::env::var("SPARK_HOME").expect("SPARK_HOME"));
    prost_build::compile_protos(&[format!("{include}/spark_datasource.proto")], &[include])
}

src/lib.rs:

//! A native data source for Apache Spark, written in Rust. It reads the numbers [0, end) as a
//! column `id`, in two partitions. The option `end` defaults to 10.

use std::cell::RefCell;
use std::ffi::{c_char, c_int, c_void, CString};
use std::panic::{catch_unwind, AssertUnwindSafe};
use std::sync::Arc;

use arrow_array::ffi_stream::FFI_ArrowArrayStream;
use arrow_array::{Int64Array, RecordBatch, RecordBatchIterator};
use arrow_schema::{DataType, Field, Schema, SchemaRef};
use prost::Message;

/// The messages of `spark_datasource.proto`.
mod proto {
    include!(concat!(env!("OUT_DIR"), "/spark.datasource.rs"));
}

use proto::{call_reply, call_request, data_type, open_request, stream_request};

type Result<T> = std::result::Result<T, Box<dyn std::error::Error>>;

/// `SparkDsSink`: receives the reply of a function, and copies it.
type Sink = unsafe extern "C" fn(sink: *mut c_void, data: *const u8, length: i64);

/// What a handle refers to.
enum Object {
    DataSource,
    Reader { end: i64 },
    Partition { start: i64, end: i64 },
}

thread_local! {
    static LAST_ERROR: RefCell<CString> = RefCell::new(CString::default());
}

fn arrow_schema() -> SchemaRef {
    Arc::new(Schema::new(vec![Field::new("id", DataType::Int64, false)]))
}

/// The schema of the data, as the Spark SQL types of the messages.
fn spark_schema() -> proto::StructType {
    let long = data_type::Kind::Long(data_type::Long {});
    proto::StructType {
        fields: vec![proto::StructField {
            name: "id".to_string(),
            data_type: Some(proto::DataType { kind: Some(long) }),
            nullable: false,
        }],
    }
}

/// Runs `f`, and reports its error or panic to Spark: neither may cross into the JVM.
fn call(f: impl FnOnce() -> Result<()>) -> c_int {
    let message = match catch_unwind(AssertUnwindSafe(f)) {
        Ok(Ok(())) => return 0,
        Ok(Err(e)) => e.to_string(),
        Err(_) => "the data source panicked".to_string(),
    };
    LAST_ERROR.with(|error| *error.borrow_mut() = CString::new(message).unwrap_or_default());
    1
}

unsafe fn decode<M: Message + Default>(request: *const u8, length: i64) -> Result<M> {
    Ok(M::decode(std::slice::from_raw_parts(request, length as usize))?)
}

unsafe fn reply(reply: Sink, sink: *mut c_void, message: &impl Message) {
    let bytes = message.encode_to_vec();
    reply(sink, bytes.as_ptr(), bytes.len() as i64);
}

#[no_mangle]
pub extern "C" fn spark_ds_abi_version() -> i32 {
    1
}

#[no_mangle]
pub extern "C" fn spark_ds_last_error() -> *const c_char {
    LAST_ERROR.with(|error| error.borrow().as_ptr())
}

#[no_mangle]
pub unsafe extern "C" fn spark_ds_open(
    _host: *const c_void,
    request: *const u8,
    length: i64,
    sink_function: Sink,
    sink: *mut c_void,
    handle: *mut *mut c_void,
) -> c_int {
    call(|| {
        let object = match decode::<proto::OpenRequest>(request, length)?.object {
            Some(open_request::Object::DataSource(_)) => {
                let capabilities = vec![proto::Capability::BatchRead as i32];
                reply(sink_function, sink, &proto::OpenReply { capabilities });
                Object::DataSource
            }
            Some(open_request::Object::BatchReader(reader)) => {
                let options = reader.data_source.unwrap_or_default().options;
                let end = options.get("end").map(|end| end.parse()).transpose()?;
                Object::Reader { end: end.unwrap_or(10) }
            }
            Some(open_request::Object::PartitionReader(reader)) => {
                let bytes = reader.partition;
                let start = i64::from_le_bytes(bytes[0..8].try_into()?);
                let end = i64::from_le_bytes(bytes[8..16].try_into()?);
                Object::Partition { start, end }
            }
            _ => return Err("this data source only supports batch reads".into()),
        };
        *handle = Box::into_raw(Box::new(object)) as *mut c_void;
        Ok(())
    })
}

#[no_mangle]
pub unsafe extern "C" fn spark_ds_call(
    handle: *mut c_void,
    request: *const u8,
    length: i64,
    sink_function: Sink,
    sink: *mut c_void,
) -> c_int {
    call(|| {
        let object = &*(handle as *const Object);
        let result = match (decode::<proto::CallRequest>(request, length)?.call, object) {
            (Some(call_request::Call::InferSchema(_)), _) => {
                let schema = Some(spark_schema());
                call_reply::Reply::InferSchema(proto::InferSchemaReply { schema })
            }
            (Some(call_request::Call::PlanPartitions(_)), Object::Reader { end }) => {
                let partitions = [(0, end / 2), (end / 2, *end)]
                    .iter()
                    .map(|(start, end)| [start.to_le_bytes(), end.to_le_bytes()].concat())
                    .collect();
                call_reply::Reply::PlanPartitions(proto::PlanPartitionsReply { partitions })
            }
            // The other requests, such as the pushdown, have empty replies.
            _ => return Ok(()),
        };
        reply(sink_function, sink, &proto::CallReply { reply: Some(result) });
        Ok(())
    })
}

#[no_mangle]
pub unsafe extern "C" fn spark_ds_stream(
    handle: *mut c_void,
    request: *const u8,
    length: i64,
    _input: *mut FFI_ArrowArrayStream,
    output: *mut FFI_ArrowArrayStream,
) -> c_int {
    call(|| {
        let Object::Partition { start, end } = &*(handle as *const Object) else {
            return Err("not a partition reader".into());
        };
        let stream = decode::<proto::StreamRequest>(request, length)?.stream;
        let Some(stream_request::Stream::Read(_)) = stream else {
            return Err("this data source only supports reads".into());
        };
        let ids = Int64Array::from_iter_values(*start..*end);
        let batch = RecordBatch::try_new(arrow_schema(), vec![Arc::new(ids)])?;
        let batches = RecordBatchIterator::new(vec![Ok(batch)], arrow_schema());
        std::ptr::write(output, FFI_ArrowArrayStream::new(Box::new(batches)));
        Ok(())
    })
}

#[no_mangle]
pub unsafe extern "C" fn spark_ds_close(handle: *mut c_void) {
    drop(Box::from_raw(handle as *mut Object));
}

Build and install, here on macOS:

SPARK_HOME=/path/to/spark cargo build --release
cp target/release/libspark_datasource_rust_range.dylib /usr/local/lib/

Or package, to add it to a session with spark.addArtifact:

mkdir -p package/osx-aarch64
cp target/release/libspark_datasource_rust_range.dylib package/osx-aarch64/
cat > package/spark-native-datasource.json <<EOF
{
  "abiVersion": 1,
  "dataSources": ["rust_range"],
  "libraries": {"osx-aarch64": "osx-aarch64/libspark_datasource_rust_range.dylib"}
}
EOF
(cd package && zip -r ../rust_range.sparkpkg .)
C++ (with Arrow C++ and protobuf-lite)

cpp_range.cc:

// A native data source for Apache Spark, written in C++ with Arrow C++ and Protocol Buffers. It
// reads the numbers [0, end) as a column `id`, in two partitions. The option `end` defaults to 10.

#include <cstdint>
#include <cstring>
#include <memory>
#include <stdexcept>
#include <string>
#include <variant>

#include <arrow/api.h>
#include <arrow/c/bridge.h>

#include "spark_datasource.h"
#include "spark_datasource.pb.h"

namespace {

namespace sds = spark::datasource;

// What a handle refers to.
struct DataSource {};
struct Reader {
  int64_t end;
};
struct Partition {
  int64_t start;
  int64_t end;
};
using Object = std::variant<DataSource, Reader, Partition>;

thread_local std::string last_error;

std::shared_ptr<arrow::Schema> Schema() {
  return arrow::schema({arrow::field("id", arrow::int64(), /*nullable=*/false)});
}

// Runs `f`, and reports its error to Spark: no C++ exception may cross into the JVM.
template <typename F>
int Call(F&& f) {
  try {
    f();
    return 0;
  } catch (const std::exception& e) {
    last_error = e.what();
    return 1;
  } catch (...) {
    last_error = "unknown error";
    return 1;
  }
}

void Check(const arrow::Status& status) {
  if (!status.ok()) throw std::runtime_error(status.ToString());
}

template <typename M>
M Decode(const uint8_t* request, int64_t length) {
  M message;
  if (!message.ParseFromArray(request, static_cast<int>(length))) {
    throw std::runtime_error("invalid request");
  }
  return message;
}

void Reply(SparkDsSink reply, void* sink, const google::protobuf::MessageLite& message) {
  std::string bytes = message.SerializeAsString();
  reply(sink, reinterpret_cast<const uint8_t*>(bytes.data()), static_cast<int64_t>(bytes.size()));
}

}  // namespace

extern "C" {

int32_t spark_ds_abi_version(void) { return SPARK_DS_ABI_VERSION; }

const char* spark_ds_last_error(void) { return last_error.c_str(); }

int spark_ds_open(const SparkDsHost*, const uint8_t* request, int64_t length, SparkDsSink reply,
                  void* sink, void** handle) {
  return Call([&] {
    auto open = Decode<sds::OpenRequest>(request, length);
    std::unique_ptr<Object> object;
    if (open.has_data_source()) {
      sds::OpenReply capabilities;
      capabilities.add_capabilities(sds::CAPABILITY_BATCH_READ);
      Reply(reply, sink, capabilities);
      object = std::make_unique<Object>(DataSource{});
    } else if (open.has_batch_reader()) {
      const auto& options = open.batch_reader().data_source().options();
      auto end = options.find("end");
      object = std::make_unique<Object>(
          Reader{end == options.end() ? 10 : std::stoll(end->second)});
    } else if (open.has_partition_reader()) {
      int64_t bounds[2];
      std::memcpy(bounds, open.partition_reader().partition().data(), sizeof(bounds));
      object = std::make_unique<Object>(Partition{bounds[0], bounds[1]});
    } else {
      throw std::runtime_error("this data source only supports batch reads");
    }
    *handle = object.release();
  });
}

int spark_ds_call(void* handle, const uint8_t* request, int64_t length, SparkDsSink reply,
                  void* sink) {
  return Call([&] {
    auto call = Decode<sds::CallRequest>(request, length);
    sds::CallReply result;
    if (call.has_infer_schema()) {
      auto* field = result.mutable_infer_schema()->mutable_schema()->add_fields();
      field->set_name("id");
      field->mutable_data_type()->mutable_long_();
      field->set_nullable(false);
    } else if (call.has_plan_partitions()) {
      int64_t end = std::get<Reader>(*static_cast<Object*>(handle)).end;
      int64_t bounds[2][2] = {{0, end / 2}, {end / 2, end}};
      for (const auto& partition : bounds) {
        result.mutable_plan_partitions()->add_partitions(partition, sizeof(partition));
      }
    } else {
      return;  // The other requests, such as the pushdown, have empty replies.
    }
    Reply(reply, sink, result);
  });
}

int spark_ds_stream(void* handle, const uint8_t* request, int64_t length, struct ArrowArrayStream*,
                    struct ArrowArrayStream* out) {
  return Call([&] {
    if (!Decode<sds::StreamRequest>(request, length).has_read()) {
      throw std::runtime_error("this data source only supports reads");
    }
    const auto& partition = std::get<Partition>(*static_cast<Object*>(handle));
    arrow::Int64Builder ids;
    for (int64_t id = partition.start; id < partition.end; id++) Check(ids.Append(id));
    std::shared_ptr<arrow::Array> array;
    Check(ids.Finish(&array));
    auto batch = arrow::RecordBatch::Make(Schema(), array->length(), {array});
    auto batches = arrow::RecordBatchReader::Make({batch}, Schema()).ValueOrDie();
    Check(arrow::ExportRecordBatchReader(batches, out));
  });
}

void spark_ds_close(void* handle) { delete static_cast<Object*>(handle); }

}  // extern "C"

Generate the messages, then build (Arrow C++ 24 requires C++20) and install, or package as above:

protoc --cpp_out=. -I"$SPARK_HOME/include" "$SPARK_HOME/include/spark_datasource.proto"
c++ -std=c++20 -O2 -shared -fPIC -I. -I"$SPARK_HOME/include" -I"$ARROW_HOME/include" \
  cpp_range.cc spark_datasource.pb.cc $(pkg-config --cflags --libs protobuf-lite) \
  -L"$ARROW_HOME/lib" -larrow -o libspark_datasource_cpp_range.dylib
cp libspark_datasource_cpp_range.dylib /usr/local/lib/

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.addArtifact or any configuration:

>>> spark.read.format("rust_range").option("end", 5).load().show()
+---+
| id|
+---+
|  0|
|  1|
|  2|
|  3|
|  4|
+---+
>>> spark.read.format("cpp_range").option("end", 5).load().filter("id >= 3").collect()
[Row(id=3), Row(id=4)]

Or added to a session as a package:

>>> spark.addArtifact("rust_range/rust_range.sparkpkg")
>>> spark.read.format("rust_range").option("end", 5).load().count()
5

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.h and spark_datasource.proto, which a Spark distribution contains in its include directory.

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, with spark.sql.execution.arrow.maxRecordsPerBatch at 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 without spark_ds_open.

Locally on macOS (osx-aarch64), with Java 26 (Spark built with Java 17): NativeDataSourceSuite, NativeDataSourceClusterSuite (executors on Java 26 too), ColumnarDataSourceSuite and NativeDataSourceConnectSuite pass, and with Java 17, NativeDataSourceSuite passes 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 as NATIVE_DATA_SOURCE_ERROR. The C++ test library builds without warnings with Apple clang (-Wall -Wextra) in all its variants. checkstyle and scalastyle of 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.

HyukjinKwon and others added 27 commits October 9, 2026 10:09
…(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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant