This is an automated email from the ASF dual-hosted git repository.
Gabriel39 pushed a commit to branch branch-4.1
in repository https://gitbox.apache.org/repos/asf/doris.git
The following commit(s) were added to refs/heads/branch-4.1 by this push:
new a17fe99ac4a [fix](iceberg) Backport nested partition and default fixes
to branch-4.1 (#66825)
a17fe99ac4a is described below
commit a17fe99ac4a08bff7869da96eacabc367314ea94
Author: Gabriel <[email protected]>
AuthorDate: Tue Oct 13 09:24:54 2026 +0800
[fix](iceberg) Backport nested partition and default fixes to branch-4.1
(#66825)
### What problem does this PR solve?
Backport the remaining applicable fixes from #66529 to the legacy
Iceberg implementation on `branch-4.1`. The requested scope is limited
to #66529 and #66750.
| Source PR | Applicability on branch-4.1 |
| --- | --- |
| #66529 | Port nested partition-source handling, INSERT retry metadata
reset, format V2 non-finite initial defaults and position-delete row
defaults/requiredness. Adapt the changes to the existing synchronous
writer and statement-pinned schema. |
| #66750 | No code change needed: its lowercase system-table diagnostic
regression belongs to the connector-provider implementation. The legacy
Paimon scan paths already use `Paimon` in these diagnostics. Keep the
branch's existing engine metadata and routing behavior. |
Format V1 reader bug fixes and compatibility changes are outside this
PR.
### Changes
- Accept nested primitive partition sources in the pinned writer schema;
resolve them by stable field IDs and pass the root slot plus child path
through FE/BE routing.
- Preserve parent NULLs in writer and UPDATE/MERGE exchange partition
transforms. Bind nested identity predicates by their full schema path
and reject static nested partition labels that cannot be validated
against row contents.
- Refresh planner-loaded MVCC metadata on INSERT retries while retaining
caller-injected source snapshot fences across every attempt. A new
execution still clears all snapshots.
- Exclude Iceberg V3 lineage columns from positional field-ID binding so
transformed and nested partitions retain partition-aware UPDATE/MERGE
routing.
- In the format V2 reader, materialize Iceberg `NaN`, `Infinity`, and
`-Infinity` initial defaults without sending them through Doris's
finite-only numeric parser. Prepare typed nested defaults and enforce
required-field checks for position-delete row projections.
- Include the source regression coverage and focused tests for nested
writer metadata, field-ID routing, parent NULLs, static partition
rejection, retry snapshot ownership, V3 routing, and default
materialization.
### Validation
- The new retry-snapshot and V3 routing tests each reproduced the
reported failure before their corresponding fixes.
- FE focused tests: 231 passed, with no failures, errors, or skipped
tests.
- ASAN BE focused tests: 257 passed from 27 suites, including nested
partition routing, format V2 non-finite defaults, and position-delete
row defaults.
- FE Checkstyle: passed.
- clang-format 16: all 16 affected C/C++ files passed, including the
restored V1 files.
- Full update-diff self-review and whitespace checks: passed.
Full external-cluster regressions were not run locally. Existing
regression output is retained from the earlier backport; it was not
regenerated during this update.
---
.../sink/writer/iceberg/viceberg_table_writer.cpp | 134 +++++++++++++++++---
.../sink/writer/iceberg/viceberg_table_writer.h | 7 ++
be/src/format/table/iceberg/schema.cpp | 29 ++++-
be/src/format/table/iceberg/schema.h | 4 +
.../transformer/iceberg_partition_function.cpp | 76 +++++++++++-
.../transformer/iceberg_partition_function.h | 4 +
.../iceberg_position_delete_sys_table_reader.cpp | 10 ++
be/src/format_v2/table/iceberg_reader.cpp | 37 +++++-
be/src/format_v2/table/iceberg_reader.h | 4 +
be/test/core/value/merge_partitioner_test.cpp | 91 ++++++++++++++
.../writer/iceberg/viceberg_table_writer_test.cpp | 83 ++++++++++++-
be/test/format/table/iceberg/schema_test.cpp | 16 +++
...eberg_position_delete_sys_table_reader_test.cpp | 29 +++++
be/test/format_v2/table/iceberg_reader_test.cpp | 33 +++++
.../datasource/iceberg/IcebergTransaction.java | 24 +++-
.../iceberg/IcebergWriteSchemaContext.java | 7 +-
.../org/apache/doris/nereids/StatementContext.java | 11 ++
.../glue/translator/PhysicalPlanTranslator.java | 3 +-
.../nereids/properties/DistributionSpecMerge.java | 17 ++-
.../commands/insert/InsertIntoTableCommand.java | 8 ++
.../plans/physical/PhysicalIcebergMergeSink.java | 92 +++++++++++++-
.../org/apache/doris/planner/DataPartition.java | 10 ++
.../datasource/iceberg/IcebergTransactionTest.java | 14 +++
.../iceberg/IcebergWriteSchemaContextTest.java | 17 +++
.../plans/commands/insert/InsertUtilsTest.java | 47 +++++++
.../physical/PhysicalIcebergMergeSinkTest.java | 135 +++++++++++++++++++++
gensrc/thrift/Partitions.thrift | 2 +
.../write/test_iceberg_write_complex_evolution.out | 12 +-
.../test_iceberg_write_complex_evolution.groovy | 26 +++-
29 files changed, 930 insertions(+), 52 deletions(-)
diff --git a/be/src/exec/sink/writer/iceberg/viceberg_table_writer.cpp
b/be/src/exec/sink/writer/iceberg/viceberg_table_writer.cpp
index 6b1f8318c33..71c5c1c931d 100644
--- a/be/src/exec/sink/writer/iceberg/viceberg_table_writer.cpp
+++ b/be/src/exec/sink/writer/iceberg/viceberg_table_writer.cpp
@@ -17,6 +17,7 @@
#include "exec/sink/writer/iceberg/viceberg_table_writer.h"
+#include <algorithm>
#include <boost/uuid/string_generator.hpp>
#include "common/exception.h"
@@ -25,8 +26,10 @@
#include "core/block/materialize_block.h"
#include "core/column/column_const.h"
#include "core/column/column_nullable.h"
+#include "core/column/column_struct.h"
#include "core/column/column_vector.h"
#include "core/data_type/data_type_nullable.h"
+#include "core/data_type/data_type_struct.h"
#include "core/data_type_serde/data_type_serde.h"
#include "exec/sink/writer/iceberg/iceberg_partition_path.h"
#include "exec/sink/writer/iceberg/partition_transformers.h"
@@ -123,31 +126,106 @@ std::vector<VIcebergTableWriter::IcebergPartitionColumn>
VIcebergTableWriter::_to_iceberg_partition_columns() {
std::vector<IcebergPartitionColumn> partition_columns;
- std::unordered_map<int, int> id_to_column_idx;
- id_to_column_idx.reserve(_schema->columns().size());
- for (int i = 0; i < _schema->columns().size(); i++) {
- id_to_column_idx[_schema->columns()[i].field_id()] = i;
- }
for (const auto& partition_field : _partition_spec->fields()) {
- auto column_idx_it =
id_to_column_idx.find(partition_field.source_id());
- if (column_idx_it == id_to_column_idx.end()) {
+ const auto* field_path =
_schema->find_field_path(partition_field.source_id());
+ if (field_path == nullptr || field_path->empty()) {
throw Exception(
ErrorCode::INTERNAL_ERROR,
"Iceberg partition field {} references source field {}
outside writer schema",
partition_field.field_id(), partition_field.source_id());
}
- int column_idx = column_idx_it->second;
+ int column_idx = -1;
+ for (int i = 0; i < _schema->columns().size(); ++i) {
+ if (_schema->columns()[i].field_id() ==
field_path->front()->field_id()) {
+ column_idx = i;
+ break;
+ }
+ }
+ DORIS_CHECK(column_idx >= 0);
+ std::vector<size_t> child_indices;
+ iceberg::Type* iceberg_type = field_path->front()->field_type();
+ DataTypePtr source_type =
_vec_output_expr_ctxs[column_idx]->root()->data_type();
+ for (size_t depth = 1; depth < field_path->size(); ++depth) {
+ if (!iceberg_type->is_struct_type()) {
+ throw Exception(ErrorCode::INTERNAL_ERROR,
+ "Iceberg partition source field {} has a
non-struct ancestor",
+ partition_field.source_id());
+ }
+ const auto& fields = iceberg_type->as_struct_type()->fields();
+ auto child = std::find_if(fields.begin(), fields.end(), [&](const
auto& candidate) {
+ return candidate.field_id() ==
(*field_path)[depth]->field_id();
+ });
+ DORIS_CHECK(child != fields.end());
+ const size_t child_idx = std::distance(fields.begin(), child);
+ const auto* struct_type =
+
check_and_get_data_type<DataTypeStruct>(remove_nullable(source_type).get());
+ if (struct_type == nullptr || child_idx >=
struct_type->get_elements().size()) {
+ throw Exception(
+ ErrorCode::INTERNAL_ERROR,
+ "Iceberg nested partition source field {} does not
match writer type",
+ partition_field.source_id());
+ }
+ child_indices.push_back(child_idx);
+ iceberg_type = child->field_type();
+ source_type = struct_type->get_element(child_idx);
+ }
+ if (!iceberg_type->is_primitive_type()) {
+ throw Exception(ErrorCode::INTERNAL_ERROR,
+ "Iceberg partition source field {} is not
primitive",
+ partition_field.source_id());
+ }
std::unique_ptr<PartitionColumnTransform> partition_column_transform =
- PartitionColumnTransforms::create(
- partition_field,
_vec_output_expr_ctxs[column_idx]->root()->data_type());
+ PartitionColumnTransforms::create(partition_field,
source_type);
partition_columns.emplace_back(
- partition_field,
-
_vec_output_expr_ctxs[column_idx]->root()->data_type()->get_primitive_type(),
- column_idx, std::move(partition_column_transform));
+ partition_field,
remove_nullable(source_type)->get_primitive_type(), column_idx,
+ std::move(child_indices),
std::move(partition_column_transform));
}
return partition_columns;
}
+ColumnWithTypeAndName VIcebergTableWriter::_nested_partition_source(
+ const Block& block, const IcebergPartitionColumn& partition_column)
const {
+ ColumnWithTypeAndName source =
block.get_by_position(partition_column.source_idx());
+ if (partition_column.child_indices().empty()) {
+ return source;
+ }
+ ColumnPtr column = source.column->convert_to_full_column_if_const();
+ DataTypePtr type = source.type;
+ auto combined_nulls = ColumnUInt8::create(block.rows(), 0);
+ bool nullable = false;
+ auto unwrap_nullable = [&]() {
+ if (const auto* nullable_column =
check_and_get_column<ColumnNullable>(column.get())) {
+ nullable = true;
+ const auto& nulls = nullable_column->get_null_map_data();
+ auto& combined = combined_nulls->get_data();
+ for (size_t row = 0; row < combined.size(); ++row) {
+ combined[row] |= nulls[row];
+ }
+ column = nullable_column->get_nested_column_ptr();
+ type = remove_nullable(type);
+ }
+ };
+ for (size_t child_idx : partition_column.child_indices()) {
+ unwrap_nullable();
+ const auto* struct_column =
check_and_get_column<ColumnStruct>(column.get());
+ const auto* struct_type =
check_and_get_data_type<DataTypeStruct>(type.get());
+ if (struct_column == nullptr || struct_type == nullptr ||
+ child_idx >= struct_column->tuple_size()) {
+ throw Exception(ErrorCode::INTERNAL_ERROR,
+ "Iceberg nested partition source does not match
writer block");
+ }
+ column = struct_column->get_column_ptr(child_idx);
+ type = struct_type->get_element(child_idx);
+ }
+ // Parent NULL masks the leaf even when the nested storage column contains
a materialized value.
+ unwrap_nullable();
+ if (nullable) {
+ column = ColumnNullable::create(column, std::move(combined_nulls));
+ type = make_nullable(type);
+ }
+ return {std::move(column), std::move(type), source.name};
+}
+
void VIcebergTableWriter::_init_static_partition_values() {
auto& iceberg_sink = _t_sink.iceberg_table_sink;
if (!iceberg_sink.__isset.static_partition_values ||
@@ -159,6 +237,29 @@ void VIcebergTableWriter::_init_static_partition_values() {
_has_static_partition = true;
const auto& static_values_map = iceberg_sink.static_partition_values;
+ if (_partition_spec == nullptr) {
+ throw Exception(ErrorCode::INVALID_ARGUMENT,
+ "Static Iceberg partitions require a partition
specification");
+ }
+
+ for (const auto& partition_field : _partition_spec->fields()) {
+ if (!static_values_map.contains(partition_field.name())) {
+ continue;
+ }
+ const auto* field_path =
_schema->find_field_path(partition_field.source_id());
+ if (field_path == nullptr) {
+ throw Exception(ErrorCode::INTERNAL_ERROR,
+ "Iceberg partition field {} references a missing
source field",
+ partition_field.field_id());
+ }
+ if (field_path->size() > 1) {
+ // Static values bypass extraction from the row, so they cannot
prove that a nested
+ // source leaf written to the file matches the partition label.
+ throw Exception(ErrorCode::INVALID_ARGUMENT,
+ "Static Iceberg partition field {} cannot use a
nested source column",
+ partition_field.name());
+ }
+ }
size_t num_cols = _iceberg_partition_columns.size();
_partition_column_static_values.resize(num_cols);
@@ -467,9 +568,12 @@ Status VIcebergTableWriter::_write_prepared_block(Block&
output_block) {
transformed_block.insert(
{std::move(col), result_type,
iceberg_partition_columns.field().name()});
} else {
+ Block source_block;
+ source_block.insert(
+ _nested_partition_source(output_block,
iceberg_partition_columns));
transformed_block.insert(
-
iceberg_partition_columns.partition_column_transform().apply(
- output_block,
iceberg_partition_columns.source_idx()));
+
iceberg_partition_columns.partition_column_transform().apply(source_block,
+
0));
}
}
for (int i = 0; i < output_block.rows(); ++i) {
diff --git a/be/src/exec/sink/writer/iceberg/viceberg_table_writer.h
b/be/src/exec/sink/writer/iceberg/viceberg_table_writer.h
index c66cc1a74da..5baf351c72c 100644
--- a/be/src/exec/sink/writer/iceberg/viceberg_table_writer.h
+++ b/be/src/exec/sink/writer/iceberg/viceberg_table_writer.h
@@ -91,6 +91,7 @@ public:
private:
friend class IcebergTableSinkOperatorTest;
FRIEND_TEST(VIcebergTableWriterTest, RejectMissingPartitionSource);
+ FRIEND_TEST(VIcebergTableWriterTest, ResolvesNestedPartitionSource);
// The currently active partition writer (may be VIcebergPartitionWriter
or VIcebergSortWriter).
// Updated during write() to track which writer received the most recent
data.
@@ -102,10 +103,12 @@ private:
public:
IcebergPartitionColumn(const iceberg::PartitionField& field,
const PrimitiveType& source_type, int
source_idx,
+ std::vector<size_t> child_indices,
std::unique_ptr<PartitionColumnTransform>
partition_column_transform)
: _field(field),
_source_type(source_type),
_source_idx(source_idx),
+ _child_indices(std::move(child_indices)),
_partition_column_transform(std::move(partition_column_transform)) {}
public:
@@ -113,6 +116,7 @@ private:
const PrimitiveType& source_type() const { return _source_type; }
int source_idx() const { return _source_idx; }
+ const std::vector<size_t>& child_indices() const { return
_child_indices; }
const PartitionColumnTransform& partition_column_transform() const {
return *_partition_column_transform;
@@ -126,10 +130,13 @@ private:
const iceberg::PartitionField& _field;
PrimitiveType _source_type;
int _source_idx;
+ std::vector<size_t> _child_indices;
std::unique_ptr<PartitionColumnTransform> _partition_column_transform;
};
std::vector<IcebergPartitionColumn> _to_iceberg_partition_columns();
+ ColumnWithTypeAndName _nested_partition_source(
+ const Block& block, const IcebergPartitionColumn&
partition_column) const;
std::string _partition_to_writer_key(const doris::iceberg::StructLike&
data);
std::string _partition_to_path(const doris::iceberg::StructLike& data);
diff --git a/be/src/format/table/iceberg/schema.cpp
b/be/src/format/table/iceberg/schema.cpp
index 5a1bdaf5c93..25e2d20919a 100644
--- a/be/src/format/table/iceberg/schema.cpp
+++ b/be/src/format/table/iceberg/schema.cpp
@@ -17,6 +17,8 @@
#include "format/table/iceberg/schema.h"
+#include <functional>
+
namespace doris::iceberg {
#include "common/compile_check_begin.h"
@@ -25,10 +27,26 @@ const int Schema::DEFAULT_SCHEMA_ID = 0;
Schema::Schema(int schema_id, std::vector<NestedField> columns)
: _schema_id(schema_id), _root_struct(std::move(columns)) {
- _id_to_field.reserve(_root_struct.fields().size());
+ FieldPath path;
+ std::function<void(const NestedField&)> index_field = [&](const
NestedField& field) {
+ path.push_back(&field);
+ _id_to_field[field.field_id()] = &field;
+ _id_to_field_path[field.field_id()] = path;
+ Type* type = field.field_type();
+ if (type->is_struct_type()) {
+ for (const auto& child : type->as_struct_type()->fields()) {
+ index_field(child);
+ }
+ } else if (type->is_list_type()) {
+ index_field(type->as_list_type()->element_field());
+ } else if (type->is_map_type()) {
+ index_field(type->as_map_type()->key_field());
+ index_field(type->as_map_type()->value_field());
+ }
+ path.pop_back();
+ };
for (const auto& field : _root_struct.fields()) {
- int field_id = field.field_id();
- _id_to_field[field_id] = &field;
+ index_field(field);
}
}
Schema::Schema(std::vector<NestedField> columns) : Schema(DEFAULT_SCHEMA_ID,
std::move(columns)) {}
@@ -49,5 +67,10 @@ const NestedField* Schema::find_field(int id) const {
return nullptr;
}
+const Schema::FieldPath* Schema::find_field_path(int id) const {
+ auto it = _id_to_field_path.find(id);
+ return it == _id_to_field_path.end() ? nullptr : &it->second;
+}
+
#include "common/compile_check_end.h"
} // namespace doris::iceberg
diff --git a/be/src/format/table/iceberg/schema.h
b/be/src/format/table/iceberg/schema.h
index 29ea62510ee..70377a63ac0 100644
--- a/be/src/format/table/iceberg/schema.h
+++ b/be/src/format/table/iceberg/schema.h
@@ -27,6 +27,7 @@ class StructType;
class Schema {
public:
+ using FieldPath = std::vector<const NestedField*>;
Schema(int schema_id, std::vector<NestedField> columns);
Schema(std::vector<NestedField> columns);
@@ -41,6 +42,8 @@ public:
const NestedField* find_field(int id) const;
+ const FieldPath* find_field_path(int id) const;
+
private:
static const char NEWLINE = '\n';
static const std::string ALL_COLUMNS;
@@ -49,6 +52,7 @@ private:
int _schema_id;
StructType _root_struct;
std::unordered_map<int, const NestedField*> _id_to_field;
+ std::unordered_map<int, FieldPath> _id_to_field_path;
};
#include "common/compile_check_end.h"
diff --git a/be/src/format/transformer/iceberg_partition_function.cpp
b/be/src/format/transformer/iceberg_partition_function.cpp
index 14eba85e28a..64062e7ee53 100644
--- a/be/src/format/transformer/iceberg_partition_function.cpp
+++ b/be/src/format/transformer/iceberg_partition_function.cpp
@@ -23,6 +23,8 @@
#include "core/column/column_const.h"
#include "core/column/column_nullable.h"
#include "core/column/column_struct.h"
+#include "core/column/column_vector.h"
+#include "core/data_type/data_type_nullable.h"
#include "core/data_type/data_type_struct.h"
#include "exec/sink/writer/iceberg/partition_transformers.h"
#include "format/table/iceberg/partition_spec.h"
@@ -88,6 +90,9 @@ Status IcebergInsertPartitionFunction::init(const
std::vector<TExpr>& texprs) {
insert_field.expr_ctx = std::move(ctx);
insert_field.source_id = field.__isset.source_id ? field.source_id
: 0;
insert_field.name = field.__isset.name ? field.name : "";
+ if (field.__isset.source_field_path) {
+ insert_field.source_field_path = field.source_field_path;
+ }
_partition_fields.emplace_back(std::move(insert_field));
}
}
@@ -118,10 +123,22 @@ Status IcebergInsertPartitionFunction::open(RuntimeState*
state) {
RETURN_IF_ERROR(VExpr::open(field_ctxs, state));
for (auto& field : _partition_fields) {
try {
+ DataTypePtr source_type = field.expr_ctx->root()->data_type();
+ for (int32_t child_index : field.source_field_path) {
+ const auto* struct_type =
check_and_get_data_type<DataTypeStruct>(
+ remove_nullable(source_type).get());
+ if (child_index < 0 || struct_type == nullptr ||
+ static_cast<size_t>(child_index) >=
struct_type->get_elements().size()) {
+ throw Exception(ErrorCode::INTERNAL_ERROR,
+ "Iceberg nested merge partition source
does not match "
+ "expression type");
+ }
+ // Transform validation must use the leaf type that
get_partitions() extracts.
+ source_type =
struct_type->get_element(static_cast<size_t>(child_index));
+ }
doris::iceberg::PartitionField
partition_field(field.source_id, 0, field.name,
field.transform);
- field.transformer = PartitionColumnTransforms::create(
- partition_field, field.expr_ctx->root()->data_type());
+ field.transformer =
PartitionColumnTransforms::create(partition_field, source_type);
} catch (const doris::Exception& e) {
return Status::NotSupported("Unsupported Iceberg partition
transform: {}",
e.what());
@@ -168,12 +185,60 @@ Status
IcebergInsertPartitionFunction::clone(RuntimeState* state,
field.expr_ctx = dst_field_ctxs[i];
field.source_id = _partition_fields[i].source_id;
field.name = _partition_fields[i].name;
+ field.source_field_path = _partition_fields[i].source_field_path;
new_function->_partition_fields.emplace_back(std::move(field));
}
}
return Status::OK();
}
+Status IcebergInsertPartitionFunction::_nested_partition_source(
+ size_t rows, const InsertPartitionField& field, ColumnWithTypeAndName*
source) const {
+ if (field.source_field_path.empty()) {
+ return Status::OK();
+ }
+ ColumnPtr column = source->column->convert_to_full_column_if_const();
+ DataTypePtr type = source->type;
+ ColumnUInt8::MutablePtr combined_nulls;
+ bool nullable = false;
+ auto unwrap_nullable = [&]() {
+ if (const auto* nullable_column =
check_and_get_column<ColumnNullable>(column.get())) {
+ nullable = true;
+ if (!combined_nulls) {
+ combined_nulls = ColumnUInt8::create(rows, 0);
+ }
+ const auto& nulls = nullable_column->get_null_map_data();
+ auto& combined = combined_nulls->get_data();
+ for (size_t row = 0; row < combined.size(); ++row) {
+ combined[row] |= nulls[row];
+ }
+ column = nullable_column->get_nested_column_ptr();
+ type = remove_nullable(type);
+ }
+ };
+ for (int32_t child_index : field.source_field_path) {
+ unwrap_nullable();
+ const auto* struct_column =
check_and_get_column<ColumnStruct>(column.get());
+ const auto* struct_type =
check_and_get_data_type<DataTypeStruct>(type.get());
+ if (child_index < 0 || struct_column == nullptr || struct_type ==
nullptr ||
+ static_cast<size_t>(child_index) >= struct_column->tuple_size()) {
+ return Status::InternalError(
+ "Iceberg nested merge partition source does not match
input block");
+ }
+ column =
struct_column->get_column_ptr(static_cast<size_t>(child_index));
+ type = struct_type->get_element(static_cast<size_t>(child_index));
+ }
+ // A nullable parent masks a materialized child value; exchange routing
must match the writer's partition.
+ unwrap_nullable();
+ if (nullable) {
+ column = ColumnNullable::create(column, std::move(combined_nulls));
+ type = make_nullable(type);
+ }
+ std::string name = source->name;
+ *source = {std::move(column), std::move(type), std::move(name)};
+ return Status::OK();
+}
+
Status IcebergInsertPartitionFunction::_compute_hashes_with_transform(
Block* block, std::vector<HashValType>& partitions) const {
const size_t rows = block->rows();
@@ -196,8 +261,13 @@ Status
IcebergInsertPartitionFunction::_compute_hashes_with_transform(
if (_partition_fields[i].transformer == nullptr) {
return Status::InternalError("Merge partitioning transform is not
initialized");
}
+ ColumnWithTypeAndName source = block->get_by_position(results[i]);
+ if (!_partition_fields[i].source_field_path.empty()) {
+ RETURN_IF_ERROR(_nested_partition_source(rows,
_partition_fields[i], &source));
+ }
+ Block source_block({source});
ColumnWithTypeAndName transformed =
- _partition_fields[i].transformer->apply(*block, results[i]);
+ _partition_fields[i].transformer->apply(source_block, 0);
const auto& [column, is_const] = unpack_if_const(transformed.column);
if (is_const) {
// A const column has the same value for all rows in this block,
diff --git a/be/src/format/transformer/iceberg_partition_function.h
b/be/src/format/transformer/iceberg_partition_function.h
index d7698ed78b4..57f255991b0 100644
--- a/be/src/format/transformer/iceberg_partition_function.h
+++ b/be/src/format/transformer/iceberg_partition_function.h
@@ -23,6 +23,7 @@
#include <string>
#include <vector>
+#include "core/block/column_with_type_and_name.h"
#include "exec/partitioner/partitioner.h"
#include "exec/sink/writer/iceberg/partition_transformers.h"
@@ -51,8 +52,11 @@ private:
std::unique_ptr<PartitionColumnTransform> transformer;
int32_t source_id = 0;
std::string name;
+ std::vector<int32_t> source_field_path;
};
+ Status _nested_partition_source(size_t rows, const InsertPartitionField&
field,
+ ColumnWithTypeAndName* source) const;
Status _compute_hashes_with_transform(Block* block,
std::vector<HashValType>& partitions) const;
Status _compute_hashes_with_exprs(Block* block, std::vector<HashValType>&
partitions) const;
Status _clone_expr_ctxs(RuntimeState* state, const VExprContextSPtrs& src,
diff --git
a/be/src/format_v2/table/iceberg_position_delete_sys_table_reader.cpp
b/be/src/format_v2/table/iceberg_position_delete_sys_table_reader.cpp
index b2b37c1bf09..cd84a255e69 100644
--- a/be/src/format_v2/table/iceberg_position_delete_sys_table_reader.cpp
+++ b/be/src/format_v2/table/iceberg_position_delete_sys_table_reader.cpp
@@ -35,6 +35,7 @@
#include "core/types.h"
#include "format/table/iceberg_delete_file_reader_helper.h"
#include "format/table/parquet_utils.h"
+#include "format_v2/table/iceberg_reader.h"
#include "format_v2/table/iceberg_schema_utils.h"
#include "runtime/descriptors.h"
#include "runtime/runtime_state.h"
@@ -147,6 +148,8 @@ protected:
void configure_mapper_options(format::TableColumnMapperOptions* options)
const override {
options->enable_row_lineage_virtual_columns = true;
+ // Position-delete row projection must reject a physically absent
required field exactly like data scans.
+ options->reject_missing_required_field =
supports_iceberg_scan_semantics_v2(_scan_params);
// Parquet may preserve a selected complex wrapper without its own ID;
position-delete row
// projection must use the same descendant-ID fallback as ordinary
Iceberg data scans.
options->allow_idless_complex_wrapper_projection =
@@ -591,6 +594,13 @@ Status
IcebergPositionDeleteSysTableV2Reader::_build_delete_file_projected_colum
columns->push_back(*it);
columns->back().type = column.type;
set_iceberg_delete_field_id(&columns->back());
+ // The copied row tree bypasses
IcebergTableReader::annotate_projected_column, so prepare its
+ // typed nested defaults before the generic inner reader builds
the column mapper.
+ if (supports_iceberg_scan_semantics_v2(_scan_params)) {
+ RETURN_IF_ERROR(prepare_iceberg_initial_default_exprs(
+ &columns->back(),
+ _runtime_state == nullptr ? nullptr :
&_runtime_state->timezone_obj()));
+ }
continue;
}
auto field = build_delete_file_column(column.name, column.type);
diff --git a/be/src/format_v2/table/iceberg_reader.cpp
b/be/src/format_v2/table/iceberg_reader.cpp
index 3c1c098af93..0774c9655b4 100644
--- a/be/src/format_v2/table/iceberg_reader.cpp
+++ b/be/src/format_v2/table/iceberg_reader.cpp
@@ -24,6 +24,7 @@
#include <algorithm>
#include <deque>
+#include <limits>
#include <memory>
#include <sstream>
#include <string_view>
@@ -376,6 +377,28 @@ static Status build_v2_json_map_default(const
format::ColumnDefinition& field,
return Status::OK();
}
+static bool parse_non_finite_default(doris::PrimitiveType type,
std::string_view value,
+ Field* result) {
+ DORIS_CHECK(result != nullptr);
+ if (type != TYPE_FLOAT && type != TYPE_DOUBLE) {
+ return false;
+ }
+ double parsed;
+ if (value == "NaN") {
+ parsed = std::numeric_limits<double>::quiet_NaN();
+ } else if (value == "Infinity") {
+ parsed = std::numeric_limits<double>::infinity();
+ } else if (value == "-Infinity") {
+ parsed = -std::numeric_limits<double>::infinity();
+ } else {
+ return false;
+ }
+ // Iceberg serializes non-finite defaults as strings, which generic Doris
numeric parsers reject.
+ *result = type == TYPE_FLOAT ?
Field::create_field<TYPE_FLOAT>(static_cast<float>(parsed))
+ : Field::create_field<TYPE_DOUBLE>(parsed);
+ return true;
+}
+
static Status build_v2_json_scalar_default(const format::ColumnDefinition&
field,
const DataTypePtr& value_type,
const rapidjson::Value& json_value,
@@ -416,6 +439,9 @@ static Status build_v2_json_scalar_default(const
format::ColumnDefinition& field
*result =
Field::create_field<TYPE_STRING>(std::move(serialized_value));
return Status::OK();
}
+ if (parse_non_finite_default(primitive_type, serialized_value, result)) {
+ return Status::OK();
+ }
RETURN_IF_ERROR(doris::iceberg::detail::normalize_timestamp_for_doris(primitive_type,
timezone,
&serialized_value));
RETURN_IF_ERROR(value_type->get_serde()->from_fe_string(serialized_value,
*result));
@@ -505,6 +531,9 @@ static Status build_v2_initial_default_field(const
format::ColumnDefinition& fie
}
std::string serialized_value = *field.initial_default_value;
+ if (parse_non_finite_default(primitive_type, serialized_value, result)) {
+ return Status::OK();
+ }
RETURN_IF_ERROR(doris::iceberg::detail::normalize_timestamp_for_doris(primitive_type,
timezone,
&serialized_value));
RETURN_IF_ERROR(value_type->get_serde()->from_fe_string(serialized_value,
*result));
@@ -526,8 +555,8 @@ static Status build_initial_default_literal(const
format::ColumnDefinition& tabl
return Status::OK();
}
-static Status build_initial_default_exprs(format::ColumnDefinition* column,
- const cctz::time_zone* timezone) {
+Status prepare_iceberg_initial_default_exprs(format::ColumnDefinition* column,
+ const cctz::time_zone* timezone) {
DORIS_CHECK(column != nullptr);
if (column->initial_default_value.has_value()) {
VExprSPtr literal;
@@ -535,7 +564,7 @@ static Status
build_initial_default_exprs(format::ColumnDefinition* column,
column->default_expr = VExprContext::create_shared(std::move(literal));
}
for (auto& child : column->children) {
- RETURN_IF_ERROR(build_initial_default_exprs(&child, timezone));
+ RETURN_IF_ERROR(prepare_iceberg_initial_default_exprs(&child,
timezone));
}
return Status::OK();
}
@@ -850,7 +879,7 @@ Status IcebergTableReader::annotate_projected_column(const
TFileScanSlotInfo& sl
auto& schema_column = *context->schema_column;
const cctz::time_zone* timezone =
context->runtime_state == nullptr ? nullptr :
&context->runtime_state->timezone_obj();
- RETURN_IF_ERROR(build_initial_default_exprs(&schema_column, timezone));
+ RETURN_IF_ERROR(prepare_iceberg_initial_default_exprs(&schema_column,
timezone));
column->initial_default_value = schema_column.initial_default_value;
column->initial_default_value_is_base64 =
schema_column.initial_default_value_is_base64;
column->is_optional = schema_column.is_optional;
diff --git a/be/src/format_v2/table/iceberg_reader.h
b/be/src/format_v2/table/iceberg_reader.h
index 21609b9866f..4000c173bff 100644
--- a/be/src/format_v2/table/iceberg_reader.h
+++ b/be/src/format_v2/table/iceberg_reader.h
@@ -42,6 +42,10 @@ struct FileSystemProperties;
namespace doris::format::iceberg {
+// Position-delete row projection also needs the typed defaults used by
ordinary data scans.
+Status prepare_iceberg_initial_default_exprs(format::ColumnDefinition* column,
+ const cctz::time_zone* timezone =
nullptr);
+
// Iceberg table-level reader.
// It reuses TableReader for split orchestration, dynamic partition pruning
and table-block
// finalization, while composing a FileReader for physical data-file reads
instead of inheriting
diff --git a/be/test/core/value/merge_partitioner_test.cpp
b/be/test/core/value/merge_partitioner_test.cpp
index 0836c6f8753..094c635a2a4 100644
--- a/be/test/core/value/merge_partitioner_test.cpp
+++ b/be/test/core/value/merge_partitioner_test.cpp
@@ -83,6 +83,41 @@ protected:
return expr;
}
+ TTypeDesc _nested_int_struct_type_desc() {
+ TTypeNode struct_node;
+ struct_node.__set_type(TTypeNodeType::STRUCT);
+ TStructField child;
+ child.__set_name("part");
+ child.__set_contains_null(true);
+ struct_node.__set_struct_fields({child});
+
+ TTypeNode int_node;
+ int_node.__set_type(TTypeNodeType::SCALAR);
+ TScalarType scalar;
+ scalar.__set_type(TPrimitiveType::INT);
+ int_node.__set_scalar_type(scalar);
+
+ TTypeDesc type_desc;
+ type_desc.__set_types({struct_node, int_node});
+ type_desc.__set_is_nullable(true);
+ return type_desc;
+ }
+
+ TExpr _make_nested_source_expr() {
+ TExprNode node;
+ node.__set_node_type(TExprNodeType::SLOT_REF);
+ node.__set_num_children(0);
+ TSlotRef slot_ref;
+ slot_ref.__set_slot_id(_nested_source_slot_id);
+ slot_ref.__set_tuple_id(_tuple_id);
+ node.__set_slot_ref(slot_ref);
+ node.__set_type(_nested_int_struct_type_desc());
+ node.__set_is_nullable(true);
+ TExpr expr;
+ expr.nodes.emplace_back(std::move(node));
+ return expr;
+ }
+
TMergePartitionInfo _make_base_merge_info(bool insert_random) {
TMergePartitionInfo merge_info;
merge_info.__set_operation_expr(
@@ -180,6 +215,13 @@ protected:
.column_name("delete_key")
.column_pos(4)
.build());
+ TTypeDesc nested_type = _nested_int_struct_type_desc();
+ tuple_builder.add_slot(TSlotDescriptorBuilder()
+ .set_slotType(nested_type)
+ .nullable(true)
+ .column_name("nested_source")
+ .column_pos(5)
+ .build());
tuple_builder.build(&dtb);
TDescriptorTable thrift_tbl = dtb.desc_tbl();
@@ -205,11 +247,13 @@ protected:
_row_id_slot_id = find_slot_id("row_id");
_insert_key_slot_id = find_slot_id("insert_key");
_delete_key_slot_id = find_slot_id("delete_key");
+ _nested_source_slot_id = find_slot_id("nested_source");
ASSERT_GE(_operation_slot_id, 0);
ASSERT_GE(_row_id_slot_id, 0);
ASSERT_GE(_insert_key_slot_id, 0);
ASSERT_GE(_delete_key_slot_id, 0);
+ ASSERT_GE(_nested_source_slot_id, 0);
}
ObjectPool _pool;
@@ -220,6 +264,7 @@ protected:
TSlotId _row_id_slot_id = -1;
TSlotId _insert_key_slot_id = -1;
TSlotId _delete_key_slot_id = -1;
+ TSlotId _nested_source_slot_id = -1;
};
TEST_F(MergePartitionerTest, TestInsertDeleteUpdatePartitioning) {
@@ -318,6 +363,52 @@ TEST_F(MergePartitionerTest,
TestInsertPartitionFieldsIdentity) {
ASSERT_TRUE(partitioner.close(&_state).ok());
}
+TEST_F(MergePartitionerTest,
TestNestedInsertPartitionFieldPreservesParentNulls) {
+ ScopedConfigValue<int32_t> max_partition_guard(
+ config::table_sink_partition_write_max_partition_nums_per_writer,
0);
+
+ TMergePartitionInfo merge_info = _make_base_merge_info(false);
+ TIcebergPartitionField field;
+ field.__set_transform("bucket[8]");
+ field.__set_source_expr(_make_nested_source_expr());
+ field.__set_name("payload_part");
+ field.__set_source_id(3);
+ field.__set_source_field_path({0});
+ merge_info.__set_insert_partition_fields({field});
+
+ MergePartitioner partitioner(8, merge_info, false);
+ ASSERT_TRUE(partitioner.init({}).ok());
+ ASSERT_TRUE(partitioner.prepare(&_state, *_row_desc).ok());
+ ASSERT_TRUE(partitioner.open(&_state).ok());
+
+ Block block = _build_block({1, 1, 1, 1}, {"p1", "p2", "p3", "p4"}, {1, 2,
3, 4},
+ {10, 11, 12, 13}, {"d1", "d2", "d3", "d4"});
+ auto values = ColumnInt32::create();
+ values->insert_value(9);
+ values->insert_value(9);
+ values->insert_value(7);
+ values->insert_value(8);
+ auto child_nulls = ColumnUInt8::create(4, 0);
+ ColumnPtr child = ColumnNullable::create(std::move(values),
std::move(child_nulls));
+ auto struct_column = ColumnStruct::create(Columns {std::move(child)});
+ auto parent_nulls = ColumnUInt8::create();
+ parent_nulls->get_data().assign({0, 0, 1, 1});
+ DataTypePtr child_type = make_nullable(std::make_shared<DataTypeInt32>());
+ DataTypePtr struct_type =
+ std::make_shared<DataTypeStruct>(DataTypes {child_type}, Strings
{"part"});
+ block.insert(ColumnWithTypeAndName(
+ ColumnNullable::create(std::move(struct_column),
std::move(parent_nulls)),
+ make_nullable(struct_type), "nested_source"));
+
+ ASSERT_TRUE(partitioner.do_partitioning(&_state, &block).ok());
+ const auto& channel_ids = partitioner.get_channel_ids();
+ ASSERT_EQ(4, channel_ids.size());
+ EXPECT_EQ(channel_ids[0], channel_ids[1]);
+ EXPECT_EQ(channel_ids[2], channel_ids[3]);
+
+ ASSERT_TRUE(partitioner.close(&_state).ok());
+}
+
TEST_F(MergePartitionerTest, TestInvalidTransformFallbacksToRandom) {
ScopedConfigValue<int64_t> threshold_guard(
config::table_sink_non_partition_write_scaling_data_processed_threshold, 0);
diff --git a/be/test/exec/sink/writer/iceberg/viceberg_table_writer_test.cpp
b/be/test/exec/sink/writer/iceberg/viceberg_table_writer_test.cpp
index 849b55b2437..1bbbe5454af 100644
--- a/be/test/exec/sink/writer/iceberg/viceberg_table_writer_test.cpp
+++ b/be/test/exec/sink/writer/iceberg/viceberg_table_writer_test.cpp
@@ -22,11 +22,19 @@
#include <thrift/transport/TBufferTransports.h>
#include "common/exception.h"
+#include "core/block/block.h"
+#include "core/column/column_nullable.h"
+#include "core/column/column_struct.h"
+#include "core/column/column_vector.h"
#include "core/data_type/data_type_nullable.h"
+#include "core/data_type/data_type_number.h"
+#include "core/data_type/data_type_struct.h"
#include "core/data_type/data_type_timestamptz.h"
#include "core/data_type/data_type_varbinary.h"
#include "exec/sink/writer/iceberg/partition_transformers.h"
#include "exec/sink/writer/iceberg/vpartition_writer_base.h"
+#include "exprs/vexpr_context.h"
+#include "exprs/vslot_ref.h"
#include "format/table/iceberg/partition_spec_parser.h"
#include "format/table/iceberg/schema.h"
#include "format/table/iceberg/types.h"
@@ -74,8 +82,10 @@ TEST(VIcebergTableWriterTest,
TimestampIdentityPreservesUtcCommitAndNull) {
state.set_timezone("America/New_York");
writer._state = &state;
writer._schema = schema;
+ writer._partition_spec = std::move(spec);
+ // Retain the real spec for static validation; top-level sources have no
nested child path.
writer._iceberg_partition_columns.emplace_back(
- spec->fields()[0], TYPE_TIMESTAMPTZ, 0,
+ writer._partition_spec->fields()[0], TYPE_TIMESTAMPTZ, 0,
std::vector<size_t> {},
std::make_unique<IdentityPartitionColumnTransform>(type));
auto column = ColumnTimeStampTz::create();
TimestampTzValue first;
@@ -150,9 +160,10 @@ TEST(VIcebergTableWriterTest,
StaticNullBinaryPartitionsKeepNullPathsAndCommitVa
VExprContextSPtrs exprs;
VIcebergTableWriter writer(data_sink, exprs);
writer._schema = schema;
+ writer._partition_spec = std::move(spec);
auto type = make_nullable(std::make_shared<DataTypeVarbinary>());
writer._iceberg_partition_columns.emplace_back(
- spec->fields()[0], TYPE_VARBINARY, 0,
+ writer._partition_spec->fields()[0], TYPE_VARBINARY, 0,
std::vector<size_t> {},
std::make_unique<IdentityPartitionColumnTransform>(type));
ASSERT_NO_THROW(writer._init_static_partition_values());
EXPECT_TRUE(writer._is_full_static_partition);
@@ -172,7 +183,7 @@ TEST(VIcebergTableWriterTest,
StaticNullBinaryPartitionsKeepNullPathsAndCommitVa
// Partition columns retain a reference to their spec field throughout
dispatch.
iceberg::PartitionField dynamic_field(2, 1001, "id", "identity");
writer._iceberg_partition_columns.emplace_back(
- dynamic_field, TYPE_INT, 1,
+ dynamic_field, TYPE_INT, 1, std::vector<size_t> {},
std::make_unique<IdentityPartitionColumnTransform>(
std::make_shared<DataTypeInt32>()));
ASSERT_NO_THROW(writer._init_static_partition_values());
@@ -214,8 +225,9 @@ TEST(VIcebergTableWriterTest,
BinaryIdentityStaticAndDynamicRouting) {
VExprContextSPtrs exprs;
VIcebergTableWriter writer(data_sink, exprs);
writer._schema = schema;
+ writer._partition_spec = std::move(spec);
writer._iceberg_partition_columns.emplace_back(
- spec->fields()[0], TYPE_VARBINARY, 0,
+ writer._partition_spec->fields()[0], TYPE_VARBINARY, 0,
std::vector<size_t> {},
std::make_unique<IdentityPartitionColumnTransform>(type));
auto column = type->create_column();
const std::string bytes("\xDE\xAD", 2);
@@ -239,7 +251,7 @@ TEST(VIcebergTableWriterTest,
BinaryIdentityStaticAndDynamicRouting) {
// Add a dynamic field to exercise hybrid routing with the same static
binary value.
iceberg::PartitionField dynamic_field(2, 1001, "id", "identity");
writer._iceberg_partition_columns.emplace_back(
- dynamic_field, TYPE_INT, 1,
+ dynamic_field, TYPE_INT, 1, std::vector<size_t> {},
std::make_unique<IdentityPartitionColumnTransform>(std::make_shared<DataTypeInt32>()));
writer._init_static_partition_values();
EXPECT_FALSE(writer._is_full_static_partition);
@@ -288,8 +300,9 @@ TEST(VIcebergTableWriterTest,
UuidStaticPartitionAcceptsCanonicalText) {
VExprContextSPtrs exprs;
VIcebergTableWriter writer(data_sink, exprs);
writer._schema = schema;
+ writer._partition_spec = std::move(spec);
writer._iceberg_partition_columns.emplace_back(
- spec->fields()[0], TYPE_VARBINARY, 0,
+ writer._partition_spec->fields()[0], TYPE_VARBINARY, 0,
std::vector<size_t> {},
std::make_unique<IdentityPartitionColumnTransform>(
std::make_shared<DataTypeVarbinary>()));
writer._init_static_partition_values();
@@ -300,4 +313,62 @@ TEST(VIcebergTableWriterTest,
UuidStaticPartitionAcceptsCanonicalText) {
EXPECT_THROW(writer._init_static_partition_values(), Exception);
}
+TEST(VIcebergTableWriterTest, ResolvesNestedPartitionSource) {
+ std::vector<iceberg::NestedField> children;
+ children.emplace_back(true, 2, "part",
std::make_unique<iceberg::IntegerType>(), std::nullopt);
+ std::vector<iceberg::NestedField> columns;
+ columns.emplace_back(true, 1, "payload",
+
std::make_unique<iceberg::StructType>(std::move(children)), std::nullopt);
+ auto schema = std::make_shared<iceberg::Schema>(std::move(columns));
+ const std::string spec_json =
R"({"spec-id":1,"fields":[{"name":"part","transform":"identity",)"
+ R"("source-id":2,"field-id":1000}]})";
+ auto child_type = make_nullable(std::make_shared<DataTypeInt32>());
+ auto struct_type = make_nullable(
+ std::make_shared<DataTypeStruct>(DataTypes {child_type}, Strings
{"part"}));
+ VExprContextSPtrs output_exprs {
+ VExprContext::create_shared(VSlotRef::create_shared(0, 0, -1,
struct_type, "payload"))};
+
+ TIcebergTableSink iceberg_sink;
+ TDataSink data_sink;
+ data_sink.__set_iceberg_table_sink(iceberg_sink);
+ VIcebergTableWriter writer(data_sink, output_exprs);
+ writer._schema = schema;
+ writer._partition_spec = iceberg::PartitionSpecParser::from_json(schema,
spec_json);
+
+ auto partition_columns = writer._to_iceberg_partition_columns();
+ ASSERT_EQ(partition_columns.size(), 1);
+ EXPECT_EQ(partition_columns[0].source_idx(), 0);
+ EXPECT_EQ(partition_columns[0].child_indices(), std::vector<size_t>({0}));
+ EXPECT_EQ(partition_columns[0].source_type(), TYPE_INT);
+
+ auto child_data = ColumnInt32::create();
+ child_data->insert_value(7);
+ child_data->insert_value(8);
+ auto child_nulls = ColumnUInt8::create(2, 0);
+ auto child_column = ColumnNullable::create(std::move(child_data),
std::move(child_nulls));
+ Columns children_columns {std::move(child_column)};
+ auto struct_column = ColumnStruct::create(std::move(children_columns));
+ auto parent_nulls = ColumnUInt8::create(2, 0);
+ parent_nulls->get_data()[1] = 1;
+ auto parent_column = ColumnNullable::create(std::move(struct_column),
std::move(parent_nulls));
+ Block block;
+ block.insert({std::move(parent_column), struct_type, "payload"});
+
+ auto source = writer._nested_partition_source(block, partition_columns[0]);
+ const auto* nullable_source =
check_and_get_column<ColumnNullable>(source.column.get());
+ ASSERT_NE(nullable_source, nullptr);
+ ASSERT_EQ(nullable_source->size(), 2);
+ EXPECT_EQ(nullable_source->get_null_map_data()[0], 0);
+ EXPECT_EQ(nullable_source->get_null_map_data()[1], 1);
+ const auto* source_data =
+
check_and_get_column<ColumnInt32>(nullable_source->get_nested_column_ptr().get());
+ ASSERT_NE(source_data, nullptr);
+ EXPECT_EQ(source_data->get_data()[0], 7);
+ EXPECT_EQ(source_data->get_data()[1], 8);
+
+ // A static label must not override the nested leaf actually stored in the
row.
+ writer._t_sink.iceberg_table_sink.__set_static_partition_values({{"part",
"9"}});
+ EXPECT_THROW(writer._init_static_partition_values(), Exception);
+}
+
} // namespace doris
diff --git a/be/test/format/table/iceberg/schema_test.cpp
b/be/test/format/table/iceberg/schema_test.cpp
index bccf0f1f341..b6e56b38ced 100644
--- a/be/test/format/table/iceberg/schema_test.cpp
+++ b/be/test/format/table/iceberg/schema_test.cpp
@@ -66,5 +66,21 @@ TEST(SchemaTest, test_find_field) {
EXPECT_EQ(found_field2->field_id(), 2);
}
+TEST(SchemaTest, FindNestedFieldPath) {
+ std::vector<NestedField> children;
+ children.emplace_back(true, 2, "part", std::make_unique<IntegerType>(),
std::nullopt);
+ std::vector<NestedField> columns;
+ columns.emplace_back(true, 1, "payload",
std::make_unique<StructType>(std::move(children)),
+ std::nullopt);
+ Schema schema(1, std::move(columns));
+
+ const auto* path = schema.find_field_path(2);
+ ASSERT_NE(path, nullptr);
+ ASSERT_EQ(path->size(), 2);
+ EXPECT_EQ((*path)[0]->field_id(), 1);
+ EXPECT_EQ((*path)[1]->field_id(), 2);
+ EXPECT_EQ(schema.find_type(2)->type_id(), TypeID::INTEGER);
+}
+
} // namespace iceberg
} // namespace doris
diff --git
a/be/test/format_v2/table/iceberg_position_delete_sys_table_reader_test.cpp
b/be/test/format_v2/table/iceberg_position_delete_sys_table_reader_test.cpp
index a219ba37f3d..8cf538eb0cf 100644
--- a/be/test/format_v2/table/iceberg_position_delete_sys_table_reader_test.cpp
+++ b/be/test/format_v2/table/iceberg_position_delete_sys_table_reader_test.cpp
@@ -19,6 +19,9 @@
#include <gtest/gtest.h>
+#include "core/data_type/data_type_number.h"
+#include "core/data_type/data_type_struct.h"
+#include "format/table/iceberg_scan_semantics.h"
#include "runtime/runtime_profile.h"
#include "runtime/runtime_state.h"
@@ -59,5 +62,31 @@ TEST(IcebergPositionDeleteSysTableV2ProfileTest,
UsesDistinctProfileForNestedPos
reader._position_reader_profile);
}
+TEST(IcebergPositionDeleteSysTableV2ProfileTest,
PreparesNestedRowInitialDefaults) {
+ IcebergPositionDeleteSysTableV2Reader reader;
+ TFileScanRangeParams params;
+
params.__set_iceberg_scan_semantics_version(ICEBERG_SCAN_SEMANTICS_VERSION_2);
+ reader._scan_params = ¶ms;
+ const auto child_type = make_nullable(std::make_shared<DataTypeInt32>());
+ const auto row_type = make_nullable(
+ std::make_shared<DataTypeStruct>(DataTypes {child_type}, Strings
{"added"}));
+ ColumnDefinition row;
+ row.name = "row";
+ row.type = row_type;
+ ColumnDefinition child;
+ child.name = "added";
+ child.type = child_type;
+ child.initial_default_value = "7";
+ row.children.push_back(std::move(child));
+ reader._projected_columns = {row};
+ reader._read_columns = {{"row", row_type}};
+
+ std::vector<ColumnDefinition> columns;
+ ASSERT_TRUE(reader._build_delete_file_projected_columns(&columns).ok());
+ ASSERT_EQ(columns.size(), 1);
+ ASSERT_EQ(columns[0].children.size(), 1);
+ EXPECT_NE(columns[0].children[0].default_expr, nullptr);
+}
+
} // namespace
} // namespace doris::format::iceberg
diff --git a/be/test/format_v2/table/iceberg_reader_test.cpp
b/be/test/format_v2/table/iceberg_reader_test.cpp
index 234531335af..dbf8109f40f 100644
--- a/be/test/format_v2/table/iceberg_reader_test.cpp
+++ b/be/test/format_v2/table/iceberg_reader_test.cpp
@@ -28,6 +28,7 @@
#include <array>
#include <bit>
#include <chrono>
+#include <cmath>
#include <cstring>
#include <filesystem>
#include <fstream>
@@ -1553,6 +1554,38 @@ void apply_final_conjuncts(Block* block, const
VExprContextSPtrs& conjuncts) {
ASSERT_TRUE(status.ok()) << status;
}
+TEST(IcebergV2ReaderTest, PreparesIcebergNonFiniteInitialDefaults) {
+ struct Case {
+ DataTypePtr type;
+ std::string value;
+ bool nan;
+ bool negative;
+ };
+ std::vector<Case> cases {{std::make_shared<DataTypeFloat32>(), "NaN",
true, false},
+ {std::make_shared<DataTypeFloat64>(), "Infinity",
false, false},
+ {std::make_shared<DataTypeFloat64>(),
"-Infinity", false, true}};
+ for (const auto& test_case : cases) {
+ ColumnDefinition column;
+ column.name = "value";
+ column.type = test_case.type;
+ column.initial_default_value = test_case.value;
+
ASSERT_TRUE(iceberg::prepare_iceberg_initial_default_exprs(&column).ok());
+ ASSERT_NE(column.default_expr, nullptr);
+ const auto* literal = dynamic_cast<const
VLiteral*>(column.default_expr->root().get());
+ ASSERT_NE(literal, nullptr);
+ Field value;
+ literal->get_column_ptr()->get(0, value);
+ const double number = test_case.type->get_primitive_type() ==
TYPE_FLOAT
+ ? value.get<TYPE_FLOAT>()
+ : value.get<TYPE_DOUBLE>();
+ EXPECT_EQ(std::isnan(number), test_case.nan);
+ if (!test_case.nan) {
+ EXPECT_TRUE(std::isinf(number));
+ EXPECT_EQ(std::signbit(number), test_case.negative);
+ }
+ }
+}
+
TEST(IcebergV2ReaderTest, AnnotateBuildsTypedNestedInitialDefault) {
const auto int_type = make_nullable(std::make_shared<DataTypeInt32>());
const auto struct_type = make_nullable(
diff --git
a/fe/fe-core/src/main/java/org/apache/doris/datasource/iceberg/IcebergTransaction.java
b/fe/fe-core/src/main/java/org/apache/doris/datasource/iceberg/IcebergTransaction.java
index 435d8af7d5d..444798b936a 100644
---
a/fe/fe-core/src/main/java/org/apache/doris/datasource/iceberg/IcebergTransaction.java
+++
b/fe/fe-core/src/main/java/org/apache/doris/datasource/iceberg/IcebergTransaction.java
@@ -1019,14 +1019,18 @@ public class IcebergTransaction implements Transaction {
if (sourceField == null) {
return null;
}
+ String sourceColumnName = identitySourceColumnName(schema,
field.sourceId());
+ if (sourceColumnName == null) {
+ return null;
+ }
String valueStr = partitionValues.get(i);
if ("null".equals(valueStr)) {
valueStr = null;
}
Object value =
IcebergUtils.parsePartitionValueFromString(valueStr, sourceField.type());
Expression predicate = value == null
- ? Expressions.isNull(sourceField.name())
- : Expressions.equal(sourceField.name(), value);
+ ? Expressions.isNull(sourceColumnName)
+ : Expressions.equal(sourceColumnName, value);
expression = expression == null ? predicate :
Expressions.and(expression, predicate);
}
return expression;
@@ -1232,14 +1236,18 @@ public class IcebergTransaction implements Transaction {
throw new RuntimeException(String.format("Source field not
found for partition field: %s",
partitionColName));
}
+ String sourceColName = identitySourceColumnName(schema,
field.sourceId());
+ if (sourceColName == null) {
+ throw new RuntimeException(String.format(
+ "Source column path not found for partition field:
%s", partitionColName));
+ }
// Convert partition value string to appropriate type
Object partitionValue =
IcebergUtils.parsePartitionValueFromString(
partitionValueStr, sourceField.type());
- // Build equality expression using source field name (not
partition field name)
- // For identity partitions, Iceberg requires the source column
name in expressions
- String sourceColName = sourceField.name();
+ // Build equality expression using the source column path (not
partition field name).
+ // For identity partitions, Iceberg requires the source column
in expressions.
Expression eqExpr;
if (partitionValue == null) {
eqExpr = Expressions.isNull(sourceColName);
@@ -1268,4 +1276,10 @@ public class IcebergTransaction implements Transaction {
}
return result;
}
+
+ private String identitySourceColumnName(Schema schema, int sourceId) {
+ // Nested identity predicates must bind to the full path when sibling
structs reuse a leaf name.
+ return schema.findColumnName(sourceId);
+ }
+
}
diff --git
a/fe/fe-core/src/main/java/org/apache/doris/datasource/iceberg/IcebergWriteSchemaContext.java
b/fe/fe-core/src/main/java/org/apache/doris/datasource/iceberg/IcebergWriteSchemaContext.java
index 3437f26c4cf..8b0aebb2e0b 100644
---
a/fe/fe-core/src/main/java/org/apache/doris/datasource/iceberg/IcebergWriteSchemaContext.java
+++
b/fe/fe-core/src/main/java/org/apache/doris/datasource/iceberg/IcebergWriteSchemaContext.java
@@ -307,13 +307,12 @@ public final class IcebergWriteSchemaContext {
private static void validateWriterMetadataSources(
Schema schema, PartitionSpec partitionSpec, SortOrder sortOrder,
String tableName) {
- Map<Integer, Types.NestedField> topLevelFields =
schema.columns().stream()
-
.collect(ImmutableMap.toImmutableMap(Types.NestedField::fieldId, field ->
field));
for (PartitionField field : partitionSpec.fields()) {
- if (!topLevelFields.containsKey(field.sourceId())) {
+ // Iceberg permits a nested primitive partition source; its ID is
schema-wide.
+ if (schema.findField(field.sourceId()) == null) {
throw new AnalysisException("Iceberg partition field " +
field.fieldId()
+ " references source field " + field.sourceId()
- + " outside pinned top-level schema " +
schema.schemaId()
+ + " outside pinned schema " + schema.schemaId()
+ " for table " + tableName);
}
}
diff --git
a/fe/fe-core/src/main/java/org/apache/doris/nereids/StatementContext.java
b/fe/fe-core/src/main/java/org/apache/doris/nereids/StatementContext.java
index 0a182fee34e..10be865bc73 100644
--- a/fe/fe-core/src/main/java/org/apache/doris/nereids/StatementContext.java
+++ b/fe/fe-core/src/main/java/org/apache/doris/nereids/StatementContext.java
@@ -278,6 +278,7 @@ public class StatementContext implements Closeable {
private Backend groupCommitMergeBackend;
private final Map<MvccTableInfo, MvccSnapshot> snapshots =
Maps.newHashMap();
+ private final Map<MvccTableInfo, MvccSnapshot> injectedSnapshots =
Maps.newHashMap();
private final Map<MvccTableInfo, MvccSnapshot> latestSnapshots =
Maps.newHashMap();
private final Map<MvccTableInfo, MvccSnapshot> latestSnapshotFences =
Maps.newHashMap();
private final Map<MvccTableInfo, Map<String, String>>
resolvedSnapshotScanParams = Maps.newHashMap();
@@ -1146,6 +1147,7 @@ public class StatementContext implements Closeable {
* to one EXECUTE only; carrying it forward would make a later commit
permanently invisible.
*/
public void resetMvccSnapshots() {
+ injectedSnapshots.clear();
snapshots.clear();
latestSnapshots.clear();
latestSnapshotFences.clear();
@@ -1160,6 +1162,14 @@ public class StatementContext implements Closeable {
externalMetadataPreloadResult = null;
}
+ public void resetMvccSnapshotsForRetry() {
+ // MTMV refresh partitions and rewrite inputs were selected against
these task-owned fences.
+ // Only planner-loaded metadata may advance when the same execution
retries planning.
+ Map<MvccTableInfo, MvccSnapshot> preservedSnapshots =
Maps.newHashMap(injectedSnapshots);
+ resetMvccSnapshots();
+ preservedSnapshots.forEach(this::setSnapshot);
+ }
+
/**
* Obtain snapshot information of mvcc
*
@@ -1247,6 +1257,7 @@ public class StatementContext implements Closeable {
* @param snapshot snapshot
*/
public void setSnapshot(MvccTableInfo mvccTableInfo, MvccSnapshot
snapshot) {
+ injectedSnapshots.put(mvccTableInfo, snapshot);
// Injected snapshots are execution fences (for example MTMV refresh);
an unqualified
// relation must reuse that fence instead of treating the
latest-snapshot cache as empty.
latestSnapshots.put(mvccTableInfo, snapshot);
diff --git
a/fe/fe-core/src/main/java/org/apache/doris/nereids/glue/translator/PhysicalPlanTranslator.java
b/fe/fe-core/src/main/java/org/apache/doris/nereids/glue/translator/PhysicalPlanTranslator.java
index be66a89526c..527442b6363 100644
---
a/fe/fe-core/src/main/java/org/apache/doris/nereids/glue/translator/PhysicalPlanTranslator.java
+++
b/fe/fe-core/src/main/java/org/apache/doris/nereids/glue/translator/PhysicalPlanTranslator.java
@@ -3427,7 +3427,8 @@ public class PhysicalPlanTranslator extends
DefaultPlanVisitor<PlanFragment, Pla
field.getTransform(),
field.getParam(),
field.getName(),
- field.getSourceId()));
+ field.getSourceId(),
+ field.getSourceFieldPath()));
}
return new DataPartition(TPartitionType.MERGE_PARTITIONED,
operationExpr,
insertPartitionExprs, deletePartitionExprs,
mergeSpec.isInsertRandom(),
diff --git
a/fe/fe-core/src/main/java/org/apache/doris/nereids/properties/DistributionSpecMerge.java
b/fe/fe-core/src/main/java/org/apache/doris/nereids/properties/DistributionSpecMerge.java
index c6ea4e37a8b..63935cb8f2d 100644
---
a/fe/fe-core/src/main/java/org/apache/doris/nereids/properties/DistributionSpecMerge.java
+++
b/fe/fe-core/src/main/java/org/apache/doris/nereids/properties/DistributionSpecMerge.java
@@ -38,17 +38,25 @@ public class DistributionSpecMerge extends DistributionSpec
{
private final Integer param;
private final String name;
private final Integer sourceId;
+ private final ImmutableList<Integer> sourceFieldPath;
/**
* Create a partition field mapping for merge insert routing.
*/
public IcebergPartitionField(String transform, ExprId sourceExprId,
Integer param,
String name, Integer sourceId) {
+ this(transform, sourceExprId, param, name, sourceId,
ImmutableList.of());
+ }
+
+ /** Create a partition field mapping whose source is nested below a
top-level slot. */
+ public IcebergPartitionField(String transform, ExprId sourceExprId,
Integer param,
+ String name, Integer sourceId, List<Integer> sourceFieldPath) {
this.transform = Objects.requireNonNull(transform, "transform
should not be null");
this.sourceExprId = Objects.requireNonNull(sourceExprId,
"sourceExprId should not be null");
this.param = param;
this.name = name;
this.sourceId = sourceId;
+ this.sourceFieldPath = ImmutableList.copyOf(sourceFieldPath);
}
public String getTransform() {
@@ -71,6 +79,10 @@ public class DistributionSpecMerge extends DistributionSpec {
return sourceId;
}
+ public List<Integer> getSourceFieldPath() {
+ return sourceFieldPath;
+ }
+
@Override
public boolean equals(Object o) {
if (this == o) {
@@ -84,12 +96,13 @@ public class DistributionSpecMerge extends DistributionSpec
{
&& sourceExprId.equals(that.sourceExprId)
&& Objects.equals(param, that.param)
&& Objects.equals(name, that.name)
- && Objects.equals(sourceId, that.sourceId);
+ && Objects.equals(sourceId, that.sourceId)
+ && sourceFieldPath.equals(that.sourceFieldPath);
}
@Override
public int hashCode() {
- return Objects.hash(transform, sourceExprId, param, name,
sourceId);
+ return Objects.hash(transform, sourceExprId, param, name,
sourceId, sourceFieldPath);
}
}
diff --git
a/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/commands/insert/InsertIntoTableCommand.java
b/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/commands/insert/InsertIntoTableCommand.java
index 20f21f5b8b2..97c58141256 100644
---
a/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/commands/insert/InsertIntoTableCommand.java
+++
b/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/commands/insert/InsertIntoTableCommand.java
@@ -264,6 +264,7 @@ public class InsertIntoTableCommand extends Command
implements NeedAuditEncrypti
int retryTimes = 0;
ctx.getStatementContext().setIsInsert(true);
while (++retryTimes <
Math.max(ctx.getSessionVariable().dmlPlanRetryTimes, 3)) {
+ prepareMvccSnapshotsForPlanAttempt(ctx.getStatementContext(),
retryTimes);
TableIf targetTableIf = getTargetTableIf(ctx,
qualifiedTargetTableName);
DatabaseIf<?> targetDatabase = getTargetDatabase(targetTableIf);
// check auth
@@ -341,6 +342,13 @@ public class InsertIntoTableCommand extends Command
implements NeedAuditEncrypti
throw new AnalysisException("Insert plan failed. Could not get target
table lock.");
}
+ static void prepareMvccSnapshotsForPlanAttempt(StatementContext
statementContext, int attempt) {
+ if (attempt > 1) {
+ // Refresh planner metadata while retaining the source fences
selected by the caller.
+ statementContext.resetMvccSnapshotsForRetry();
+ }
+ }
+
/**
* Hook method to determine if auth check is needed.
* Subclasses can override this to skip auth check, e.g.,
WarmupSelectCommand.
diff --git
a/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/physical/PhysicalIcebergMergeSink.java
b/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/physical/PhysicalIcebergMergeSink.java
index 85748b4ce18..d20e948292d 100644
---
a/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/physical/PhysicalIcebergMergeSink.java
+++
b/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/physical/PhysicalIcebergMergeSink.java
@@ -21,6 +21,7 @@ import org.apache.doris.catalog.Column;
import org.apache.doris.datasource.iceberg.IcebergExternalDatabase;
import org.apache.doris.datasource.iceberg.IcebergExternalTable;
import org.apache.doris.datasource.iceberg.IcebergMergeOperation;
+import org.apache.doris.datasource.iceberg.IcebergUtils;
import org.apache.doris.datasource.iceberg.IcebergWriteSchemaContext;
import org.apache.doris.nereids.memo.GroupExpression;
import org.apache.doris.nereids.properties.DistributionSpecMerge;
@@ -273,12 +274,13 @@ public class PhysicalIcebergMergeSink<CHILD_TYPE extends
Plan> extends PhysicalB
.orElse(targetIcebergTable.schema());
List<Column> partitionColumns =
getRetainedPartitionColumns(partitionSpec, partitionSchema);
Map<String, ExprId> columnExprIdMap =
buildColumnExprIdMap(outputSlots, nameToExprId);
+ Map<Integer, ExprId> columnIdToExprId =
buildColumnIdExprIdMap(outputSlots);
boolean insertExprsOk = false;
if (!partitionColumns.isEmpty()) {
insertExprsOk =
buildInsertPartitionExprIds(insertPartitionExprIds, partitionColumns,
columnExprIdMap);
}
InsertPartitionFieldResult fieldResult = buildInsertPartitionFields(
- insertPartitionFields, partitionSpec, partitionSchema,
columnExprIdMap);
+ insertPartitionFields, partitionSpec, partitionSchema,
columnIdToExprId);
boolean insertFieldsOk = fieldResult.success;
boolean hasNonIdentity = fieldResult.hasNonIdentity;
if (insertFieldsOk) {
@@ -344,7 +346,9 @@ public class PhysicalIcebergMergeSink<CHILD_TYPE extends
Plan> extends PhysicalB
if (IcebergMergeOperation.OPERATION_COLUMN.equalsIgnoreCase(name))
{
continue;
}
- if (Column.ICEBERG_ROWID_COL.equalsIgnoreCase(name)) {
+ // V3 lineage slots have no visible schema column and must not
shift positional binding.
+ if (Column.ICEBERG_ROWID_COL.equalsIgnoreCase(name)
+ || IcebergUtils.isIcebergRowLineageColumn(name)) {
continue;
}
dataSlots.add(slot);
@@ -356,7 +360,7 @@ public class PhysicalIcebergMergeSink<CHILD_TYPE extends
Plan> extends PhysicalB
List<DistributionSpecMerge.IcebergPartitionField>
insertPartitionFields,
PartitionSpec spec,
Schema schema,
- Map<String, ExprId> columnExprIdMap) {
+ Map<Integer, ExprId> columnIdToExprId) {
if (spec == null || !spec.isPartitioned()) {
return new InsertPartitionFieldResult(false, false, null);
}
@@ -376,15 +380,24 @@ public class PhysicalIcebergMergeSink<CHILD_TYPE extends
Plan> extends PhysicalB
insertPartitionFields.clear();
return new InsertPartitionFieldResult(false, hasNonIdentity,
spec.specId());
}
- ExprId exprId = columnExprIdMap.get(sourceField.name());
+ Column rootColumn = findSourceRootColumn(cols, field.sourceId());
+ // Nested routing must bind through stable Iceberg IDs; a name
fallback can target a
+ // different field after rename/drop-and-add schema evolution.
+ ExprId exprId = rootColumn == null || rootColumn.getUniqueId() < 0
+ ? null : columnIdToExprId.get(rootColumn.getUniqueId());
if (exprId == null) {
insertPartitionFields.clear();
return new InsertPartitionFieldResult(false, hasNonIdentity,
spec.specId());
}
String transform = field.transform().toString();
Integer param = parseTransformParam(transform);
+ List<Integer> sourceFieldPath = resolveSourceFieldPath(rootColumn,
field.sourceId());
+ if (sourceFieldPath == null) {
+ insertPartitionFields.clear();
+ return new InsertPartitionFieldResult(false, hasNonIdentity,
spec.specId());
+ }
insertPartitionFields.add(new
DistributionSpecMerge.IcebergPartitionField(
- transform, exprId, param, field.name(), field.sourceId()));
+ transform, exprId, param, field.name(), field.sourceId(),
sourceFieldPath));
}
if (insertPartitionFields.isEmpty()) {
return new InsertPartitionFieldResult(false, hasNonIdentity,
spec.specId());
@@ -392,6 +405,75 @@ public class PhysicalIcebergMergeSink<CHILD_TYPE extends
Plan> extends PhysicalB
return new InsertPartitionFieldResult(true, hasNonIdentity,
spec.specId());
}
+ private Map<Integer, ExprId> buildColumnIdExprIdMap(List<Slot>
outputSlots) {
+ Map<Integer, ExprId> result = new java.util.HashMap<>();
+ List<Column> visibleColumns = new ArrayList<>();
+ for (Column column : cols) {
+ if (column.isVisible()) {
+ visibleColumns.add(column);
+ }
+ }
+ List<Slot> dataSlots = getDataSlots(outputSlots);
+ if (visibleColumns.size() != dataSlots.size()) {
+ return result;
+ }
+ for (int i = 0; i < visibleColumns.size(); i++) {
+ if (visibleColumns.get(i).getUniqueId() >= 0) {
+ result.put(visibleColumns.get(i).getUniqueId(),
dataSlots.get(i).getExprId());
+ }
+ }
+ return result;
+ }
+
+ private static Column findSourceRootColumn(List<Column> columns, int
sourceId) {
+ for (Column column : columns) {
+ if (column.getUniqueId() == sourceId ||
containsFieldId(column.getChildren(), sourceId)) {
+ return column;
+ }
+ }
+ return null;
+ }
+
+ private static boolean containsFieldId(List<Column> columns, int sourceId)
{
+ if (columns == null) {
+ return false;
+ }
+ for (Column column : columns) {
+ if (column.getUniqueId() == sourceId ||
containsFieldId(column.getChildren(), sourceId)) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ private static List<Integer> resolveSourceFieldPath(Column root, int
sourceId) {
+ if (root == null) {
+ return null;
+ }
+ if (root.getUniqueId() == sourceId) {
+ return ImmutableList.of();
+ }
+ List<Integer> path = new ArrayList<>();
+ return findSourceFieldPath(root.getChildren(), sourceId, path)
+ ? ImmutableList.copyOf(path) : null;
+ }
+
+ private static boolean findSourceFieldPath(List<Column> columns, int
sourceId, List<Integer> path) {
+ if (columns == null) {
+ return false;
+ }
+ for (int index = 0; index < columns.size(); index++) {
+ Column column = columns.get(index);
+ path.add(index);
+ if (column.getUniqueId() == sourceId
+ || findSourceFieldPath(column.getChildren(), sourceId,
path)) {
+ return true;
+ }
+ path.remove(path.size() - 1);
+ }
+ return false;
+ }
+
private List<Column> getRetainedPartitionColumns(PartitionSpec
partitionSpec, Schema schema) {
Map<String, Column> columnsByName = new
TreeMap<>(String.CASE_INSENSITIVE_ORDER);
for (Column column : cols) {
diff --git
a/fe/fe-core/src/main/java/org/apache/doris/planner/DataPartition.java
b/fe/fe-core/src/main/java/org/apache/doris/planner/DataPartition.java
index 8e652821c76..9f20824a9c2 100644
--- a/fe/fe-core/src/main/java/org/apache/doris/planner/DataPartition.java
+++ b/fe/fe-core/src/main/java/org/apache/doris/planner/DataPartition.java
@@ -205,14 +205,21 @@ public class DataPartition {
private final Integer param;
private final String name;
private final Integer sourceId;
+ private final ImmutableList<Integer> sourceFieldPath;
public IcebergPartitionField(Expr sourceExpr, String transform,
Integer param,
String name, Integer sourceId) {
+ this(sourceExpr, transform, param, name, sourceId,
ImmutableList.of());
+ }
+
+ public IcebergPartitionField(Expr sourceExpr, String transform,
Integer param,
+ String name, Integer sourceId, List<Integer> sourceFieldPath) {
this.sourceExpr = Preconditions.checkNotNull(sourceExpr,
"sourceExpr should not be null");
this.transform = Preconditions.checkNotNull(transform, "transform
should not be null");
this.param = param;
this.name = name;
this.sourceId = sourceId;
+ this.sourceFieldPath = ImmutableList.copyOf(sourceFieldPath);
}
public TIcebergPartitionField toThrift() {
@@ -228,6 +235,9 @@ public class DataPartition {
if (sourceId != null) {
field.setSourceId(sourceId);
}
+ if (!sourceFieldPath.isEmpty()) {
+ field.setSourceFieldPath(sourceFieldPath);
+ }
return field;
}
diff --git
a/fe/fe-core/src/test/java/org/apache/doris/datasource/iceberg/IcebergTransactionTest.java
b/fe/fe-core/src/test/java/org/apache/doris/datasource/iceberg/IcebergTransactionTest.java
index 793e0653dd0..e9300c82d76 100644
---
a/fe/fe-core/src/test/java/org/apache/doris/datasource/iceberg/IcebergTransactionTest.java
+++
b/fe/fe-core/src/test/java/org/apache/doris/datasource/iceberg/IcebergTransactionTest.java
@@ -49,6 +49,7 @@ import org.apache.iceberg.Table;
import org.apache.iceberg.catalog.Namespace;
import org.apache.iceberg.catalog.TableIdentifier;
import org.apache.iceberg.exceptions.CommitFailedException;
+import org.apache.iceberg.expressions.Binder;
import org.apache.iceberg.expressions.Expression;
import org.apache.iceberg.expressions.Expressions;
import org.apache.iceberg.expressions.UnboundPredicate;
@@ -1430,6 +1431,19 @@ public class IcebergTransactionTest {
Collections.singletonMap("unknown", "2026-01-01"),
spec, schema));
}
+ @Test
+ public void testStaticPartitionFilterBindsNestedIdentitySourceByFullPath()
{
+ Schema schema = new Schema(Types.NestedField.required(
+ 1, "payload", Types.StructType.of(
+ Types.NestedField.required(2, "region",
Types.StringType.get()))));
+ PartitionSpec spec =
PartitionSpec.builderFor(schema).identity("payload.region").build();
+
+ Expression filter = getTxn().buildPartitionFilter(
+ Collections.singletonMap(spec.fields().get(0).name(),
"us-east"), spec, schema);
+
+ Assert.assertNotNull(Binder.bind(schema.asStruct(), filter, true));
+ }
+
private DeleteFile buildDeletionVectorDeleteFile(String puffinPath, String
referencedDataFile,
long contentOffset, long contentLength) {
return FileMetadata.deleteFileBuilder(PartitionSpec.unpartitioned())
diff --git
a/fe/fe-core/src/test/java/org/apache/doris/datasource/iceberg/IcebergWriteSchemaContextTest.java
b/fe/fe-core/src/test/java/org/apache/doris/datasource/iceberg/IcebergWriteSchemaContextTest.java
index 3df06081bfd..f587a4bc411 100644
---
a/fe/fe-core/src/test/java/org/apache/doris/datasource/iceberg/IcebergWriteSchemaContextTest.java
+++
b/fe/fe-core/src/test/java/org/apache/doris/datasource/iceberg/IcebergWriteSchemaContextTest.java
@@ -41,7 +41,9 @@ import org.apache.doris.qe.ConnectContext;
import com.google.common.collect.ImmutableList;
import com.google.common.collect.ImmutableMap;
+import org.apache.iceberg.FileFormat;
import org.apache.iceberg.HasTableOperations;
+import org.apache.iceberg.MetricsConfig;
import org.apache.iceberg.PartitionSpec;
import org.apache.iceberg.Schema;
import org.apache.iceberg.SchemaParser;
@@ -75,6 +77,21 @@ import java.util.stream.Collectors;
public class IcebergWriteSchemaContextTest {
+ @Test
+ public void testWriterMetadataAcceptsNestedPartitionSource() {
+ Schema schema = new Schema(Types.NestedField.optional(1, "payload",
+ Types.StructType.of(Types.NestedField.optional(2, "part",
Types.IntegerType.get()))));
+ PartitionSpec spec =
PartitionSpec.builderFor(schema).bucket("payload.part", 8).build();
+
+ IcebergWriteSchemaContext context =
IcebergWriteSchemaContext.forSchema(
+ schema, 2, spec, SortOrder.unsorted(), FileFormat.PARQUET,
+ MetricsConfig.getDefault(), "zstd", "s3://bucket/table/data",
+ ImmutableMap.of(), true, true);
+
+ Assertions.assertEquals(2,
context.getPartitionSpec().fields().get(0).sourceId());
+ Assertions.assertEquals(2,
context.getColumns().get(0).getChildren().get(0).getUniqueId());
+ }
+
@Test
public void testReadAndWriteBindingsPreserveNestedTypesWithLegacyFlags() {
Schema schema = new Schema(
diff --git
a/fe/fe-core/src/test/java/org/apache/doris/nereids/trees/plans/commands/insert/InsertUtilsTest.java
b/fe/fe-core/src/test/java/org/apache/doris/nereids/trees/plans/commands/insert/InsertUtilsTest.java
index d03ad56593d..e8617fa68f4 100644
---
a/fe/fe-core/src/test/java/org/apache/doris/nereids/trees/plans/commands/insert/InsertUtilsTest.java
+++
b/fe/fe-core/src/test/java/org/apache/doris/nereids/trees/plans/commands/insert/InsertUtilsTest.java
@@ -22,6 +22,7 @@ import org.apache.doris.catalog.DatabaseIf;
import org.apache.doris.catalog.PrimitiveType;
import org.apache.doris.catalog.Type;
import org.apache.doris.common.security.authentication.ExecutionAuthenticator;
+import org.apache.doris.datasource.CatalogIf;
import org.apache.doris.datasource.iceberg.IcebergExternalCatalog;
import org.apache.doris.datasource.iceberg.IcebergExternalTable;
import org.apache.doris.datasource.iceberg.IcebergMvccSnapshot;
@@ -29,6 +30,8 @@ import
org.apache.doris.datasource.iceberg.IcebergPartitionInfo;
import org.apache.doris.datasource.iceberg.IcebergSnapshot;
import org.apache.doris.datasource.iceberg.IcebergSnapshotCacheValue;
import org.apache.doris.datasource.iceberg.IcebergWriteSchemaContext;
+import org.apache.doris.datasource.mvcc.MvccSnapshot;
+import org.apache.doris.datasource.mvcc.MvccTableInfo;
import org.apache.doris.nereids.StatementContext;
import org.apache.doris.nereids.analyzer.UnboundAlias;
import org.apache.doris.nereids.analyzer.UnboundIcebergTableSink;
@@ -151,6 +154,50 @@ public class InsertUtilsTest {
.build();
}
+ @Test
+ public void
testInsertPlanRetryPreservesInjectedSnapshotAndReloadsPlannerSnapshot() {
+ StatementContext statementContext = new StatementContext();
+ IcebergExternalTable table = Mockito.mock(IcebergExternalTable.class);
+ DatabaseIf database = Mockito.mock(DatabaseIf.class);
+ CatalogIf catalog = Mockito.mock(CatalogIf.class);
+ Mockito.when(table.getName()).thenReturn("table");
+ Mockito.when(table.getDatabase()).thenReturn(database);
+ Mockito.when(database.getFullName()).thenReturn("db");
+ Mockito.when(database.getCatalog()).thenReturn(catalog);
+ Mockito.when(catalog.getName()).thenReturn("catalog");
+ MvccSnapshot snapshot = Mockito.mock(MvccSnapshot.class);
+ MvccSnapshot newerSnapshot = Mockito.mock(MvccSnapshot.class);
+ Mockito.when(table.loadSnapshot(Optional.empty(),
Optional.empty())).thenReturn(newerSnapshot);
+ statementContext.setSnapshot(new MvccTableInfo(table), snapshot);
+
+
InsertIntoTableCommand.prepareMvccSnapshotsForPlanAttempt(statementContext, 1);
+
+ Assertions.assertSame(snapshot,
+ statementContext.loadSnapshots(table, Optional.empty(),
Optional.empty())
+ .orElseThrow(AssertionError::new));
+ Mockito.verify(table, Mockito.never()).loadSnapshot(Optional.empty(),
Optional.empty());
+ IcebergExternalTable plannerTable =
Mockito.mock(IcebergExternalTable.class);
+ Mockito.when(plannerTable.getName()).thenReturn("planner_table");
+ Mockito.when(plannerTable.getDatabase()).thenReturn(database);
+ Mockito.when(plannerTable.loadSnapshot(Optional.empty(),
Optional.empty()))
+ .thenReturn(snapshot, newerSnapshot);
+ Assertions.assertSame(snapshot, statementContext.loadSnapshots(
+ plannerTable, Optional.empty(),
Optional.empty()).orElseThrow(AssertionError::new));
+
+ // Target-schema retries must keep the source generation selected for
the MTMV refresh.
+ for (int attempt = 2; attempt <= 3; attempt++) {
+
InsertIntoTableCommand.prepareMvccSnapshotsForPlanAttempt(statementContext,
attempt);
+ Assertions.assertSame(snapshot, statementContext.loadSnapshots(
+ table, Optional.empty(),
Optional.empty()).orElseThrow(AssertionError::new));
+ Assertions.assertSame(newerSnapshot,
statementContext.loadSnapshots(
+ plannerTable, Optional.empty(),
Optional.empty()).orElseThrow(AssertionError::new));
+ }
+ Mockito.verify(table, Mockito.never()).loadSnapshot(Optional.empty(),
Optional.empty());
+ statementContext.resetMvccSnapshots();
+ Assertions.assertSame(newerSnapshot, statementContext.loadSnapshots(
+ table, Optional.empty(),
Optional.empty()).orElseThrow(AssertionError::new));
+ }
+
private String generateString(int length) {
return generateString(length, "X");
}
diff --git
a/fe/fe-core/src/test/java/org/apache/doris/nereids/trees/plans/physical/PhysicalIcebergMergeSinkTest.java
b/fe/fe-core/src/test/java/org/apache/doris/nereids/trees/plans/physical/PhysicalIcebergMergeSinkTest.java
new file mode 100644
index 00000000000..64de644c983
--- /dev/null
+++
b/fe/fe-core/src/test/java/org/apache/doris/nereids/trees/plans/physical/PhysicalIcebergMergeSinkTest.java
@@ -0,0 +1,135 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements. See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership. The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License. You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied. See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+package org.apache.doris.nereids.trees.plans.physical;
+
+import org.apache.doris.catalog.Column;
+import org.apache.doris.catalog.Type;
+import org.apache.doris.datasource.iceberg.IcebergExternalDatabase;
+import org.apache.doris.datasource.iceberg.IcebergExternalTable;
+import org.apache.doris.datasource.iceberg.IcebergMergeOperation;
+import org.apache.doris.datasource.iceberg.IcebergWriteSchemaContext;
+import org.apache.doris.nereids.properties.DistributionSpecMerge;
+import org.apache.doris.nereids.properties.LogicalProperties;
+import org.apache.doris.nereids.trees.expressions.ExprId;
+import org.apache.doris.nereids.trees.expressions.NamedExpression;
+import org.apache.doris.nereids.trees.expressions.SlotReference;
+import org.apache.doris.nereids.trees.plans.Plan;
+import org.apache.doris.nereids.trees.plans.RelationId;
+import
org.apache.doris.nereids.trees.plans.commands.delete.DeleteCommandContext;
+import org.apache.doris.nereids.types.DataType;
+import org.apache.doris.nereids.types.IntegerType;
+import org.apache.doris.qe.ConnectContext;
+
+import org.apache.iceberg.PartitionSpec;
+import org.apache.iceberg.Schema;
+import org.apache.iceberg.Table;
+import org.apache.iceberg.types.Types;
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.Test;
+import org.mockito.Mockito;
+
+import java.lang.reflect.Method;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+
+public class PhysicalIcebergMergeSinkTest {
+ @Test
+ public void nestedPartitionRoutesThroughRootIdAfterRename() throws
Exception {
+ Schema schema = new Schema(Types.NestedField.optional(1, "payload",
+ Types.StructType.of(Types.NestedField.optional(2, "part",
Types.IntegerType.get()))));
+ PartitionSpec spec =
PartitionSpec.builderFor(schema).bucket("payload.part", 8).build();
+ Column root = Mockito.mock(Column.class);
+ Column leaf = new Column("renamed_part", Type.INT);
+ leaf.setUniqueId(2);
+ Mockito.when(root.getUniqueId()).thenReturn(1);
+ Mockito.when(root.getChildren()).thenReturn(new
ArrayList<>(Collections.singletonList(leaf)));
+ PhysicalIcebergMergeSink<Plan> sink = new PhysicalIcebergMergeSink<>(
+ Mockito.mock(IcebergExternalDatabase.class),
Mockito.mock(IcebergExternalTable.class),
+ Mockito.mock(Table.class), Collections.singletonList(root),
Collections.emptyList(),
+ new DeleteCommandContext(), true, false, Optional.empty(),
+ Mockito.mock(LogicalProperties.class),
+ new PhysicalEmptyRelation(new RelationId(1),
Collections.emptyList(), null));
+ Method build =
PhysicalIcebergMergeSink.class.getDeclaredMethod("buildInsertPartitionFields",
+ List.class, PartitionSpec.class, Schema.class, Map.class);
+ build.setAccessible(true);
+ ExprId rootExprId = new ExprId(42);
+ List<DistributionSpecMerge.IcebergPartitionField> fields = new
ArrayList<>();
+
+ build.invoke(sink, fields, spec, schema, Collections.singletonMap(1,
rootExprId));
+
+ Assertions.assertEquals(1, fields.size());
+ Assertions.assertEquals(rootExprId, fields.get(0).getSourceExprId());
+ Assertions.assertEquals(2, fields.get(0).getSourceId());
+ Assertions.assertEquals(Collections.singletonList(0),
fields.get(0).getSourceFieldPath());
+ Assertions.assertEquals(8, fields.get(0).getParam());
+
+ // A missing stable root ID must disable field-based routing to
prevent a wrong partition.
+ fields.clear();
+ Mockito.when(root.getUniqueId()).thenReturn(-1);
+ build.invoke(sink, fields, spec, schema, Collections.singletonMap(1,
rootExprId));
+ Assertions.assertTrue(fields.isEmpty());
+ }
+
+ @Test
+ public void v3LineageSlotsDoNotDisablePartitionRouting() {
+ Schema schema = new Schema(
+ Types.NestedField.optional(1, "payload", Types.StructType.of(
+ Types.NestedField.optional(2, "part",
Types.IntegerType.get()))),
+ Types.NestedField.optional(3, "id", Types.IntegerType.get()));
+ List<Column> columns = IcebergWriteSchemaContext.forSchema(schema, 3,
true, true).getMergeColumns();
+ List<NamedExpression> output = new ArrayList<>();
+ for (Column column : columns) {
+ output.add(new SlotReference(column.getName(),
DataType.fromCatalogType(column.getType())));
+ }
+ output.add(new SlotReference(IcebergMergeOperation.OPERATION_COLUMN,
IntegerType.INSTANCE));
+ PhysicalEmptyRelation child = new PhysicalEmptyRelation(new
RelationId(2), output, null);
+ ConnectContext previous = ConnectContext.get();
+ ConnectContext context = new ConnectContext();
+ context.getSessionVariable().enableIcebergMergePartitioning = true;
+ context.setThreadLocalInfo();
+ try {
+ for (PartitionSpec spec : new PartitionSpec[] {
+ PartitionSpec.builderFor(schema).bucket("id", 8).build(),
+ PartitionSpec.builderFor(schema).bucket("payload.part",
8).build(),
+
PartitionSpec.builderFor(schema).identity("payload.part").build()}) {
+ Table table = Mockito.mock(Table.class);
+ Mockito.when(table.schema()).thenReturn(schema);
+ Mockito.when(table.spec()).thenReturn(spec);
+ PhysicalIcebergMergeSink<PhysicalEmptyRelation> sink = new
PhysicalIcebergMergeSink<>(
+ Mockito.mock(IcebergExternalDatabase.class),
Mockito.mock(IcebergExternalTable.class),
+ table, columns, output, new DeleteCommandContext(),
true, false,
+ Optional.empty(), null, child);
+ DistributionSpecMerge distribution = (DistributionSpecMerge)
+
sink.getRequirePhysicalProperties().getDistributionSpec();
+ Assertions.assertFalse(distribution.isInsertRandom());
+ Assertions.assertEquals(1,
distribution.getInsertPartitionFields().size());
+ Assertions.assertEquals(spec.fields().get(0).sourceId(),
+
distribution.getInsertPartitionFields().get(0).getSourceId());
+ }
+ } finally {
+ if (previous == null) {
+ ConnectContext.remove();
+ } else {
+ previous.setThreadLocalInfo();
+ }
+ }
+ }
+}
diff --git a/gensrc/thrift/Partitions.thrift b/gensrc/thrift/Partitions.thrift
index 73863696115..a49a4c9d1b8 100644
--- a/gensrc/thrift/Partitions.thrift
+++ b/gensrc/thrift/Partitions.thrift
@@ -99,6 +99,8 @@ struct TIcebergPartitionField {
3: required Exprs.TExpr source_expr
4: optional string name
5: optional i32 source_id
+ // Zero-based STRUCT child indexes below source_expr; empty/unset means a
top-level source.
+ 6: optional list<i32> source_field_path
}
struct TMergePartitionInfo {
diff --git
a/regression-test/data/external_table_p0/iceberg/write/test_iceberg_write_complex_evolution.out
b/regression-test/data/external_table_p0/iceberg/write/test_iceberg_write_complex_evolution.out
index 76bede82d3f..673d6c879cb 100644
---
a/regression-test/data/external_table_p0/iceberg/write/test_iceberg_write_complex_evolution.out
+++
b/regression-test/data/external_table_p0/iceberg/write/test_iceberg_write_complex_evolution.out
@@ -3,8 +3,9 @@
1 A [1, null, 3] {"x":10, "null-value":null} {"metric":10,
"label":"old-a", "nested":{"count":1, "comment":null, "score":null},
"tags":null, "attributes":null}
2 N \N {"x":null} {"metric":20, "label":null,
"nested":{"count":null, "comment":"old-null", "score":null}, "tags":null,
"attributes":null}
3 B [] {} \N
-4 A1 [4000000000, null] {"large":5000000000, "null-value":null}
{"metric":6000000000, "label":"new-a", "nested":{"count":7000000000,
"comment":"nested-new", "score":7.5}, "tags":["x", null, "z"],
"attributes":{"a":8000000000, "b":null}}
+4 A2 [4000000000, null] {"large":5000000000, "null-value":null}
{"metric":6000000000, "label":"new-a", "nested":{"count":7000000000,
"comment":"nested-new", "score":7.5}, "tags":["x", null, "z"],
"attributes":{"a":8000000000, "b":null}}
5 N2 [null] \N {"metric":50, "label":null,
"nested":{"count":5, "comment":null, "score":null}, "tags":null,
"attributes":{"null-value":null}}
+6 Z4 \N \N \N
-- !complex_children --
1 10 1 \N \N \N
@@ -12,19 +13,24 @@
3 \N \N \N \N \N
4 6000000000 7000000000 7.5 ["x", null, "z"]
{"a":8000000000, "b":null}
5 50 5 \N \N {"null-value":null}
+6 \N \N \N \N \N
-- !complex_nulls --
1
2
3
5
+6
-- !complex_partition_specs --
0 3
-2 2
+3 5
+
+-- !complex_nested_partition_pruning --
+4
+6
-- !complex_base_tag --
1 [1, null, 3] {"x":10, "null-value":null} 10 old-a 1
\N
2 \N {"x":null} 20 \N \N old-null
3 [] {} \N \N \N \N
-
diff --git
a/regression-test/suites/external_table_p0/iceberg/write/test_iceberg_write_complex_evolution.groovy
b/regression-test/suites/external_table_p0/iceberg/write/test_iceberg_write_complex_evolution.groovy
index e5ad9e7c6ed..3460561577b 100644
---
a/regression-test/suites/external_table_p0/iceberg/write/test_iceberg_write_complex_evolution.groovy
+++
b/regression-test/suites/external_table_p0/iceberg/write/test_iceberg_write_complex_evolution.groovy
@@ -116,6 +116,15 @@ suite("test_iceberg_write_complex_evolution",
"""
sql """alter table complex_evolution add partition key bucket(8, id) as
id_bucket"""
sql """alter table complex_evolution add partition key truncate(1,
group_key) as group_prefix"""
+ // Iceberg permits a nested primitive source. Create it through Spark to
verify Doris can plan and
+ // physically partition the following INSERT by the schema-wide nested
field id. Invalidate Spark's
+ // cached table first so its commit requirement sees the partition ids
assigned by the Doris DDLs.
+ spark_iceberg """refresh table demo.${dbName}.complex_evolution"""
+ spark_iceberg """
+ alter table demo.${dbName}.complex_evolution
+ add partition field bucket(4, payload.nested.count)
+ """
+ sql """refresh table complex_evolution"""
sql """
insert into complex_evolution values
@@ -135,7 +144,15 @@ suite("test_iceberg_write_complex_evolution",
struct(cast(5 as bigint), null, null),
null,
map('null-value', null)
- ))
+ )),
+ (6, 'Z3', null, null, null)
+ """
+
+ // Route an UPDATE insert image by the nested source and preserve the
parent-NULL partition value.
+ sql """
+ update complex_evolution
+ set group_key = case id when 4 then 'A2' else 'Z4' end
+ where id in (4, 6)
"""
// W02-S03: Current schema reads both old and new files without moving old
child values.
@@ -166,6 +183,13 @@ suite("test_iceberg_write_complex_evolution",
group by spec_id
order by spec_id
"""
+ order_qt_complex_nested_partition_pruning """
+ select id
+ from complex_evolution
+ where payload.nested.count = cast(7000000000 as bigint)
+ or (id = 6 and payload.nested.count is null)
+ order by id
+ """
assertSparkMatchesDoris()
// W02-S04: A pre-evolution tag binds the old files to their historical
complex schema.
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]