Skip to content

fix: GenericByteViewArray::gc() drops inline views on the multi-buffer slow path#10287

Open
adriangb wants to merge 4 commits into
apache:mainfrom
pydantic:fix-byte-view-gc-drops-inline-views
Open

fix: GenericByteViewArray::gc() drops inline views on the multi-buffer slow path#10287
adriangb wants to merge 4 commits into
apache:mainfrom
pydantic:fix-byte-view-gc-drops-inline-views

Conversation

@adriangb

@adriangb adriangb commented Jul 5, 2026

Copy link
Copy Markdown
Contributor

Which issue does this PR close?

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().

`gc()` takes a multi-buffer "slow path" once a `Utf8View`/`BinaryView`
column references more than `i32::MAX` (~2.1 GiB) of non-inline data (a
single Arrow buffer is addressed by a `u32` offset, so the output must be
split across buffers).

That path built its copy groups by counting only *non-inline* views and
then copied a contiguous index range of that size, so every inline (short)
view was dropped and the row count shrank. The index-range / byte-budget
mismatch could also push a single output buffer past `i32::MAX`. `gc()` is
a pure size optimisation and must never change the row count, so callers
that rebuild a `RecordBatch` from the result tripped the row-count
invariant and panicked.

Replace the grouping logic with a single pass over *all* views: inline
views are copied unchanged, non-inline views are packed into the current
buffer until the next would exceed the max buffer size, then a new buffer
is sealed. Extract the body into a private `gc_with_max_buffer_size` so the
split path can be tested with a tiny threshold instead of allocating 2+ GiB.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RQ5ToRQyLENghZVTbA36xt
@github-actions github-actions Bot added the arrow Changes to the arrow crate label Jul 5, 2026
@adriangb
adriangb marked this pull request as ready for review July 5, 2026 15:24
@adriangb

adriangb commented Jul 5, 2026

Copy link
Copy Markdown
Contributor Author

@XiangpengHao or @rluvaton would one of you mind taking a look at this fix?

Comment thread arrow-array/src/array/byte_view_array.rs Outdated
Comment thread arrow-array/src/array/byte_view_array.rs Outdated
Comment thread arrow-array/src/array/byte_view_array.rs Outdated
// progress even for a single value larger than `max_buffer_size`.
if view_len > MAX_INLINE_VIEW_LEN
&& !data_buf.is_empty()
&& data_buf.len() as u64 + view_len as u64 > max_buffer_size as u64

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why do we operate on u64's here instead of i32/u32?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Left a comment in the code, copying here for visibility: data_buf.len() itself can exceed max_buffer_size, so the sum is computed in u64 to avoid overflowing the u32 offset space on the addition.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

data_buf.len() cannot exceed max_buffer_len here, i think you mean data_buf.len() + view_len can exceed it?

- Remove the redundant current_buffer_idx; use data_blocks.len() as the
  index of the buffer currently being filled (per review).
- Size each buffer reservation to the data still to be copied
  (remaining_large.min(max_buffer_size)) instead of always reserving a
  full max_buffer_size, so the final buffer is no longer padded out to a
  full reservation when little data is left.
- Clarify the comments: the reservation cap is about total_large (the
  multi-GiB sum that forced the slow path), not a single value; and note
  why the buffer-overflow check is done in u64 (a single value larger
  than max_buffer_size can push data_buf.len() past max_buffer_size).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Xx1J3hcCvDUxCMnizMn8cz
@adriangb

adriangb commented Jul 8, 2026

Copy link
Copy Markdown
Contributor Author

@Jefffrey thank you for the review. I reviewed the changes Claude made to address it and they make sense to me, let me know if you agree.

total_len,
}
// 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`),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

technically i think buffers can exceed i32::MAX safely; if we fill i32::MAX - 1 of the buffer, then the last string is also a massive string (e.g. i32::MAX) then its offset is still within i32 range 🤔

that is, things are fine so long as the starting offset is within i32 range, even if its length causes the buffer to exceed i32::MAX in length

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

though i do wonder if having such strings could cause problems downstream in other functions anyway 🤔

let mut remaining_large = total_large;
let mut views_buf = Vec::with_capacity(len);
let mut data_blocks: Vec<Buffer> = Vec::new();
let mut data_buf: Vec<u8> = Vec::with_capacity(remaining_large.min(max_buffer_size));

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

the way of reserving this much capacity up front can lead to fragmentation with unused capacity at the end; i guess this is why the initial approach calculated groups to get exact sizes 🤔

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, visiting one pass is lightweight, so I uses group here

// next one would overflow the current buffer's offset space,
// seal it and start a new one. The `!data_buf.is_empty()` guard
// avoids emitting a leading empty buffer and guarantees forward
// progress even for a single value larger than `max_buffer_size`

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

can we clean up these comments; it should not be possible for a single value to be larger than max_buffer_size (unless we're doing testing where max_buffer_size < i32::MAX)

// progress even for a single value larger than `max_buffer_size`.
if view_len > MAX_INLINE_VIEW_LEN
&& !data_buf.is_empty()
&& data_buf.len() as u64 + view_len as u64 > max_buffer_size as u64

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

data_buf.len() cannot exceed max_buffer_len here, i think you mean data_buf.len() + view_len can exceed it?

let new_view = unsafe { self.copy_view_to_buffer(i, buffer_idx, &mut data_buf) };
views_buf.push(new_view);
if is_large {
remaining_large -= view_len as usize;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we can probably push this remaining_large adjustment inside the same if block above where we allocate a new data_buf; this way we just subtract based on previous data_buf instead of on each iteration

current_elements = 0;
}
current_length += len;
current_elements += 1;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So extract current_elements += 1; fix the bug that the small buffer being eliminate?

@mapleFU

mapleFU commented Jul 13, 2026

Copy link
Copy Markdown
Member

I wonder whether this is ok, since the wrong problem is current_elements is in the if condition.

diff --git a/arrow-array/src/array/byte_view_array.rs b/arrow-array/src/array/byte_view_array.rs
index be6e799221..cf137b4faf 100644
--- a/arrow-array/src/array/byte_view_array.rs
+++ b/arrow-array/src/array/byte_view_array.rs
@@ -469,6 +469,13 @@ impl<T: ByteViewType + ?Sized> GenericByteViewArray<T> {
         }
     }
 
+    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)
+    }
+
     /// Returns a "compacted" version of this array
     ///
     /// The original array will *not* be modified
@@ -512,7 +519,7 @@ 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 {
+    pub 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 +550,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 as usize {
             // 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);
@@ -579,20 +586,20 @@ impl<T: ByteViewType + ?Sized> GenericByteViewArray<T> {
             for view in self.views() {
                 let len = *view as u32;
                 if len > MAX_INLINE_VIEW_LEN {
-                    if current_length + len > i32::MAX as u32 {
+                    if current_length + len > max_buffer_size as u32 {
                         // Start a new group
                         groups.push(GcCopyGroup::new(current_length, current_elements));
                         current_length = 0;
                         current_elements = 0;
                     }
                     current_length += len;
-                    current_elements += 1;
                 }
+                current_elements += 1;
             }
             if current_elements != 0 {
                 groups.push(GcCopyGroup::new(current_length, current_elements));
             }
-            debug_assert!(groups.len() <= i32::MAX as usize);
+            debug_assert!(groups.len() <= max_buffer_size);
 
             // 3) Copy the buffers group by group
             let mut views_buf = Vec::with_capacity(len);

adriangb and others added 2 commits July 13, 2026 09:12
Address mapleFU's review: instead of a single reserve-as-you-go pass,
partition views into contiguous groups (first pass) and copy each into
an exactly-sized buffer (second pass). This restores precise buffer
sizing with no over-reservation or trailing waste, while keeping the
actual bug fix — counting *all* views per group, not just non-inline
ones, so inline views are preserved and the row count is unchanged.

Keeps the `gc_with_max_buffer_size` extraction and the
`test_gc_slow_path_preserves_inline_views` regression test.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BPEM1WJDpZKTGsoHDkJwLD
A byte-view length is a u32 and a single value may exceed max_buffer_size,
so the per-group running sum is computed in u64 to stay within the
addressable buffer-offset space without wrapping. An oversized value that
cannot be split occupies a buffer of its own.

Add test_gc_slow_path_value_larger_than_buffer covering that path: row
count preserved, values unchanged, and validate_full passes.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BPEM1WJDpZKTGsoHDkJwLD
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

arrow Changes to the arrow crate

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants