Repository navigation
fix: Export a zero offset for empty arrays with an empty offsets buffer - #966
Conversation
IPC permits a zero-length offsets buffer for an empty variable-size array, and nanoarrow's own encoder writes one. ArrowArrayFinishBuilding() left such a buffer as-is (or, if NULL, reserved one uninitialized byte), so the exported ArrowArray pointed at no valid offsets -- into a neighbouring part of the message body when decoding with shared buffers. The C data interface carries no buffer sizes, so consumers such as Arrow C++ read offsets[0] unconditionally and saw garbage. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## main #966 +/- ##
==========================================
+ Coverage 78.24% 78.28% +0.04%
==========================================
Files 106 106
Lines 16901 16906 +5
Branches 2012 2012
==========================================
+ Hits 13224 13235 +11
+ Misses 2435 2429 -6
Partials 1242 1242 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
paleolimbot
left a comment
There was a problem hiding this comment.
I think we should test this differently, but the change itself is great...thank you!
It may be a bit of a gotcha that reducing the verification level may produce arrays that will crash Arrow C++. It came up a while back that we should possibly avoid reading any buffers of arrays with length 0 and I thought Arrow C++ fixed it (I believe we fixed it here); however, this is still a good change to make.
| // nanoarrow's encoder writes a zero-length offsets buffer for an empty array (as IPC | ||
| // permits). Decoded arrays must still export offsets[0] == 0 through the C data | ||
| // interface, which carries no buffer sizes, whether or not the body is shared. | ||
| struct ArrowError error; |
There was a problem hiding this comment.
Probably this is more appropriate as a test for ArrowArrayFinalizeBuffers in array_test.cc, although I get the motivating use case was IPC reads.
The IPC read case is I think better tested with a record batch bytes fixture (you can construct the schema programmatically to save space).
There was a problem hiding this comment.
Moved. ArrayTestFinishBuildingEmptyArrayOffsets in array_test.cc checks ArrowArrayFinishBuildingDefault() on empty string, large binary and list arrays, both when the offsets buffer was never allocated and when it has 0 bytes over storage filled with 0xff. The IPC case is now NanoarrowIpcDecodeEmptyArrayOffsets in decoder_test.cc. It uses a record batch bytes fixture (one row of struct<l: list<string>, i: int32>, with the schema built in code) where the empty string array's 0-byte offsets buffer sits at the same body offset as i's data, 0x55555555. It decodes with both a borrowed and a shared body. Both tests fail on main. I removed the round-trip test from writer_test.cc.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
|
Thanks! Agreed on the gotcha with lower validation levels. I ran into exactly that: decoding at |
paleolimbot
left a comment
There was a problem hiding this comment.
Thank you!
Not sure what the best solution is for minimal validation here...default validation is probably not much slower and would be what I'd recommend here (if it is a lot slower for some reason we could separate CPU access from validation level in a follow up as you noted).
An empty variable-size array (string, binary, their large variants, list, large list, map) needs one offset,
offsets[0], even though it has no elements. The C data interface doesn't carry buffer sizes, so consumers readoffsets[0]without checking. Arrow C++'sImportArray()does this, for example.IPC allows a zero-length offsets buffer for an empty array, and nanoarrow's own encoder writes one, since
ArrowArrayViewSetArray()sizes the offsets buffer of a length-0 array as 0 bytes. When such an array is decoded,ArrowArrayFinishBuilding()leaves the empty buffer as it is. If the buffer is NULL, it reserves one byte but never initializes it. Either way, the exportedArrowArrayhas an offsets pointer with no valid offset behind it. With shared buffers, that pointer points into whatever follows in the message body.I found this moving a DuckDB extension's IPC from Arrow C++ to nanoarrow. A
MAPcolumn holding a singleNULLround-trips through a worker as an empty key/value child. After nanoarrow decoded it and Arrow C++ imported it, about one run in four failedValidateFull()witharray starts at negative offset -2099303496. The value depends on whatever bytes happen to sit in the message body after the empty buffer.With this PR,
ArrowArrayFinalizeBuffers()gives aDATA_OFFSETbuffer of an empty array, if it holds no bytes, a single zero offset of the right width (4 or 8 bytes). This works for both owned and shared buffers: the shared reference is released, and the buffer is replaced with an owned one. Arrays with elements are left alone, so validation still reports a truncated offsets buffer as before (ArrayTestExplicitValidationLevelchecks this).Things worth a look in review:
ArrowArrayFinalizeBuffers()only runs atNANOARROW_VALIDATION_LEVEL_DEFAULTor higher, because it assumes CPU data access. Decoding atMINIMALorNONEstill exports the empty buffer unchanged. I've left that gate alone. Putting a host buffer behind a device array would be worse.offsets[0], as Arrow C++ does. That only covers streams nanoarrow wrote itself, though. Other writers can emit zero-length offsets buffers too, so the fix belongs on the decode side.Tests:
NanoarrowIpcWriter.RoundtripEmptyArraysHaveZeroOffsetwrites a zero-row batch withstring,large_binaryandlist<int32>columns. It reads the batch back with and without shared buffers, then checks that each exported offsets buffer has a size of 4 or 8 bytes and holds 0. The test fails onmain, where the buffers are 0 bytes. ctest passes locally (macOS arm64, Debug, 389 tests). The touched suites (array, IPC writer/reader/decoder) pass under ASan/UBSan.array_testneedsallocator_may_return_null=1there, as it does onmain.🤖 Generated with Claude Code