Repository navigation
Conversation
There was a problem hiding this comment.
🟡 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
| raw = collect(codeunits("Luxembourg_120203202_18815")) | ||
| compressed = vcat( | ||
| reinterpret(UInt8, [Int64(length(raw))]), | ||
| transcode(Arrow.CodecZstd.ZstdCompressor(), raw), |
| 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), |
| 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 Report✅ All modified and coverable lines are covered by tests. 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. 🚀 New features to boost your workflow:
|
quinnj
left a comment
There was a problem hiding this comment.
[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
-
@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 redltsandminjobs, and I get the same locally on 1.10:Expression: pointer(col.arrow) == Ptr{UInt8}(pointer(col.data)) Evaluated: Ptr{UInt8} @0x00000070553eaaf8 == Ptr{UInt8} @0x00000070553eab60On those versions the 32-byte decompressed vector is 8-byte aligned but not 16-byte aligned, so
reinterp(ViewElement, ...)takes its realign branch andcol.datais 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 onmainthroughbytes === Aandcol.arrow !== batch.bytes. -
The
Formatjob fails ontest/runtests.jl.julia -e 'using JuliaFormatter; format("test/runtests.jl")'from the repo root fixes it. It rewraps the firstArrow.ViewElement(...)call and thefirst(Arrow.BatchIterator(...))expression.
Suggestions, not blocking
- The test builds a fake
batchandrbfrom NamedTuples and asserts object identity, so it is tied to internals. A small compressed fixture read throughArrow.Tablewould test what users see. pyarrow writes one withipc.new_file(path, schema, options=ipc.IpcWriteOptions(compression="zstd")). The check I used above (read,GC.gc(), allocate, compare) was red onmainin every run. - This PR and #615 both edit the
#511testset, so they conflict with each other intest/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.
e361e72 to
506b3e2
Compare


…t8, ...)
When reading compressed Arrow RecordBatches containing Utf8View or BinaryView columns:
Return bytes directly from reinterp when compression !== nothing && T === UInt8, and store the bytes vector from reinterp(ViewElement, ...) in View.arrow.