Skip to content

Fix GC rooting of decompressed buffers in Arrow.View and reinterp(UIn… - #616

Open
bdklahn wants to merge 1 commit into
apache:mainfrom
bdklahn:fix-compressed-view-gc
Open

bdklahn wants to merge 1 commit into
apache:mainfrom
bdklahn:fix-compressed-view-gc

Conversation

@bdklahn

@bdklahn bdklahn commented Nov 6, 2026

Copy link
Copy Markdown
Contributor

…t8, ...)

When reading compressed Arrow RecordBatches containing Utf8View or BinaryView columns:

  1. build(f::Meta.Field, L::ViewTypes, ...) discarded the decompressed bytes vector returned by reinterp(ViewElement, ...) and stored batch.bytes in View.arrow, leaving the unsafe_wrap-backed View.data array unrooted for GC.
  2. reinterp(UInt8, ...) wrapped the newly allocated decompressed Vector{UInt8} in a second non-owning unsafe_wrap Array header, leaving variadic buffers in View.buffers unrooted when build discarded the first return value.

Return bytes directly from reinterp when compression !== nothing && T === UInt8, and store the bytes vector from reinterp(ViewElement, ...) in View.arrow.

Copilot AI balanced review requested due to automatic review settings November 6, 2026 03:30

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 Changes recommended

The regression test fails before exercising the GC fix because the codec is uninitialized, and coverage still lacks deterministic ownership checks and a compressed view-buffer lifetime test.

3 open issues
What changed in this PR

This PR fixes garbage-collection ownership of decompressed buffers when Arrow reads compressed Utf8View and BinaryView columns.

Changes:

  • Return the owning decompressed vector directly from reinterp(UInt8, ...).
  • Retain the view-descriptor buffer in View.arrow.
  • Add a regression test for decompressed byte-buffer lifetime.
File Description
test/​runtests.jl Adds a compressed-buffer regression test with forced garbage collection.
src/​table.jl Preserves ownership of decompressed view descriptors and byte buffers.

Review effort: Balanced

Comment thread test/runtests.jl Outdated
raw = collect(codeunits("Luxembourg_120203202_18815"))
compressed = vcat(
reinterpret(UInt8, [Int64(length(raw))]),
transcode(Arrow.CodecZstd.ZstdCompressor(), raw),
Comment thread src/table.jl
meta = buildmetadata(f.custom_metadata)
T = juliaeltype(f, meta, convert)
return View{T}(batch.bytes, validity, views, inline, buffers, len, meta),
return View{T}(bytes, validity, views, inline, buffers, len, meta),
Comment thread test/runtests.jl Outdated
batch = (; bytes=compressed, pos=1)
buf = (; offset=0, length=length(compressed))
comp = (; codec=Arrow.Meta.CompressionType.ZSTD)
_, A = Arrow.reinterp(UInt8, batch, buf, comp)
@codecov-commenter

codecov-commenter commented Nov 10, 2026 •

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 88.15%. Comparing base (3712291) to head (506b3e2).
⚠️ Report is 58 commits behind head on main.

Additional details and impacted files
@@            Coverage Diff             @@
##             main     #616      +/-   ##
==========================================
+ Coverage   87.43%   88.15%   +0.71%     
==========================================
  Files          26       28       +2     
  Lines        3288     3487     +199     
==========================================
+ Hits         2875     3074     +199     
  Misses        413      413              

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@quinnj quinnj left a comment

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.

[review generated via AI]

Reviewed at e361e72. The src/table.jl change is correct and fixes a real use-after-free. I'm requesting changes only because CI is red on this head, for two reasons in the new test. Both are small.

Reproduced on main (71df78e), fixed here. Julia 1.12.6 and 1.10, --check-bounds=yes. I read ZSTD and LZ4 compressed files with one string_view column (2000 rows, pyarrow 25.0.1 files and Polars 1.43.2 streams), then ran GC.gc(), allocated garbage, and compared every value, 20 rounds:

  • main: all six runs fail (four files on 1.12, two on 1.10). Examples: BoundsError: attempt to access 2-element Vector{Vector{UInt8}} at index [500432081], and for the Polars ZSTD stream thousands of wrong values with no error at all.
  • This branch: 0 mismatches in the same six runs. With #615 merged in locally, the three-view-column files also read correctly under the same GC pressure.

I also checked every other reinterp caller (dictionary indices, list offsets and data, fixed-size lists, unions, primitives, bools, validity bitmaps). Each one already stores the bytes owner it gets back, so the view path was the only one with this fault. Dictionary batches and Arrow.Stream go through the same build, so they are covered by the fix.

Blocking

  1. @test pointer(col.arrow) == Ptr{UInt8}(pointer(col.data)) (test/runtests.jl:1151) fails on Julia 1.9 and 1.10. That is the eight red lts and min jobs, and I get the same locally on 1.10:

    Expression: pointer(col.arrow) == Ptr{UInt8}(pointer(col.data))
     Evaluated: Ptr{UInt8} @0x00000070553eaaf8 == Ptr{UInt8} @0x00000070553eab60
    

    On those versions the 32-byte decompressed vector is 8-byte aligned but not 16-byte aligned, so reinterp(ViewElement, ...) takes its realign branch and col.data is a fresh copy that owns its memory. The fix is still right there. Only the assertion is wrong. With that one line removed the testset passes on 1.10 (4 of 4) and still fails on main through bytes === A and col.arrow !== batch.bytes.

  2. The Format job fails on test/runtests.jl. julia -e 'using JuliaFormatter; format("test/runtests.jl")' from the repo root fixes it. It rewraps the first Arrow.ViewElement(...) call and the first(Arrow.BatchIterator(...)) expression.

Suggestions, not blocking

  • The test builds a fake batch and rb from NamedTuples and asserts object identity, so it is tied to internals. A small compressed fixture read through Arrow.Table would test what users see. pyarrow writes one with ipc.new_file(path, schema, options=ipc.IpcWriteOptions(compression="zstd")). The check I used above (read, GC.gc(), allocate, compare) was red on main in every run.
  • This PR and #615 both edit the #511 testset, so they conflict with each other in test/runtests.jl. Whichever lands second needs a rebase.

…t8, ...)

When reading compressed Arrow RecordBatches containing Utf8View or BinaryView
columns:
1. build(f::Meta.Field, L::ViewTypes, ...) discarded the decompressed bytes
   vector returned by reinterp(ViewElement, ...) and stored batch.bytes in
   View.arrow, leaving the unsafe_wrap-backed View.data array unrooted for GC.
2. reinterp(UInt8, ...) wrapped the newly allocated decompressed Vector{UInt8}
   in a second non-owning unsafe_wrap Array header, leaving variadic buffers in
   View.buffers unrooted when build discarded the first return value.

Return bytes directly from reinterp when compression !== nothing && T === UInt8,
and store the bytes vector from reinterp(ViewElement, ...) in View.arrow.
@bdklahn
bdklahn force-pushed the fix-compressed-view-gc branch from e361e72 to 506b3e2 Compare November 11, 2026 13:36

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants