Skip to content

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

Open
adriangb wants to merge 1 commit 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 1 commit 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 on lines +581 to +582
// started. Cap the per-buffer reservation at `max_buffer_size` so a
// single huge value doesn't request an absurd allocation up front.

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.

i dont understand this point:

Cap the per-buffer reservation at max_buffer_size so a single huge value doesn't request an absurd allocation up front.

a single huge value cannot exceed max_buffer_size (which is i32::MAX) anyway, because the length is bounded by that as well 🤔

{
data_blocks.push(Buffer::from_vec(std::mem::take(&mut data_buf)));
current_buffer_idx += 1;
data_buf.reserve(buffer_cap);

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.

by always reserving buffer_cap does that mean the last buffer will always have more memory than strictly necessary?

let mut views_buf = Vec::with_capacity(len);
let mut data_blocks = Vec::new();
let mut data_buf: Vec<u8> = Vec::with_capacity(buffer_cap);
let mut current_buffer_idx: i32 = 0;

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 could refactor current_buffer_idx away by just using data_blocks.len()

// 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?

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.

2 participants