This is an automated email from the ASF dual-hosted git repository.
mapleFU 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 98998a8533 fix: `GenericByteViewArray::gc()` drops inline views on the
multi-buffer slow path (#10287)
98998a8533 is described below
commit 98998a8533e8426a823cde64977b1ca02bb89fba
Author: Adrian Garcia Badaracco <[email protected]>
AuthorDate: Mon Jul 20 22:32:36 2026 -0500
fix: `GenericByteViewArray::gc()` drops inline views on the multi-buffer
slow path (#10287)
# Which issue does this PR close?
<!-- We generally require a GitHub issue to be filed for all bug fixes
and enhancements -->
N/A — filing alongside; happy to open a tracking issue if preferred.
# Rationale for this change
`GenericByteViewArray::gc()` takes a multi-buffer **slow path** once a
`Utf8View`/`BinaryView` column references more than `i32::MAX` (~2.1
GiB) of non-inline data. Because a single Arrow data buffer is addressed
by a `u32` offset, the compacted output has to be split across multiple
buffers there.
That slow path built its copy groups by counting **only non-inline
views** (`current_elements`), then copied a *contiguous index range* of
that size. As a result:
- every **inline** (short) view was dropped, so the output array was
**shorter than its input**, and
- the index-range / byte-budget mismatch could also let a single output
buffer grow past `i32::MAX`.
`gc()` is documented as a pure size optimisation and must never change
the row count. Downstream code that rebuilds a `RecordBatch` from the
GC'd columns therefore panicked on the "all columns must have the same
row count" invariant whenever a view column crossed the ~2.1 GiB
threshold.
The existing `test_gc_huge_array` missed this because all of its views
are non-inline, so the miscount happened to equal `len`.
# What changes are included in this PR?
- Rewrite the slow path as a single pass over **all** views: inline
views are copied unchanged (they reference no buffer); non-inline views
are packed into the current output buffer until adding the next one
would exceed the max buffer size, at which point a new buffer is sealed.
This preserves the row count and value order, and keeps each output
buffer within the size limit.
- Extract the body into a private
`gc_with_max_buffer_size(max_buffer_size)`; `gc()` calls it with
`i32::MAX`. This lets the split path be tested with a tiny threshold
instead of allocating 2+ GiB.
- Add `test_gc_slow_path_preserves_inline_views`, which interleaves
inline, large, and null views and drives the split via a small
`max_buffer_size`, asserting the row count, per-value equality,
buffer-size cap, and that the split actually occurred.
# Are there any user-facing changes?
No. Public API is unchanged; this is a correctness fix to `gc()`.
---------
Co-authored-by: Claude Opus 4.8 (1M context) <[email protected]>
---
arrow-array/src/array/byte_view_array.rs | 189 +++++++++++++++++++++++++------
1 file changed, 157 insertions(+), 32 deletions(-)
diff --git a/arrow-array/src/array/byte_view_array.rs
b/arrow-array/src/array/byte_view_array.rs
index be6e799221..58b5ce7a05 100644
--- a/arrow-array/src/array/byte_view_array.rs
+++ b/arrow-array/src/array/byte_view_array.rs
@@ -513,6 +513,19 @@ impl<T: ByteViewType + ?Sized> GenericByteViewArray<T> {
/// Note: this function does not attempt to canonicalize / deduplicate
values. For this
/// feature see [`GenericByteViewBuilder::with_deduplicate_strings`].
pub fn gc(&self) -> Self {
+ // A single Arrow data buffer is addressed by a `u32` offset, so no
+ // output buffer may exceed `i32::MAX` bytes. Above that the data is
+ // split across multiple buffers (the "slow path").
+ self.gc_with_max_buffer_size(i32::MAX as usize)
+ }
+
+ /// Like [`Self::gc`], but with a configurable maximum output buffer size.
+ ///
+ /// `gc` always uses `i32::MAX` (the largest a single Arrow buffer can be).
+ /// This method exists so the multi-buffer split path — which `gc` only
+ /// reaches once a column references more than 2 GiB of non-inline data —
+ /// can be exercised in tests with a small threshold and tiny inputs.
+ fn gc_with_max_buffer_size(&self, max_buffer_size: usize) -> Self {
// 1) Read basic properties once
let len = self.len(); // number of elements
let nulls = self.nulls().cloned(); // reuse & clone existing null
bitmap
@@ -543,7 +556,7 @@ impl<T: ByteViewType + ?Sized> GenericByteViewArray<T> {
};
}
- let (views_buf, data_blocks) = if total_large < i32::MAX as usize {
+ let (views_buf, data_blocks) = if total_large <= max_buffer_size {
// fast path, the entire data fits in a single buffer
// 3) Allocate exactly capacity for all non-inline data
let mut data_buf = Vec::with_capacity(total_large);
@@ -556,66 +569,78 @@ impl<T: ByteViewType + ?Sized> GenericByteViewArray<T> {
let data_blocks = vec![data_block];
(views_buf, data_blocks)
} else {
- // slow path, need to split into multiple buffers
+ // slow path: the non-inline data does not fit in a single buffer
+ // (a buffer offset is a `u32`, so no buffer may exceed
`i32::MAX`),
+ // so it must be split across several buffers.
+ // A contiguous run of views destined for one output buffer.
struct GcCopyGroup {
total_buffer_bytes: usize,
total_len: usize,
}
- impl GcCopyGroup {
- fn new(total_buffer_bytes: u32, total_len: usize) -> Self {
- Self {
- total_buffer_bytes: total_buffer_bytes as usize,
- total_len,
- }
- }
- }
-
- let mut groups = Vec::new();
- let mut current_length = 0;
+ // First pass: partition the views into contiguous groups, each of
+ // which fits within one output buffer. `total_len` counts *all*
+ // views in the group, not just the non-inline ones — this is
+ // essential for correctness, as gc must preserve the row count.
+ // Inline views reference no buffer and are copied unchanged, but
+ // they still belong to a group and must be accounted for.
Recording
+ // each group's exact byte size lets the second pass size every
+ // buffer precisely, with no over-reservation or trailing waste.
+ let mut groups: Vec<GcCopyGroup> = Vec::new();
+ // Bytes accumulated for the current group. A view length is a
`u32`
+ // and a single value may be larger than `max_buffer_size`, so the
+ // running sum is kept in `u64` to stay within the addressable
+ // buffer-offset space without wrapping.
+ let mut current_length: u64 = 0;
let mut current_elements = 0;
for view in self.views() {
- let len = *view as u32;
- if len > MAX_INLINE_VIEW_LEN {
- if current_length + len > i32::MAX as u32 {
- // Start a new group
- groups.push(GcCopyGroup::new(current_length,
current_elements));
+ let view_len = *view as u32;
+ if view_len > MAX_INLINE_VIEW_LEN {
+ // Seal the current group before adding this view would
push
+ // its buffer past `max_buffer_size`.
+ if current_length + view_len as u64 > max_buffer_size as
u64 {
+ groups.push(GcCopyGroup {
+ total_buffer_bytes: current_length as usize,
+ total_len: current_elements,
+ });
current_length = 0;
current_elements = 0;
}
- current_length += len;
- current_elements += 1;
+ current_length += view_len as u64;
}
+ current_elements += 1;
}
if current_elements != 0 {
- groups.push(GcCopyGroup::new(current_length,
current_elements));
+ groups.push(GcCopyGroup {
+ total_buffer_bytes: current_length as usize,
+ total_len: current_elements,
+ });
}
debug_assert!(groups.len() <= i32::MAX as usize);
- // 3) Copy the buffers group by group
+ // Second pass: copy each group into an exactly-sized buffer.
let mut views_buf = Vec::with_capacity(len);
let mut data_blocks = Vec::with_capacity(groups.len());
-
let mut current_view_idx = 0;
- for (group_idx, gc_copy_group) in groups.iter().enumerate() {
- let mut data_buf =
Vec::with_capacity(gc_copy_group.total_buffer_bytes);
+ for (group_idx, group) in groups.iter().enumerate() {
+ let mut data_buf =
Vec::with_capacity(group.total_buffer_bytes);
- // Directly push views to avoid intermediate Vec allocation
- let new_views = (current_view_idx..current_view_idx +
gc_copy_group.total_len).map(
- |view_idx| {
- // safety: the view index came from iterating over
valid range
+ // Directly push views to avoid an intermediate Vec allocation.
+ let new_views =
+ (current_view_idx..current_view_idx +
group.total_len).map(|view_idx| {
+ // SAFETY: `view_idx` came from iterating a valid range
+ // within `len`, and every view refers to valid data.
unsafe {
self.copy_view_to_buffer(view_idx, group_idx as
i32, &mut data_buf)
}
- },
- );
+ });
views_buf.extend(new_views);
data_blocks.push(Buffer::from_vec(data_buf));
- current_view_idx += gc_copy_group.total_len;
+ current_view_idx += group.total_len;
}
(views_buf, data_blocks)
};
@@ -1560,6 +1585,106 @@ mod tests {
});
}
+ #[test]
+ fn test_gc_slow_path_preserves_inline_views() {
+ // Regression test for a bug in the multi-buffer "slow path" of `gc()`,
+ // which `gc` only reaches once a view column references more than
+ // `i32::MAX` (~2.1 GiB) of non-inline data. That path grouped and
copied
+ // only the *non-inline* views and dropped every inline (short) view,
+ // returning an array shorter than its input. GC is a pure size
+ // optimisation and must never change the row count.
+ //
+ // Rather than allocate gigabytes to hit the real threshold, drive the
+ // same code via `gc_with_max_buffer_size` with a tiny cap so a handful
+ // of bytes forces the buffer split. Inline, large, and null views are
+ // interspersed so the split lands between and around inline entries.
+ let long = "this is definitely longer than twelve bytes"; // non-inline
+ let test_data = [
+ Some("short"), // inline
+ Some(long), // large
+ Some("s"), // inline
+ Some(long), // large
+ None, // null
+ Some(long), // large
+ Some("also short"), // inline
+ Some(long), // large
+ Some("tail"), // inline
+ ];
+ let array: StringViewArray = test_data.into_iter().collect();
+
+ // Cap each output buffer at ~1.5 large values so the copy is forced to
+ // split across several buffers, exercising the slow path with no
+ // multi-GiB allocation.
+ let max_buffer_size = long.len() + long.len() / 2;
+ let gced = array.gc_with_max_buffer_size(max_buffer_size);
+
+ // The core invariant: gc preserves the row count.
+ assert_eq!(gced.len(), array.len(), "gc changed the row count");
+ // The split must actually have happened, otherwise we'd be exercising
+ // the fast path and could not catch the inline-dropping regression.
+ assert!(
+ gced.data_buffers().len() > 1,
+ "expected output split across multiple buffers, got {}",
+ gced.data_buffers().len()
+ );
+ // No output buffer may exceed the cap.
+ for buf in gced.data_buffers() {
+ assert!(buf.len() <= max_buffer_size, "buffer exceeded max size");
+ }
+ // Every value (inline, large, and null) is unchanged and in order.
+ array
+ .iter()
+ .zip(gced.iter())
+ .enumerate()
+ .for_each(|(i, (a, b))| {
+ assert_eq!(a, b, "value mismatch at index {i} after gc");
+ });
+ // The result round-trips through full validation.
+ gced.to_data().validate_full().unwrap();
+ }
+
+ #[test]
+ fn test_gc_slow_path_value_larger_than_buffer() {
+ // A single non-inline value can be larger than one output buffer (a
view
+ // length is a `u32`, so a value may exceed `max_buffer_size`). Such a
+ // value cannot be split, so it occupies a buffer of its own; gc must
+ // still preserve every row and produce a valid array.
+ //
+ // The real threshold needs a multi-GiB value, so drive the same logic
+ // with a `max_buffer_size` smaller than the value length.
+ let big = "x".repeat(64); // non-inline, larger than the cap below
+ let test_data = [
+ Some("short"), // inline
+ Some(big.as_str()), // larger than max_buffer_size
+ None, // null
+ Some("tail"), // inline
+ Some(big.as_str()), // larger than max_buffer_size
+ ];
+ let array: StringViewArray = test_data.into_iter().collect();
+
+ let max_buffer_size = 16; // smaller than a single `big` value
+ let gced = array.gc_with_max_buffer_size(max_buffer_size);
+
+ // Row count is preserved.
+ assert_eq!(gced.len(), array.len(), "gc changed the row count");
+ // Each oversized value lands in its own buffer, so the copy splits.
+ assert!(
+ gced.data_buffers().len() > 1,
+ "expected output split across multiple buffers, got {}",
+ gced.data_buffers().len()
+ );
+ // Every value is unchanged and in order.
+ array
+ .iter()
+ .zip(gced.iter())
+ .enumerate()
+ .for_each(|(i, (a, b))| {
+ assert_eq!(a, b, "value mismatch at index {i} after gc");
+ });
+ // The result round-trips through full validation.
+ gced.to_data().validate_full().unwrap();
+ }
+
#[test]
fn test_eq() {
let test_data = [