GH-50623: [C++][IPC] Fix extension-wrapped union IPC roundtrip - #50927
Conversation
|
|
|
|
||
| namespace test { | ||
|
|
||
| class UnionExtensionArray : public ExtensionArray { |
There was a problem hiding this comment.
Can we move these two classes to arrow/testing/extension_type.h? They're probably going to be useful for other tests at some point.
There was a problem hiding this comment.
Moved to arrow/testing/extension_type.h in 4c4ecb9390, with the ExtensionEquals / MakeArray / Deserialize bodies in testing/gtest_util.cc next to the other example extension types.
I kept UnionExtensionType parameterized on (storage_type, extension_name) rather than splitting it into two classes, so the dense- and sparse-backed variants can be registered at the same time. Accessors are dense_union_extension_type() and sparse_union_extension_type(), matching the dict_extension_type() / complex128() naming. Deserialize() now also validates the storage type against storage_type_, which the throwaway version did not.
| "sparse-union-extension"); | ||
| } | ||
|
|
||
| Status MakeDenseUnionExtension(std::shared_ptr<RecordBatch>* out) { |
There was a problem hiding this comment.
Perhaps move these two functions to arrow/ipc/test_common.h?
There was a problem hiding this comment.
Moved to arrow/ipc/test_common.{h,cc} as MakeDenseUnionExtension / MakeSparseUnionExtension, sitting right after MakeDictExtension. Both delegate to a file-local MakeUnionExtension(type, out).
While moving them I also made them match the shape of the neighbouring makers — two fields (f0 nullable, f1 non-nullable) instead of one, and the nullable one now carries a null ([[0, 1.5], [1, null]]), which the originals did not exercise.
|
|
||
| TEST_P(TestStreamFormat, RoundTrip) { TestRoundTripWithOptions(*GetParam()); } | ||
|
|
||
| TEST_F(TestFileFormat, DenseUnionExtensionRoundTrip) { |
There was a problem hiding this comment.
Why not extend kBatchCases instead of adding dedicated test functions?
There was a problem hiding this comment.
Done — folded into kBatchCases and the four TEST_F blocks are gone.
Worth noting this is a strict improvement in coverage, not just tidier: the two cases now run through 22 test instances instead of 4, and that picks up TestIpcRoundTrip.SliceRoundTrip and TestIpcRoundTrip.ZeroLengthArrays, plus the four StreamDecoder* variants and TestFileFormatGenerator{,Coalesced} — none of which the dedicated tests touched.
It also made the regression sharper. Reverting writer.cc alone and rerunning, the new cases fail in TestFileFormat, TestIpcRoundTrip.RoundTrip, TestIpcRoundTrip.ZeroLengthArrays, and then TestFileFormatGenerator aborts outright on an out-of-bounds ArrayData child access.
| // In V5 and later, null and union types have no validity bitmap | ||
| if (internal::HasValidityBitmap(arr.type_id(), options_.metadata_version)) { | ||
| if (internal::HasValidityBitmap(physical_arr->type_id(), options_.metadata_version)) { | ||
| if (arr.null_count() > 0) { |
There was a problem hiding this comment.
This is still using arr while switching to physical_arr in other places (e.g. arr.length() above vs. physical_arr->length() below), with no obvious rationale. Can we make the code more consistent?
There was a problem hiding this comment.
Fair — that inconsistency was not deliberate, it was left over from patching the call sites one at a time.
Rewritten to unwrap once into a reference and then use it everywhere:
const Array& physical_arr = arr.type_id() == Type::EXTENSION
? *checked_cast<const ExtensionArray&>(arr).storage()
: arr;arr is not referenced again after that line. This is safe because ExtensionArray::SetData() builds the storage from data->Copy() with only the type swapped, so length, offset, null_count and the buffers are shared — the only thing that differs is the type id, which is exactly what the layout decisions below need. Added a comment saying so.
…nd arrow/ipc/test_common - UnionExtensionArray / UnionExtensionType now live in arrow/testing/extension_type.h (implementations in testing/gtest_util.cc, alongside the other example extension types), with dense_union_extension_type() / sparse_union_extension_type() accessors. - MakeDenseUnionExtension / MakeSparseUnionExtension moved to arrow/ipc/test_common, next to MakeUuid / MakeComplex128 / MakeDictExtension. - Added both to kBatchCases instead of keeping four dedicated TEST_F cases. - writer.cc: unwrap once into a physical_arr reference and use it consistently.
|
Thanks @pitrou — all four addressed in `4c4ecb9390`, replies inline. Summary:
Net effect on `read_write_test.cc` is -87/+20: the file now only registers the two types in `ExtensionTypesMixin` and lists the two makers in `kBatchCases`. Verification — `arrow-ipc-read-write-test`: 431/432, the one failure being `TestSchemaMetadata.MetadataVersionForwardCompatibility`, which is my checkout missing the `testing/` submodule (`Test resources not found, set ARROW_TEST_DATA`), not related to this change. `arrow-extension-type-test` 6/6 and `arrow-feather-test` 83/83 also pass, since both link `arrow_testing`. Non-vacuousness: reverting `writer.cc` and keeping the tests reproduces the bug in `TestFileFormat.RoundTrip/{27,28}`, `TestIpcRoundTrip.RoundTrip/{27,28}`, `TestIpcRoundTrip.ZeroLengthArrays/{27,28}`, and aborts `TestFileFormatGenerator` on On the red CI: `C++ Format` was mine — `read_write_test.cc` was not clang-format-18.1.8 clean at the previous head. That code is deleted now, and all six touched files pass `clang-format --dry-run -Werror`. The other three failures look unrelated to me — `Python (Cython) Lint` dies on `AttributeError: 'DictComprehensionAppendNode' object has no attribute 'value_expr'`, and `AMD64 Conda C++ AVX2` fails only `arrow-filesystem-test` with `Attempt to initialize S3 after it has been finalized`. Happy to look if you think either is on me. For transparency, and since it came up on another PR I have open: this work is AI-assisted. The evidence above is all locally reproducible and I would rather you have that context up front. |
|
|
|
FTR @paleolimbot |
Rationale for this change
IPC schema serialization already treats extension arrays as their storage type. Record batch serialization still handled extension arrays according to the logical
extensiontype when deciding whether to emit a validity buffer. That mismatches storage types whose IPC layout differs from the default, including unions.For extension-wrapped dense and sparse unions, the writer emitted a spurious top-level validity-buffer slot. The resulting IPC payload dropped the union buffers from the positions the reader expects, producing invalid data on read.
What changes are included in this PR?
RecordBatchSerializer::VisitArray()to use the physical storage array when deciding common IPC buffer handling for extension arraysThis follows the same storage-type rule already used by IPC schema serialization.
AI-assisted contribution note: this patch was prepared with AI assistance under Arrow's AI-generated code guidance. I reproduced the bug locally, validated the fix, and reviewed the final diff myself.
Are these changes tested?
Yes.
Locally verified with:
arrow-ipc-read-write-test --gtest_filter='TestFileFormat.DenseUnionExtensionRoundTrip:TestFileFormat.SparseUnionExtensionRoundTrip:TestStreamFormat.DenseUnionExtensionRoundTrip:TestStreamFormat.SparseUnionExtensionRoundTrip'arrow-ipc-read-write-test --gtest_filter='-TestSchemaMetadata.MetadataVersionForwardCompatibility'arrow-extension-type-testThe excluded
TestSchemaMetadata.MetadataVersionForwardCompatibilityrequires external test data and is unrelated to this patch.Are there any user-facing changes?
No API changes.
This PR contains a "Critical Fix" in Arrow's template sense: it fixes IPC output that could otherwise contain invalid union payloads when an extension type wraps union storage, which can fail validation and crash on readback.