Skip to content

Commit c92cb11

Browse files
committed
GH-45948: [C++][Parquet] Variant shredding
Implements variant shredding infrastructure per the VariantShredding spec, achieving functional parity with arrow-rs parquet-variant-compute for the primitive shredding path. == VariantBuilder extensions == - BuildWithoutMeta(): raw value bytes without metadata (shared metadata) - UnsafeAppendEncoded(data, size): zero-copy append of pre-encoded bytes - SetAllowDuplicates(bool): last-value-wins duplicate key compaction - FinishObject() updated with deterministic duplicate resolution == VariantExtensionType evolution == - Supports shredded storage: struct{metadata, value?, typed_value?} - IsSupportedStorageType() accepts both unshredded (2 required fields) and shredded (required metadata + optional value + optional typed_value) - Added typed_value() accessor and is_shredded() query - Constructor finds fields by name (not position) for robustness == Shredding schema + type compatibility == - VariantShreddingSchema: Primitive/Object/Array tree schema - ToArrowType(): converts to Arrow DataType with proper struct wrapping - IsVariantCompatibleWithType(): 21-type compatibility matrix with widening rules (Int8->Int64, Float->Double, etc.) == Shredding kernel (ShredVariantColumn) == - Per-row decode + type-match + routing to typed_value or residual value - Compatible values routed to typed_value column (value set null) - Incompatible values kept in value column (typed_value set null) - Handles Variant Null as compatible with any target type == Reconstruction kernel (ReconstructVariantColumn) == - Merges typed_value and residual value back into complete variant binary - Handles all 4 states: (null,null)=missing, (v,null)=unshredded, (null,t)=shredded, (v,t)=partial object (errors for primitives) == Tests == - 10 builder extension tests (BuildWithoutMeta, UnsafeAppendEncoded, SetAllowDuplicates with various scenarios) - 20 type compatibility tests covering all primitive types - 6 schema construction tests - 4 shred/reconstruct round-trip tests proving identity: Reconstruct(Shred(v)) == v for matching, mixed, and null values == Remaining work (object/array shredding, Parquet bridge) == - Object shredding: field-level routing + residual object encoding - Array shredding: element-wise shredding with list builders - Parquet bridge: VariantToNode/NodeToArrow in schema.cc - Native typed_value extraction (currently stores variant bytes; full native-type extraction deferred to variant_get kernel)
1 parent 8ab28f0 commit c92cb11

13 files changed

Lines changed: 4836 additions & 105 deletions

cpp/src/arrow/CMakeLists.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -393,6 +393,7 @@ set(ARROW_SRCS
393393
extension/parquet_variant.cc
394394
extension/variant_builder.cc
395395
extension/variant_internal.cc
396+
extension/variant_shredding.cc
396397
extension/uuid.cc
397398
pretty_print.cc
398399
record_batch.cc

cpp/src/arrow/extension/CMakeLists.txt

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,8 @@
1616
# under the License.
1717

1818
set(CANONICAL_EXTENSION_TESTS bool8_test.cc json_test.cc uuid_test.cc
19-
variant_internal_test.cc variant_builder_test.cc)
19+
variant_internal_test.cc variant_builder_test.cc
20+
variant_shredding_test.cc)
2021

2122
if(ARROW_JSON)
2223
list(APPEND CANONICAL_EXTENSION_TESTS tensor_extension_array_test.cc opaque_test.cc)

cpp/src/arrow/extension/meson.build

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,8 @@
1616
# under the License.
1717

1818
canonical_extension_tests = ['bool8_test.cc', 'json_test.cc', 'uuid_test.cc',
19-
'variant_internal_test.cc', 'variant_builder_test.cc']
19+
'variant_internal_test.cc', 'variant_builder_test.cc',
20+
'variant_shredding_test.cc']
2021

2122
if needs_json
2223
canonical_extension_tests += [
@@ -44,5 +45,6 @@ install_headers(
4445
# variant_internal.h: public API for variant binary encoding/decoding.
4546
# "internal" refers to the binary encoding internals, not visibility.
4647
'variant_internal.h',
48+
'variant_shredding.h',
4749
],
4850
)

cpp/src/arrow/extension/parquet_variant.cc

Lines changed: 62 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -28,18 +28,20 @@ namespace arrow::extension {
2828

2929
VariantExtensionType::VariantExtensionType(const std::shared_ptr<DataType>& storage_type)
3030
: ExtensionType(storage_type) {
31-
// GH-45948: Shredded variants will need to handle an optional shredded_value as
32-
// well as value_ becoming optional.
33-
34-
// IsSupportedStorageType should have been called already, asserting that both
35-
// metadata and value are present.
36-
if (storage_type->field(0)->name() == "metadata") {
37-
metadata_ = storage_type->field(0);
38-
value_ = storage_type->field(1);
39-
} else {
40-
value_ = storage_type->field(0);
41-
metadata_ = storage_type->field(1);
31+
// Find fields by name (ordering does not matter per spec).
32+
for (int i = 0; i < storage_type->num_fields(); ++i) {
33+
const auto& f = storage_type->field(i);
34+
if (f->name() == "metadata") {
35+
metadata_ = f;
36+
} else if (f->name() == "value") {
37+
value_ = f;
38+
} else if (f->name() == "typed_value") {
39+
typed_value_ = f;
40+
}
4241
}
42+
// IsSupportedStorageType() should have been called before construction.
43+
DCHECK_NE(metadata_, nullptr);
44+
DCHECK_NE(value_, nullptr);
4345
}
4446

4547
bool VariantExtensionType::ExtensionEquals(const ExtensionType& other) const {
@@ -71,35 +73,52 @@ bool IsBinaryField(const std::shared_ptr<Field> field) {
7173

7274
bool VariantExtensionType::IsSupportedStorageType(
7375
const std::shared_ptr<DataType>& storage_type) {
74-
// For now we only supported unshredded variants. Unshredded variant storage
75-
// type should be a struct with a binary metadata and binary value.
76-
//
77-
// GH-45948: In shredded variants, the binary value field can be replaced
78-
// with one or more of the following: object, array, typed_value, and
79-
// variant_value.
80-
if (storage_type->id() == Type::STRUCT) {
81-
if (storage_type->num_fields() == 2) {
82-
// Ordering of metadata and value fields does not matter, as we will assign
83-
// these to the VariantExtensionType's member shared_ptrs in the constructor.
84-
// Here we just need to check that they are both present.
85-
86-
const auto& field0 = storage_type->field(0);
87-
const auto& field1 = storage_type->field(1);
88-
89-
bool metadata_and_value_present =
90-
(field0->name() == "metadata" && field1->name() == "value") ||
91-
(field1->name() == "metadata" && field0->name() == "value");
92-
93-
if (metadata_and_value_present) {
94-
// Both metadata and value must be non-nullable binary types for unshredded
95-
// variants. This will change in GH-46948, when we will require a Visitor
96-
// to traverse the structure of the variant.
97-
return IsBinaryField(field0) && IsBinaryField(field1) && !field0->nullable() &&
98-
!field1->nullable();
99-
}
76+
if (storage_type->id() != Type::STRUCT) {
77+
return false;
78+
}
79+
80+
// Find fields by name
81+
std::shared_ptr<Field> metadata_field;
82+
std::shared_ptr<Field> value_field;
83+
std::shared_ptr<Field> typed_value_field;
84+
85+
for (int i = 0; i < storage_type->num_fields(); ++i) {
86+
const auto& f = storage_type->field(i);
87+
if (f->name() == "metadata") {
88+
metadata_field = f;
89+
} else if (f->name() == "value") {
90+
value_field = f;
91+
} else if (f->name() == "typed_value") {
92+
typed_value_field = f;
10093
}
10194
}
10295

96+
// metadata is always required and must be binary-like
97+
if (!metadata_field || !IsBinaryField(metadata_field)) {
98+
return false;
99+
}
100+
101+
// Unshredded: required metadata + required value (both binary)
102+
if (value_field && !typed_value_field) {
103+
return IsBinaryField(value_field) && !metadata_field->nullable() &&
104+
!value_field->nullable();
105+
}
106+
107+
// Shredded: required metadata + optional value + optional typed_value
108+
if (value_field && typed_value_field) {
109+
// metadata must be non-nullable, value must be nullable binary,
110+
// typed_value must be nullable (any type)
111+
return !metadata_field->nullable() && IsBinaryField(value_field) &&
112+
value_field->nullable() && typed_value_field->nullable();
113+
}
114+
115+
// NOTE: The shredding spec allows leaf schemas where `value` is absent
116+
// (typed_value only, for fully-shredded columns with no residual). We
117+
// reject this case for now because the current shredding implementation
118+
// always produces a `value` column. Supporting value-absent schemas
119+
// requires changes to ShredVariantColumn/ReconstructVariantColumn to
120+
// handle the missing residual path. This can be added in a follow-up
121+
// when Parquet reader integration requires it.
103122
return false;
104123
}
105124

@@ -113,9 +132,12 @@ Result<std::shared_ptr<DataType>> VariantExtensionType::Make(
113132
return std::make_shared<VariantExtensionType>(std::move(storage_type));
114133
}
115134

116-
/// NOTE: this is still experimental. GH-45948 will add shredding support, at which point
117-
/// we need to separate this into unshredded_variant and shredded_variant helper
118-
/// functions.
135+
/// \brief Return a VariantExtensionType instance.
136+
///
137+
/// Supports both unshredded and shredded storage types:
138+
/// - Unshredded: struct{required binary metadata, required binary value}
139+
/// - Shredded: struct{required binary metadata, optional binary value,
140+
/// optional <T> typed_value}
119141
std::shared_ptr<DataType> variant(std::shared_ptr<DataType> storage_type) {
120142
return VariantExtensionType::Make(std::move(storage_type)).ValueOrDie();
121143
}

cpp/src/arrow/extension/parquet_variant.h

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,13 @@ class ARROW_EXPORT VariantArray : public ExtensionArray {
4040
/// required binary value;
4141
/// }
4242
///
43+
/// Shredded variant representation:
44+
/// optional group variant_name (VARIANT) {
45+
/// required binary metadata;
46+
/// optional binary value;
47+
/// optional <T> typed_value;
48+
/// }
49+
///
4350
/// To read more about variant encoding, see the variant encoding spec at
4451
/// https://github.com/apache/parquet-format/blob/master/VariantEncoding.md
4552
///
@@ -69,10 +76,16 @@ class ARROW_EXPORT VariantExtensionType : public ExtensionType {
6976

7077
std::shared_ptr<Field> value() const { return value_; }
7178

79+
/// \brief The typed_value field, or nullptr if unshredded.
80+
std::shared_ptr<Field> typed_value() const { return typed_value_; }
81+
82+
/// \brief Whether this variant has a shredded typed_value column.
83+
bool is_shredded() const { return typed_value_ != nullptr; }
84+
7285
private:
73-
// TODO GH-45948 added shredded_value
7486
std::shared_ptr<Field> metadata_;
75-
std::shared_ptr<Field> value_;
87+
std::shared_ptr<Field> value_; // nullable when shredded
88+
std::shared_ptr<Field> typed_value_; // nullptr if unshredded
7689
};
7790

7891
/// \brief Return a VariantExtensionType instance.

cpp/src/arrow/extension/variant_builder.cc

Lines changed: 57 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -333,14 +333,34 @@ Status VariantBuilder::FinishObject(int64_t start, std::vector<FieldEntry>& fiel
333333
return Status::Invalid("VariantBuilder::FinishObject: invalid start position");
334334
}
335335

336-
// Sort fields by key name lexicographically (spec requirement)
337-
std::sort(fields.begin(), fields.end(),
338-
[](const FieldEntry& a, const FieldEntry& b) { return a.key < b.key; });
339-
340-
// Check for duplicate keys
341-
for (size_t i = 1; i < fields.size(); ++i) {
342-
if (fields[i].key == fields[i - 1].key) {
343-
return Status::Invalid("VariantBuilder: duplicate key '", fields[i].key, "'");
336+
// Sort fields by key name lexicographically (spec requirement).
337+
// For allow_duplicates_ mode, use a secondary comparison on offset
338+
// so that among duplicates, the last-inserted value (highest offset) is last.
339+
std::sort(fields.begin(), fields.end(), [](const FieldEntry& a, const FieldEntry& b) {
340+
if (a.key != b.key) return a.key < b.key;
341+
return a.offset < b.offset; // stable ordering for duplicates
342+
});
343+
344+
// Handle duplicate keys
345+
if (allow_duplicates_) {
346+
// Last-value-wins compaction: among sorted duplicates (ascending offset),
347+
// the last one has the highest offset = most recently inserted value.
348+
// Reverse-iterate to collect unique keys keeping the last occurrence.
349+
std::vector<FieldEntry> compacted;
350+
compacted.reserve(fields.size());
351+
for (auto it = fields.rbegin(); it != fields.rend(); ++it) {
352+
if (compacted.empty() || compacted.back().key != it->key) {
353+
compacted.push_back(std::move(*it));
354+
}
355+
}
356+
std::reverse(compacted.begin(), compacted.end());
357+
fields = std::move(compacted);
358+
} else {
359+
// Strict mode: reject duplicates
360+
for (size_t i = 1; i < fields.size(); ++i) {
361+
if (fields[i].key == fields[i - 1].key) {
362+
return Status::Invalid("VariantBuilder: duplicate key '", fields[i].key, "'");
363+
}
344364
}
345365
}
346366

@@ -405,14 +425,12 @@ Result<VariantBuilder::EncodedVariant> VariantBuilder::Finish() {
405425
total_string_size += static_cast<int64_t>(k.size());
406426
}
407427

408-
// Validate sizes fit within the spec's 4-byte offset limit.
409-
// Note: Go implementation enforces a stricter 128MB limit (metadataMaxSizeLimit).
410-
// We only enforce the spec's 4-byte offset maximum (~4GB), which is the correct
411-
// upper bound per the encoding format.
428+
// Validate sizes fit within the spec's 4-byte offset limit (~4GB).
429+
// This is the correct upper bound per the encoding format.
412430
if (total_string_size > static_cast<int64_t>(std::numeric_limits<uint32_t>::max())) {
413-
return Status::Invalid(
414-
"VariantBuilder: total dictionary string data (", total_string_size,
415-
" bytes) exceeds maximum representable by 4-byte offsets");
431+
return Status::Invalid("VariantBuilder: total dictionary string data (",
432+
total_string_size,
433+
" bytes) exceeds maximum representable by 4-byte offsets");
416434
}
417435

418436
// Compute the offset_size: must accommodate both the largest string offset
@@ -472,4 +490,28 @@ Result<VariantBuilder::EncodedVariant> VariantBuilder::Finish() {
472490
return result;
473491
}
474492

493+
Result<std::vector<uint8_t>> VariantBuilder::BuildWithoutMeta() {
494+
if (buffer_.empty()) {
495+
return Status::Invalid("VariantBuilder::BuildWithoutMeta: no value has been written");
496+
}
497+
std::vector<uint8_t> result = std::move(buffer_);
498+
// After std::move, buffer_ is in a valid-but-unspecified state.
499+
// Explicit clear() ensures a deterministic empty state for reuse.
500+
buffer_.clear();
501+
return result;
502+
}
503+
504+
void VariantBuilder::UnsafeAppendEncoded(const uint8_t* data, int64_t size) {
505+
// Callers must provide valid, non-empty variant bytes. The DCHECK guards
506+
// catch programming errors in debug builds; in release builds, a zero-size
507+
// append is a no-op (defensive against malformed input flowing through
508+
// reconstruction paths).
509+
DCHECK_NE(data, nullptr);
510+
DCHECK_GT(size, 0);
511+
if (size <= 0) return;
512+
buffer_.insert(buffer_.end(), data, data + size);
513+
}
514+
515+
void VariantBuilder::SetAllowDuplicates(bool allow) { allow_duplicates_ = allow; }
516+
475517
} // namespace arrow::extension::variant_internal

0 commit comments

Comments
 (0)