GH-184: Add Arrow C Data Interface import for null and primitive arrays#607
GH-184: Add Arrow C Data Interface import for null and primitive arrays#607samtalki wants to merge 1 commit into
Conversation
Add immutable ArrowSchema and ArrowArray ABI structs plus C Data import for null and primitive arrays. Validate pointer layout, child counts, buffer counts, offsets, known flag bits, dictionary pointer consistency, release idempotency, and reads after release. Trust declared null counts like Arrow C++ and arrow-rs, require a validity bitmap for unknown null counts like nanoarrow, and ignore reserved flag bits for forward compatibility. Move the base ArrowSchema and ArrowArray structures into Julia owned storage at import and mark the sources released without calling their callbacks, following the C Data Interface move semantics used by arrow-rs from_raw and nanoarrow ArrowArrayMove. Callers may free or reuse the passed structures once from_c_data returns; release_c_data or finalization releases the moved copies through the producer callbacks. Keep imported buffers rooted behind a shared release owner. Copy misaligned fixed width buffers into aligned Julia storage before typed access, and materialize Julia owned data from copy, collect, deepcopy, and serialize so no read path bypasses the release liveness gate. Co-authored-by: Robert Buessow <robert.buessow@relational.ai> Co-authored-by: Olle Martensson <olle.martensson@gmail.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com> Generated-by: OpenAI Codex
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## main #607 +/- ##
==========================================
+ Coverage 87.43% 88.16% +0.72%
==========================================
Files 26 28 +2
Lines 3288 3666 +378
==========================================
+ Hits 2875 3232 +357
- Misses 413 434 +21 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
| # On 32 bit ABIs with 8 byte Int64 alignment the C structs contain padding, so | ||
| # the packed size formulas hold only where pointer and Int64 sizes agree. | ||
| @static if Sys.WORD_SIZE == 64 | ||
| @assert sizeof(ArrowSchema) == 7 * _CDATA_PTR_SIZE + 2 * sizeof(Int64) | ||
| @assert sizeof(ArrowArray) == 5 * _CDATA_PTR_SIZE + 5 * sizeof(Int64) |
There was a problem hiding this comment.
Does this mean that sizeof(ArrowSchema)/sizeof(ArrowArray) may be different on 32 bit environment?
| const ARROW_FLAG_NULLABLE = Int64(2) | ||
| const ARROW_FLAG_MAP_KEYS_SORTED = Int64(4) | ||
|
|
||
| const _CDATA_MAX_FORMAT_BYTES = 4096 |
There was a problem hiding this comment.
I don't object this for safety but is 4096 reasonable limit?
| unlock(owner.lock) | ||
| end | ||
| else | ||
| finalizer(_finalize_c_data, owner) |
There was a problem hiding this comment.
I'm not familiar with Julia but why can we retry the finalizer? If finalizer() is called on owner, is finalization of the owner cancelled?
| function _count_nulls(bytes::Vector{UInt8}, bitoffset::Int, len::Int) | ||
| len == 0 && return 0 | ||
| firstbit = bitoffset | ||
| lastbit = bitoffset + len - 1 | ||
| firstbyte = firstbit >>> 3 | ||
| lastbyte = lastbit >>> 3 | ||
| firstmask = 0xff << (firstbit & 7) | ||
| lastmask = 0xff >>> (7 - (lastbit & 7)) | ||
| set = 0 | ||
| if firstbyte == lastbyte | ||
| set = count_ones(@inbounds(bytes[firstbyte + 1]) & firstmask & lastmask) | ||
| else | ||
| set = count_ones(@inbounds(bytes[firstbyte + 1]) & firstmask) | ||
| @inbounds for b = (firstbyte + 2):lastbyte | ||
| set += count_ones(bytes[b]) | ||
| end | ||
| set += count_ones(@inbounds(bytes[lastbyte + 1]) & lastmask) | ||
| end | ||
| return len - set | ||
| end |
There was a problem hiding this comment.
Do we need to implement this? (Do we have any existing feature for this?)
| @propagate_inbounds function Base.getindex(x::CDataNull, i::Integer) | ||
| return _with_live(x) do | ||
| @boundscheck checkbounds(x, i) | ||
| return missing | ||
| end | ||
| end | ||
|
|
||
| @propagate_inbounds function Base.getindex(x::CDataPrimitive{T}, i::Integer) where {T} | ||
| return _with_live(x) do | ||
| @boundscheck checkbounds(x, i) | ||
| if !_valid(x.validity, i) | ||
| return missing | ||
| end | ||
| return @inbounds ArrowTypes.fromarrow(T, x.data[i]) | ||
| end | ||
| end | ||
|
|
||
| function Base.collect(x::CDataVector{T}) where {T} | ||
| return _with_live(x) do | ||
| out = Vector{T}(undef, length(x)) | ||
| for i in eachindex(x) | ||
| @inbounds out[i] = x[i] | ||
| end | ||
| return out | ||
| end | ||
| end |
There was a problem hiding this comment.
Why do we need to implement them?
Can users access the the Arrow data after they import the Arrow data?
| unsafe_store!( | ||
| ptr, | ||
| ArrowArray( | ||
| array.length, | ||
| array.null_count, | ||
| array.offset, | ||
| array.n_buffers, | ||
| array.n_children, | ||
| array.buffers, | ||
| array.children, | ||
| array.dictionary, | ||
| C_NULL, | ||
| array.private_data, | ||
| ), | ||
| ) |
There was a problem hiding this comment.
I'm not familiar with Julia but we can't update only array.release with Julia API, right?
| function _load_buffers(array::ArrowArray, expected::Int) | ||
| n_buffers = _checked_nonnegative(array.n_buffers, "ArrowArray.n_buffers") | ||
| n_buffers == expected || | ||
| throw(ArgumentError("ArrowArray.n_buffers does not match the format")) |
There was a problem hiding this comment.
How about adding the actual and expected N buffers to the error messages for easy to debug?
| schema_children = _checked_nonnegative(schema.n_children, "ArrowSchema.n_children") | ||
| schema_children == n_children || |
There was a problem hiding this comment.
| schema_children = _checked_nonnegative(schema.n_children, "ArrowSchema.n_children") | |
| schema_children == n_children || | |
| schema_n_children = _checked_nonnegative(schema.n_children, "ArrowSchema.n_children") | |
| schema_n_children == n_children || |
| n_children = _checked_nonnegative(array.n_children, "ArrowArray.n_children") | ||
| schema_children = _checked_nonnegative(schema.n_children, "ArrowSchema.n_children") | ||
| schema_children == n_children || | ||
| throw(ArgumentError("ArrowSchema and ArrowArray child counts differ")) |
There was a problem hiding this comment.
Can we show actual/expected values in the error message?
| throw(ArgumentError("ArrowArray.null_count is out of range")) | ||
| end | ||
| if (schema.dictionary == C_NULL) != (array.dictionary == C_NULL) | ||
| throw(ArgumentError("ArrowSchema and ArrowArray dictionary pointers differ")) |
There was a problem hiding this comment.
Is this error message correct?
It seems that this checks only whether dictionary is NULL or not.
Summary
First PR of the C Data Interface stack for GH-184, extracting null and primitive array import from #603 as requested there.
ArrowSchemaandArrowArrayC layout structs with layout assertions.Arrow.from_c_data(schema_ptr, array_ptr; convert=true)for null and primitive arrays.null_count == -1requires the validity bitmap it is resolved from, like nanoarrow; reserved flag bits are ignored.ArrowSchemaandArrowArraystructures into Julia owned storage and marks the sources released without calling their callbacks; callers may free or reuse the passed structures oncefrom_c_datareturns.copy,collect,deepcopy, andserializematerialize Julia owned data through the release liveness gate.Struct, boolean, string, binary, list, and export support land in the follow up PRs.
Stack
Base is
main.Follow up PRs:
Size
Against
main: 6 files changed, 1246 insertions, 1 deletion.Validation
git diff --check main...HEAD: passed.ArrowSchemaandArrowArrayare isbits structs with expected C layout sizes.AI Assistance
Co-authored-by: Robert Buessow robert.buessow@relational.ai
Co-authored-by: Olle Martensson olle.martensson@gmail.com
Generated-by: OpenAI Codex