Skip to content

Commit 4c2c191

Browse files
branch-4.1: [feature](paimon) Support Variant V2 writes (#66321)
### What problem does this PR solve? Issue Number: Part of #65086 Problem Summary: The Paimon JNI writer already supports primitive and complex Doris columns, but a Doris Variant V2 value could not be written losslessly because the generic Arrow path exposed Variant as JSON text. That loses the native Variant `value` and `metadata` representation and does not support Variant nested in ARRAY, MAP, or STRUCT. This PR adds a Paimon-specific Variant V2 write protocol: - maps Paimon VARIANT targets, including nested VARIANT nodes, to Doris compute Variant V2 during analysis; - requires `enable_variant_v2=true` and rejects legacy Variant V1 inputs during analysis with an actionable error; - transports Variant V2 through Arrow as `struct<value: binary, metadata: binary>` while preserving SQL NULL separately from a Variant null value; - recursively applies the binary representation inside ARRAY, MAP, and STRUCT; - converts the Arrow pair to Paimon `GenericVariant` in the Java JNI backend and uses Paimon SDK accessors to validate compatibility with the bundled Paimon version; - keeps the existing JSON Arrow representation as the default for non-Paimon consumers. #### Supported write scenarios - `INSERT INTO ... VALUES` and `INSERT INTO ... SELECT`, including UNION inputs and reordered/omitted columns. - `INSERT OVERWRITE` and static partition writes. - Append-only and primary-key tables, fixed and dynamic bucket modes, and schema evolution involving VARIANT columns. - Top-level VARIANT and VARIANT nested in ARRAY, MAP, STRUCT, and deeper combinations. - Scalar/object/array values, SQL NULL, Variant null, typed primitives, long strings, and residual object fields. - Paimon Parquet Variant shredding. Regression coverage verifies the physical `typed_value` layout and values, Paimon unshredding, type-mismatch fallback to residual bytes, and reading a table containing both unshredded and shredded files. #### Current limitations - Variant writes are V2-only. When `enable_variant_v2=false`, or when a legacy Variant V1 expression is supplied to a Paimon VARIANT target, analysis fails intentionally. - The write path uses the Java Paimon JNI backend. - Doris-side querying of Paimon Variant V2 is outside this PR. End-to-end logical readback is therefore verified with the Spark/Paimon reader; raw Parquet shredding is independently verified through Doris's S3 TVF. - The added Paimon integration coverage uses Parquet, which is also the format used to validate Variant shredding. ### Release note Support writing Variant V2 values, including nested Variant values, from Doris into Apache Paimon tables through the Java JNI writer. Set `enable_variant_v2=true` before writing a Paimon table containing VARIANT. ### Check List (For Author) - Test - [x] Regression test - [ ] Unit Test - [x] Manual test - Debug FE and BE incremental builds. - `./run-regression-test.sh --run -s test_paimon_write_variant_shredding` - `./run-regression-test.sh --run -s test_paimon_write_variant_table_modes` - Behavior changed: - [ ] No. - [x] Yes. Paimon VARIANT targets now accept Variant V2 writes when enabled and reject disabled/V1 writes during analysis. - Does this need documentation? - [ ] No. - [x] Yes. User-facing documentation can follow separately. ### Check List (For Reviewer who merge this PR) - [ ] Confirm the release note - [ ] Confirm test cases - [ ] Confirm document - [ ] Add branch pick label
1 parent 1d147d8 commit 4c2c191

51 files changed

Lines changed: 2527 additions & 96 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

be/src/core/data_type_serde/data_type_variant_v2_serde.cpp

Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
#include "core/data_type_serde/data_type_variant_v2_serde.h"
1919

2020
#include <arrow/array/builder_binary.h>
21+
#include <arrow/array/builder_nested.h>
2122

2223
#include <algorithm>
2324
#include <cstring>
@@ -175,6 +176,133 @@ void preflight_json(const IColumn& column, size_t start, size_t end,
175176
});
176177
}
177178

179+
void validate_paimon_variant_primitive(VariantPrimitiveId primitive_id) {
180+
switch (primitive_id) {
181+
case VariantPrimitiveId::NULL_VALUE:
182+
case VariantPrimitiveId::TRUE_VALUE:
183+
case VariantPrimitiveId::FALSE_VALUE:
184+
case VariantPrimitiveId::INT8:
185+
case VariantPrimitiveId::INT16:
186+
case VariantPrimitiveId::INT32:
187+
case VariantPrimitiveId::INT64:
188+
case VariantPrimitiveId::DOUBLE:
189+
case VariantPrimitiveId::DECIMAL4:
190+
case VariantPrimitiveId::DECIMAL8:
191+
case VariantPrimitiveId::DECIMAL16:
192+
case VariantPrimitiveId::DATE:
193+
case VariantPrimitiveId::TIMESTAMP_MICROS:
194+
case VariantPrimitiveId::TIMESTAMP_NTZ_MICROS:
195+
case VariantPrimitiveId::FLOAT:
196+
case VariantPrimitiveId::BINARY:
197+
case VariantPrimitiveId::STRING:
198+
case VariantPrimitiveId::UUID:
199+
return;
200+
case VariantPrimitiveId::TIME_NTZ_MICROS:
201+
case VariantPrimitiveId::TIMESTAMP_NANOS:
202+
case VariantPrimitiveId::TIMESTAMP_NTZ_NANOS:
203+
throw Exception(ErrorCode::NOT_IMPLEMENTED_ERROR,
204+
"Paimon does not support Variant primitive id {}",
205+
static_cast<uint8_t>(primitive_id));
206+
}
207+
throw Exception(ErrorCode::NOT_IMPLEMENTED_ERROR,
208+
"Paimon does not support unknown Variant primitive id {}",
209+
static_cast<uint8_t>(primitive_id));
210+
}
211+
212+
void validate_paimon_variant_value(VariantRef value, uint32_t depth = 0) {
213+
if (depth > VARIANT_MAX_NESTING_DEPTH) {
214+
throw Exception(ErrorCode::CORRUPTION, "Variant value exceeds maximum nesting depth {}",
215+
VARIANT_MAX_NESTING_DEPTH);
216+
}
217+
const size_t encoded_size = value.value_size();
218+
if (encoded_size != value.value.size) {
219+
throw Exception(ErrorCode::CORRUPTION,
220+
"Variant value has {} trailing bytes after the encoded value",
221+
value.value.size - encoded_size);
222+
}
223+
224+
switch (value.basic_type()) {
225+
case VariantBasicType::PRIMITIVE:
226+
validate_paimon_variant_primitive(value.primitive_id());
227+
return;
228+
case VariantBasicType::SHORT_STRING:
229+
return;
230+
case VariantBasicType::OBJECT:
231+
for (uint32_t i = 0; i < value.num_elements(); ++i) {
232+
uint32_t field_id = 0;
233+
VariantRef child = value.object_value_at(i, &field_id);
234+
value.metadata.key_at(field_id);
235+
validate_paimon_variant_value(child, depth + 1);
236+
}
237+
return;
238+
case VariantBasicType::ARRAY:
239+
for (uint32_t i = 0; i < value.num_elements(); ++i) {
240+
validate_paimon_variant_value(value.array_at(i), depth + 1);
241+
}
242+
return;
243+
}
244+
}
245+
246+
void require_variant_arrow_status(const arrow::Status& status) {
247+
if (!status.ok()) {
248+
throw Exception(ErrorCode::INTERNAL_ERROR, "Variant V2 Arrow append failed: {}",
249+
status.ToString());
250+
}
251+
}
252+
253+
Status write_binary_variant_arrow(const IColumn& column, const NullMap* null_map,
254+
arrow::StructBuilder& builder, size_t start, size_t end) {
255+
// StructBuilder::type() returns a shared_ptr by value. Keep that owner alive while using the
256+
// cast reference; otherwise the reference would dangle as soon as the temporary is destroyed.
257+
const auto builder_type = builder.type();
258+
const auto& struct_type = assert_cast<const arrow::StructType&>(*builder_type);
259+
if (struct_type.num_fields() != 2 || struct_type.field(0)->name() != "value" ||
260+
struct_type.field(1)->name() != "metadata" ||
261+
struct_type.field(0)->type()->id() != arrow::Type::BINARY ||
262+
struct_type.field(1)->type()->id() != arrow::Type::BINARY) {
263+
return Status::InvalidArgument(
264+
"Binary Variant V2 Arrow type must be "
265+
"struct<value: binary, metadata: binary>, got {}",
266+
struct_type.ToString());
267+
}
268+
auto* value_builder = dynamic_cast<arrow::BinaryBuilder*>(builder.field_builder(0));
269+
auto* metadata_builder = dynamic_cast<arrow::BinaryBuilder*>(builder.field_builder(1));
270+
if (value_builder == nullptr || metadata_builder == nullptr) {
271+
return Status::InvalidArgument("Binary Variant V2 Arrow child builders must be binary");
272+
}
273+
274+
// GenericVariant assumes its input is valid, and Paimon's unshredded writer copies these two
275+
// buffers without inspecting them. Validate once at the Doris-to-Paimon boundary so a write
276+
// cannot commit bytes which Paimon is unable to read later.
277+
const auto outer_nulls = forced_nulls(null_map);
278+
visit_variant_v2_values(
279+
column, start, end, outer_nulls,
280+
[&](size_t) { require_variant_arrow_status(builder.AppendNull()); },
281+
[&](size_t row, VariantRef value) {
282+
try {
283+
constexpr size_t PAIMON_VARIANT_SIZE_LIMIT = 128 * 1024 * 1024;
284+
if (value.value.size > PAIMON_VARIANT_SIZE_LIMIT ||
285+
value.metadata.size > PAIMON_VARIANT_SIZE_LIMIT) {
286+
throw Exception(ErrorCode::INVALID_ARGUMENT,
287+
"exceeds the 128 MiB value/metadata limit");
288+
}
289+
value.metadata.validate();
290+
validate_paimon_variant_value(value);
291+
} catch (const Exception& e) {
292+
throw Exception(e.code(), "Paimon Variant V2 row {} is incompatible: {}", row,
293+
e.what());
294+
}
295+
require_variant_arrow_status(builder.Append());
296+
require_variant_arrow_status(
297+
value_builder->Append(reinterpret_cast<const uint8_t*>(value.value.data),
298+
cast_set<int32_t, size_t, false>(value.value.size)));
299+
require_variant_arrow_status(metadata_builder->Append(
300+
reinterpret_cast<const uint8_t*>(value.metadata.data),
301+
cast_set<int32_t, size_t, false>(value.metadata.size)));
302+
});
303+
return Status::OK();
304+
}
305+
178306
} // namespace
179307

180308
DataTypeVariantV2SerDe::DataTypeVariantV2SerDe(int nesting_level) : DataTypeSerDe(nesting_level) {}
@@ -537,6 +665,11 @@ Status DataTypeVariantV2SerDe::write_column_to_arrow(const IColumn& column, cons
537665
assert_cast<arrow::LargeStringBuilder&>(*array_builder), first, last,
538666
options);
539667
}
668+
if (array_builder->type()->id() == arrow::Type::STRUCT) {
669+
return write_binary_variant_arrow(column, null_map,
670+
assert_cast<arrow::StructBuilder&>(*array_builder),
671+
first, last);
672+
}
540673
return Status::InvalidArgument("Unsupported arrow type for variant column: {}",
541674
array_builder->type()->name());
542675
});

be/src/exec/sink/writer/paimon/jni_paimon_write_backend.cpp

Lines changed: 69 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,10 @@
3232

3333
#include "common/check.h"
3434
#include "common/logging.h"
35+
#include "core/data_type/data_type_agg_state.h"
36+
#include "core/data_type/data_type_array.h"
37+
#include "core/data_type/data_type_map.h"
38+
#include "core/data_type/data_type_struct.h"
3539
#include "exec/sink/writer/paimon/paimon_jni_memory_manager.h"
3640
#include "format/arrow/arrow_block_convertor.h"
3741
#include "format/arrow/arrow_row_batch.h"
@@ -69,6 +73,69 @@ void retain_memory_after_failed_close(std::unique_ptr<PaimonJniMemoryManager> ma
6973
std::lock_guard<std::mutex> lock(retained_memory_managers_mutex());
7074
retained_memory_managers().emplace_back(std::move(manager));
7175
}
76+
77+
Status convert_to_paimon_arrow_type(const DataTypePtr& origin_type,
78+
std::shared_ptr<arrow::DataType>* result,
79+
const std::string& timezone) {
80+
const DataTypePtr type = get_serialized_type(origin_type);
81+
switch (type->get_primitive_type()) {
82+
case TYPE_VARIANT:
83+
// Paimon consumes the lossless Variant V2 representation. Keeping both children non-null
84+
// distinguishes a SQL NULL struct from a non-null Variant value.
85+
*result = arrow::struct_({arrow::field("value", arrow::binary(), false),
86+
arrow::field("metadata", arrow::binary(), false)});
87+
return Status::OK();
88+
case TYPE_ARRAY: {
89+
const auto& array_type = assert_cast<const DataTypeArray&>(*remove_nullable(type));
90+
std::shared_ptr<arrow::DataType> element_type;
91+
RETURN_IF_ERROR(convert_to_paimon_arrow_type(array_type.get_nested_type(), &element_type,
92+
timezone));
93+
*result = std::make_shared<arrow::ListType>(element_type);
94+
return Status::OK();
95+
}
96+
case TYPE_MAP: {
97+
const auto& map_type = assert_cast<const DataTypeMap&>(*remove_nullable(type));
98+
std::shared_ptr<arrow::DataType> key_type;
99+
std::shared_ptr<arrow::DataType> value_type;
100+
RETURN_IF_ERROR(convert_to_paimon_arrow_type(map_type.get_key_type(), &key_type, timezone));
101+
RETURN_IF_ERROR(
102+
convert_to_paimon_arrow_type(map_type.get_value_type(), &value_type, timezone));
103+
*result = std::make_shared<arrow::MapType>(key_type, value_type);
104+
return Status::OK();
105+
}
106+
case TYPE_STRUCT: {
107+
const auto& struct_type = assert_cast<const DataTypeStruct&>(*remove_nullable(type));
108+
std::vector<std::shared_ptr<arrow::Field>> fields;
109+
fields.reserve(struct_type.get_elements().size());
110+
for (size_t i = 0; i < struct_type.get_elements().size(); ++i) {
111+
const DataTypePtr& element = struct_type.get_element(i);
112+
std::shared_ptr<arrow::DataType> field_type;
113+
RETURN_IF_ERROR(convert_to_paimon_arrow_type(element, &field_type, timezone));
114+
fields.push_back(arrow::field(struct_type.get_element_name(i), field_type,
115+
element->is_nullable()));
116+
}
117+
*result = arrow::struct_(std::move(fields));
118+
return Status::OK();
119+
}
120+
default:
121+
return convert_to_arrow_type(origin_type, result, timezone);
122+
}
123+
}
124+
125+
Status get_paimon_arrow_schema_from_block(const Block& block,
126+
std::shared_ptr<arrow::Schema>* result) {
127+
std::vector<std::shared_ptr<arrow::Field>> fields;
128+
fields.reserve(block.columns());
129+
for (const auto& type_and_name : block) {
130+
std::shared_ptr<arrow::DataType> arrow_type;
131+
RETURN_IF_ERROR(convert_to_paimon_arrow_type(type_and_name.type, &arrow_type, ""));
132+
fields.push_back(create_arrow_field_with_metadata(
133+
type_and_name.name, arrow_type, type_and_name.type->is_nullable(),
134+
type_and_name.type->get_primitive_type()));
135+
}
136+
*result = arrow::schema(std::move(fields));
137+
return Status::OK();
138+
}
72139
} // namespace
73140

74141
// ────────────────────────────────────────────────────────────
@@ -359,8 +426,9 @@ Status JniPaimonWriter::_write_projected_block(RuntimeState* state, Block& block
359426
// Step 1: Build Arrow schema from the projected Block.
360427
// Paimon write timestamps are transported as civil-time fields. The Java writer uses the
361428
// pinned Paimon target type to preserve NTZ values or convert LTZ values with the session zone.
429+
// Variant V2 is transported losslessly as its value/metadata pair, including nested Variant.
362430
std::shared_ptr<arrow::Schema> arrow_schema;
363-
RETURN_IF_ERROR(get_arrow_schema_from_block(block, &arrow_schema, ""));
431+
RETURN_IF_ERROR(get_paimon_arrow_schema_from_block(block, &arrow_schema));
364432

365433
// Step 2: Convert Doris Block columns to an Arrow RecordBatch.
366434
std::shared_ptr<arrow::RecordBatch> record_batch;

be/src/exprs/function/cast/variant_v2/cast_array_to_variant.cpp

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -137,7 +137,9 @@ Status build_array_node_plan(const ColumnPtr& source, const DataTypePtr& source_
137137

138138
Status build_array_leaf_plan(const ColumnPtr& source, PrimitiveType primitive,
139139
ArrayEncodePlan* plan) {
140-
if (primitive == INVALID_TYPE && source->empty()) {
140+
if (primitive == INVALID_TYPE) {
141+
// DataTypeNothing is represented by the element null map, including non-empty
142+
// expressions such as array(NULL).
141143
return Status::OK();
142144
} else if (primitive == TYPE_VARIANT) {
143145
const auto* variant = check_and_get_column<ColumnVariantV2>(source.get());
@@ -196,7 +198,7 @@ void append_array_value(const ArrayEncodePlan& plan, size_t index, VariantBatchB
196198
} else if (plan.jsonb_leaf != nullptr) {
197199
jsonb_to_variant(plan.jsonb_leaf->get_data_at(index), *row);
198200
} else {
199-
DORIS_CHECK(false) << "empty Array leaf unexpectedly contains a value";
201+
DORIS_CHECK(false) << "Array Variant V2 leaf has no encoder";
200202
}
201203
return;
202204
}

be/test/core/data_type_serde/data_type_serde_arrow_test.cpp

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,7 @@
7070
#include "core/data_type/data_type_quantilestate.h"
7171
#include "core/data_type/data_type_string.h"
7272
#include "core/data_type/data_type_struct.h"
73+
#include "core/data_type/data_type_variant_v2.h"
7374
#include "core/data_type/define_primitive_type.h"
7475
#include "core/field.h"
7576
#include "core/types.h"

be/test/core/data_type_serde/data_type_variant_v2_serde_output_test.cpp

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,9 @@
1515
// specific language governing permissions and limitations
1616
// under the License.
1717

18+
#include <arrow/array/array_nested.h>
1819
#include <arrow/array/builder_binary.h>
20+
#include <arrow/array/builder_nested.h>
1921
#include <gtest/gtest.h>
2022

2123
#include <array>
@@ -198,6 +200,50 @@ std::vector<std::optional<std::string>> orc_values(const DataTypeVariantV2SerDe&
198200
return result;
199201
}
200202

203+
std::shared_ptr<arrow::DataType> binary_variant_arrow_type() {
204+
return arrow::struct_({arrow::field("value", arrow::binary(), false),
205+
arrow::field("metadata", arrow::binary(), false)});
206+
}
207+
208+
std::unique_ptr<arrow::StructBuilder> binary_variant_arrow_builder() {
209+
return std::make_unique<arrow::StructBuilder>(
210+
binary_variant_arrow_type(), arrow::default_memory_pool(),
211+
std::vector<std::shared_ptr<arrow::ArrayBuilder>> {
212+
std::make_shared<arrow::BinaryBuilder>(arrow::default_memory_pool()),
213+
std::make_shared<arrow::BinaryBuilder>(arrow::default_memory_pool())});
214+
}
215+
216+
void expect_binary_variant_bytes(const DataTypeVariantV2SerDe& serde, const IColumn& column,
217+
const ColumnVariantV2& encoded,
218+
const NullMap* null_map = nullptr) {
219+
auto builder = binary_variant_arrow_builder();
220+
const Status status = serde.write_column_to_arrow(column, null_map, builder.get(), 0,
221+
column.size(), cctz::utc_time_zone());
222+
ASSERT_TRUE(status.ok()) << status;
223+
224+
std::shared_ptr<arrow::Array> output;
225+
ASSERT_TRUE(builder->Finish(&output).ok());
226+
const auto& array = assert_cast<const arrow::StructArray&>(*output);
227+
const auto& values = assert_cast<const arrow::BinaryArray&>(*array.field(0));
228+
const auto& metadata = assert_cast<const arrow::BinaryArray&>(*array.field(1));
229+
ASSERT_EQ(array.length(), static_cast<int64_t>(column.size()));
230+
const auto view = encoded.read_view();
231+
for (size_t row = 0; row < column.size(); ++row) {
232+
const bool expected_null = null_map != nullptr && (*null_map)[row] != 0;
233+
EXPECT_EQ(array.IsNull(row), expected_null);
234+
if (expected_null) {
235+
continue;
236+
}
237+
const VariantRef expected = view.value_at(row);
238+
const auto actual_value = values.GetView(row);
239+
const auto actual_metadata = metadata.GetView(row);
240+
EXPECT_EQ(std::string_view(actual_value.data(), actual_value.size()),
241+
std::string_view(expected.value.data, expected.value.size));
242+
EXPECT_EQ(std::string_view(actual_metadata.data(), actual_metadata.size()),
243+
std::string_view(expected.metadata.data, expected.metadata.size));
244+
}
245+
}
246+
201247
// NOLINTNEXTLINE(readability-function-cognitive-complexity) -- GTest macros inflate the matrix.
202248
void expect_text_surfaces(const DataTypeVariantV2SerDe& serde, const IColumn& encoded,
203249
const ColumnVariantV2& typed,
@@ -379,4 +425,34 @@ TEST(DataTypeVariantV2SerdeOutputTest, ConstNullableAndOuterMasksPreserveBoundar
379425
EXPECT_TRUE(invalid_dates->is_typed());
380426
}
381427

428+
TEST(DataTypeVariantV2SerdeOutputTest, BinaryStructPreservesEncodedAndTypedBytesAndOuterNulls) {
429+
DataTypeVariantV2SerDe serde;
430+
auto documents = encoded_json({R"({"a":[1,null,"x"]})", R"({"hidden":true})", "null"});
431+
NullMap mask {0, 1, 0};
432+
expect_binary_variant_bytes(serde, *documents, *documents, &mask);
433+
434+
auto typed = typed_strings(
435+
{std::string_view("plain"), std::nullopt, std::string_view(R"({"text":"value"})")});
436+
ColumnPtr encoded = encoded_copy(*typed);
437+
expect_binary_variant_bytes(serde, *typed, assert_cast<const ColumnVariantV2&>(*encoded));
438+
EXPECT_TRUE(typed->is_typed());
439+
}
440+
441+
TEST(DataTypeVariantV2SerdeOutputTest, BinaryStructRejectsUnsupportedPaimonPrimitive) {
442+
DataTypeVariantV2SerDe serde;
443+
VariantBatchBuilder builder(VariantBatchBuilder::ReserveHint {.rows = 1});
444+
auto row = builder.begin_row();
445+
row.add_time_ntz_micros(1'500'000);
446+
row.finish();
447+
auto encoded = ColumnVariantV2::create();
448+
encoded->insert_encoded_batch(builder.finish_batch());
449+
auto arrow_builder = binary_variant_arrow_builder();
450+
const Status status = serde.write_column_to_arrow(*encoded, nullptr, arrow_builder.get(), 0,
451+
encoded->size(), cctz::utc_time_zone());
452+
EXPECT_EQ(status.code(), ErrorCode::NOT_IMPLEMENTED_ERROR);
453+
EXPECT_NE(status.to_string().find("Paimon does not support Variant primitive id 17"),
454+
std::string::npos);
455+
EXPECT_EQ(arrow_builder->length(), 0);
456+
}
457+
382458
} // namespace doris

0 commit comments

Comments
 (0)