This is an automated email from the ASF dual-hosted git repository.
alamb pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/arrow-rs.git
The following commit(s) were added to refs/heads/main by this push:
new 305689fe1a feat(ffi): add unsafe constructors and accessors for
`FFI_ArrowArrayStream` (#11125)
305689fe1a is described below
commit 305689fe1af8afa7f54f75121ff16bd5fceb532c
Author: Kyle Barron <[email protected]>
AuthorDate: Fri Sep 18 15:20:59 2026 -0400
feat(ffi): add unsafe constructors and accessors for `FFI_ArrowArrayStream`
(#11125)
# Which issue does this PR close?
- Closes #11124.
# Rationale for this change
# What changes are included in this PR?
New unsafe constructors and accessors for `FFI_ArrowArrayStream` for
downstream consumers that don't use `RecordBatchStream`.
# Are these changes tested?
Yes. The tests were written by Claude but they look reasonable to me.
# Are there any user-facing changes?
Yes, a new `new_unchecked` constructor and new accessors.
---------
Co-authored-by: Claude Opus 5 (1M context) <[email protected]>
---
arrow-array/src/ffi_stream.rs | 192 ++++++++++++++++++++++++++++++++++++++++++
1 file changed, 192 insertions(+)
diff --git a/arrow-array/src/ffi_stream.rs b/arrow-array/src/ffi_stream.rs
index 4bb570d56a..32ed6526ec 100644
--- a/arrow-array/src/ffi_stream.rs
+++ b/arrow-array/src/ffi_stream.rs
@@ -187,6 +187,42 @@ impl FFI_ArrowArrayStream {
}
}
+ /// Creates a new [`FFI_ArrowArrayStream`] from its raw parts.
+ ///
+ /// # Safety
+ ///
+ /// The caller takes responsibility for the [C stream interface] contract:
+ ///
+ /// * Each callback that is `Some` must be sound to invoke with a pointer
to
+ /// this struct, and must implement the semantics the C stream interface
+ /// specifies for it.
+ /// * `private_data` must own everything the callbacks rely on, and must
+ /// remain valid until `release` is called.
+ /// * `release`, if `Some`, is invoked by [`Drop`] with a pointer to this
+ /// struct. It must free `private_data` exactly once and then set the
+ /// release callback to `None` to mark the stream released.
+ /// * If `release` is `None` the stream is already released, so dropping it
+ /// must not leak: nothing may be left for the callback to free.
+ ///
+ /// [C stream interface]:
https://arrow.apache.org/docs/format/CStreamInterface.html
+ pub unsafe fn new_unchecked(
+ get_schema: Option<
+ unsafe extern "C" fn(arg1: *mut Self, out: *mut FFI_ArrowSchema)
-> c_int,
+ >,
+ get_next: Option<unsafe extern "C" fn(arg1: *mut Self, out: *mut
FFI_ArrowArray) -> c_int>,
+ get_last_error: Option<unsafe extern "C" fn(arg1: *mut Self) -> *const
c_char>,
+ release: Option<unsafe extern "C" fn(arg1: *mut Self)>,
+ private_data: *mut c_void,
+ ) -> Self {
+ Self {
+ get_schema,
+ get_next,
+ get_last_error,
+ release,
+ private_data,
+ }
+ }
+
/// Takes ownership of the pointed to [`FFI_ArrowArrayStream`]
///
/// This acts to [move] the data out of `raw_stream`, setting the release
callback to NULL
@@ -214,6 +250,42 @@ impl FFI_ArrowArrayStream {
}
}
+ #[expect(clippy::unnecessary_safety_doc)]
+ /// Returns the producer-provided callback that writes this stream's
schema, if any.
+ ///
+ /// # Safety
+ ///
+ /// The callback must be invoked with a pointer to the stream it was read
from.
+ pub fn get_schema(
+ &self,
+ ) -> Option<unsafe extern "C" fn(arg1: *mut Self, out: *mut
FFI_ArrowSchema) -> c_int> {
+ self.get_schema
+ }
+
+ #[expect(clippy::unnecessary_safety_doc)]
+ /// Returns the producer-provided callback that yields the next array, if
any.
+ ///
+ /// # Safety
+ ///
+ /// The callback must be invoked with a pointer to the stream it was read
from.
+ pub fn get_next(
+ &self,
+ ) -> Option<unsafe extern "C" fn(arg1: *mut Self, out: *mut
FFI_ArrowArray) -> c_int> {
+ self.get_next
+ }
+
+ #[expect(clippy::unnecessary_safety_doc)]
+ /// Returns the producer-provided callback that describes the last error,
if any.
+ ///
+ /// # Safety
+ ///
+ /// The callback must be invoked with a pointer to the stream it was read
from.
+ /// The string it returns is owned by the producer and valid only until
the next
+ /// call on the stream.
+ pub fn get_last_error(&self) -> Option<unsafe extern "C" fn(arg1: *mut
Self) -> *const c_char> {
+ self.get_last_error
+ }
+
/// Returns the producer-provided release callback, if any.
pub fn release(&self) -> Option<unsafe extern "C" fn(arg1: *mut Self)> {
self.release
@@ -784,4 +856,124 @@ mod tests {
drop(stream); // runs wrapping_release, which chains to the original
assert!(STREAM_WRAPPER_RAN.load(Ordering::SeqCst));
}
+
+ // A producer that exports a stream of plain arrays rather than record
batches.
+ // `FFI_ArrowArrayStream::new` cannot express this, since it takes a
+ // `RecordBatchReader`. See
<https://github.com/apache/arrow-rs/issues/6586>.
+ struct ArrayStreamPrivateData {
+ field: Field,
+ arrays: std::vec::IntoIter<Int32Array>,
+ }
+
+ unsafe extern "C" fn array_stream_get_schema(
+ stream: *mut FFI_ArrowArrayStream,
+ out: *mut FFI_ArrowSchema,
+ ) -> c_int {
+ let private_data = unsafe {
&*(*stream).private_data().cast::<ArrayStreamPrivateData>() };
+ let schema = FFI_ArrowSchema::try_from(&private_data.field).unwrap();
+ unsafe { std::ptr::write(out, schema) };
+ 0
+ }
+
+ unsafe extern "C" fn array_stream_get_next(
+ stream: *mut FFI_ArrowArrayStream,
+ out: *mut FFI_ArrowArray,
+ ) -> c_int {
+ let private_data =
+ unsafe { &mut
*(*stream).private_data().cast::<ArrayStreamPrivateData>() };
+ match private_data.arrays.next() {
+ // Marks ArrowArray released to indicate reaching the end of
stream.
+ None => unsafe { std::ptr::write(out, FFI_ArrowArray::empty()) },
+ Some(array) => unsafe {
+ std::ptr::write_unaligned(out,
FFI_ArrowArray::new(&array.to_data()))
+ },
+ }
+ 0
+ }
+
+ unsafe extern "C" fn array_stream_get_last_error(
+ _stream: *mut FFI_ArrowArrayStream,
+ ) -> *const c_char {
+ std::ptr::null()
+ }
+
+ unsafe extern "C" fn array_stream_release(stream: *mut
FFI_ArrowArrayStream) {
+ let private_data =
+ unsafe {
Box::from_raw((*stream).private_data().cast::<ArrayStreamPrivateData>()) };
+ drop(private_data);
+ // Clears every callback and marks the stream released, without
dropping the
+ // value being overwritten.
+ unsafe { std::ptr::write(stream, FFI_ArrowArrayStream::empty()) };
+ }
+
+ fn array_stream(field: Field, arrays: Vec<Int32Array>) ->
FFI_ArrowArrayStream {
+ let private_data = Box::new(ArrayStreamPrivateData {
+ field,
+ arrays: arrays.into_iter(),
+ });
+
+ unsafe {
+ FFI_ArrowArrayStream::new_unchecked(
+ Some(array_stream_get_schema),
+ Some(array_stream_get_next),
+ Some(array_stream_get_last_error),
+ Some(array_stream_release),
+ Box::into_raw(private_data).cast::<c_void>(),
+ )
+ }
+ }
+
+ #[test]
+ fn test_new_unchecked_exports_stream_of_arrays() {
+ let field = Field::new("a", DataType::Int32, true);
+ let arrays = vec![
+ Int32Array::from(vec![1, 2, 3]),
+ Int32Array::from(vec![4, 5]),
+ ];
+ let mut stream = array_stream(field.clone(), arrays.clone());
+
+ // Drive the stream through the callbacks the constructor stored, so a
+ // field the constructor put in the wrong place would surface here.
+ let get_schema_fn = stream.get_schema().unwrap();
+ let get_next_fn = stream.get_next().unwrap();
+ let get_last_error_fn = stream.get_last_error().unwrap();
+
+ let mut ffi_schema = FFI_ArrowSchema::empty();
+ let ret_code = unsafe { get_schema_fn(&raw mut stream, &raw mut
ffi_schema) };
+ assert_eq!(ret_code, 0);
+ assert_eq!(Field::try_from(&ffi_schema).unwrap(), field);
+
+ let mut produced = vec![];
+ loop {
+ let mut ffi_array = FFI_ArrowArray::empty();
+ let ret_code = unsafe { get_next_fn(&raw mut stream, &raw mut
ffi_array) };
+ assert_eq!(ret_code, 0);
+
+ // The end of stream has been reached
+ if ffi_array.is_released() {
+ break;
+ }
+
+ let data = unsafe { from_ffi(ffi_array, &ffi_schema) }.unwrap();
+ produced.push(Int32Array::from(data));
+ }
+
+ assert_eq!(produced, arrays);
+
+ // This producer never fails, so it reports no error message.
+ assert!(unsafe { get_last_error_fn(&raw mut stream) }.is_null());
+
+ // Runs the stored release callback, freeing the private data exactly
once.
+ drop(stream);
+ }
+
+ #[test]
+ fn test_accessors_report_an_empty_stream_as_released() {
+ let stream = FFI_ArrowArrayStream::empty();
+ assert!(stream.get_schema().is_none());
+ assert!(stream.get_next().is_none());
+ assert!(stream.get_last_error().is_none());
+ assert!(stream.release().is_none());
+ assert!(stream.private_data().is_null());
+ }
}