This is an automated email from the ASF dual-hosted git repository.
Gabriel39 pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/doris.git
The following commit(s) were added to refs/heads/master by this push:
new 335df1dc014 [fix](iceberg) Harden schema evolution and nested
partition writes (#66529)
335df1dc014 is described below
commit 335df1dc0144ae3f807a5bf092ef800cba1ab92d
Author: Gabriel <[email protected]>
AuthorDate: Fri Aug 7 17:34:39 2026 +0800
[fix](iceberg) Harden schema evolution and nested partition writes (#66529)
## Proposed changes
- make Iceberg compatibility gates conservative and bounded while
pinning column handles to historical schemas
- reset connector metadata across INSERT retries and align V1/V2
defaults, required-field checks, and position-delete row projection
- support primitive partition sources nested in structs, including
nullable-parent propagation and regression coverage
- cache the compact equality-delete field-ID projection by immutable
table snapshot even when the optional full manifest cache is disabled
- resolve nested partition sources through the top-level Nereids slot ID
and fail closed when stable Iceberg IDs are unavailable
## Compatibility-gate trade-offs
- Equality-delete fencing remains conservative across all delete
manifests in the selected snapshot, including partition-pruned scans.
The snapshot-scoped field-ID cache removes repeated manifest walks
without weakening correctness; initial-load failures still fail closed
and remain retryable.
- Requiredness fencing intentionally uses bounded schema-history
inspection rather than an O(snapshot-count) ancestry walk because
snapshot schema IDs are optional. Once a projected requiredness hazard
exists, every non-empty selected snapshot is fenced. This can reduce
rolling-upgrade availability but cannot create a correctness false
negative.
## Testing
- `mvn -pl fe-core,fe-connector/fe-connector-iceberg -am
-Dtest=PhysicalExternalRowLevelMergeSinkTest,IcebergManifestCacheTest,IcebergScanPlanProviderTest
-Dsurefire.failIfNoSpecifiedTests=false test`
- `mvn -pl fe-connector/fe-connector-iceberg -am
-Dtest=IcebergScanPlanProviderTest,IcebergConnectorMetadataTest,IcebergWritePlanProviderTest
-Dsurefire.failIfNoSpecifiedTests=false test`
- `mvn -pl fe-core -am
-Dtest=ConnectorStatementScopeTest,InsertIntoTableCommandTest
-Dsurefire.failIfNoSpecifiedTests=false test`
- `./run-be-ut.sh --run
--filter=SchemaTest.*:VIcebergTableWriterTest.*:IcebergReaderTest.v1_materializes_non_finite_initial_defaults:IcebergV2ReaderTest.PreparesIcebergNonFiniteInitialDefaults:IcebergPositionDeleteSysTableV2ProfileTest.*`
- FE Checkstyle for all affected modules
- clang-format 16 check for all changed C/C++ files
---
.../sink/writer/iceberg/viceberg_table_writer.cpp | 112 ++++++++++--
.../sink/writer/iceberg/viceberg_table_writer.h | 7 +
be/src/format/table/iceberg/schema.cpp | 29 ++-
be/src/format/table/iceberg/schema.h | 4 +
be/src/format/table/iceberg_default_value.h | 31 +++-
.../transformer/iceberg_partition_function.cpp | 75 +++++++-
.../transformer/iceberg_partition_function.h | 4 +
.../iceberg_position_delete_sys_table_reader.cpp | 6 +
be/src/format_v2/table/iceberg_reader.cpp | 15 +-
be/src/format_v2/table/iceberg_reader.h | 3 +
be/test/core/value/merge_partitioner_test.cpp | 91 ++++++++++
.../writer/iceberg/viceberg_table_writer_test.cpp | 63 +++++++
.../format/table/iceberg/iceberg_reader_test.cpp | 17 ++
be/test/format/table/iceberg/schema_test.cpp | 31 ++++
...eberg_position_delete_sys_table_reader_test.cpp | 25 +++
be/test/format_v2/table/iceberg_reader_test.cpp | 33 ++++
.../iceberg/IcebergConnectorMetadata.java | 26 ++-
.../connector/iceberg/IcebergManifestCache.java | 54 +++++-
.../connector/iceberg/IcebergScanPlanProvider.java | 196 +++++++++------------
.../iceberg/IcebergWritePlanProvider.java | 34 +++-
.../iceberg/IcebergWriteSchemaContext.java | 7 +-
.../iceberg/IcebergConnectorMetadataTest.java | 36 ++++
.../iceberg/IcebergManifestCacheTest.java | 55 ++++++
.../iceberg/IcebergScanPlanProviderTest.java | 135 ++++++++++++--
.../iceberg/IcebergWritePlanProviderTest.java | 34 ++++
.../datasource/scan/PluginDrivenScanNode.java | 8 +-
.../glue/translator/PhysicalPlanTranslator.java | 3 +-
.../nereids/properties/DistributionSpecMerge.java | 17 +-
.../commands/insert/InsertIntoTableCommand.java | 3 +
.../PhysicalExternalRowLevelMergeSink.java | 99 ++++++++++-
.../org/apache/doris/planner/DataPartition.java | 10 ++
.../PluginDrivenScanNodeColumnPruningTest.java | 15 ++
.../PhysicalExternalRowLevelMergeSinkTest.java | 85 +++++++++
gensrc/thrift/Partitions.thrift | 2 +
.../write/test_iceberg_write_complex_evolution.out | 12 +-
.../test_iceberg_write_complex_evolution.groovy | 26 ++-
36 files changed, 1229 insertions(+), 174 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 e3f8ed645ed..3519252d7ca 100644
--- a/be/src/exec/sink/writer/iceberg/viceberg_table_writer.cpp
+++ b/be/src/exec/sink/writer/iceberg/viceberg_table_writer.cpp
@@ -17,14 +17,18 @@
#include "exec/sink/writer/iceberg/viceberg_table_writer.h"
+#include <algorithm>
+
#include "common/exception.h"
#include "core/block/block.h"
#include "core/block/column_with_type_and_name.h"
#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"
@@ -121,31 +125,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 ||
@@ -358,9 +437,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 2cb83f73ed0..20d9562c35e 100644
--- a/be/src/exec/sink/writer/iceberg/viceberg_table_writer.h
+++ b/be/src/exec/sink/writer/iceberg/viceberg_table_writer.h
@@ -83,6 +83,7 @@ public:
private:
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.
@@ -93,10 +94,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:
@@ -104,6 +107,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;
@@ -117,10 +121,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_path(const doris::iceberg::StructLike& data);
std::string _escape(const std::string& path);
diff --git a/be/src/format/table/iceberg/schema.cpp
b/be/src/format/table/iceberg/schema.cpp
index 76dac166327..a05456627bf 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 {
const std::string Schema::ALL_COLUMNS = "*";
@@ -24,10 +26,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)) {}
@@ -48,4 +66,9 @@ 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;
+}
+
} // namespace doris::iceberg
diff --git a/be/src/format/table/iceberg/schema.h
b/be/src/format/table/iceberg/schema.h
index 0273a4450da..5781b86b3fa 100644
--- a/be/src/format/table/iceberg/schema.h
+++ b/be/src/format/table/iceberg/schema.h
@@ -26,6 +26,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);
@@ -40,6 +41,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;
@@ -48,6 +51,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;
};
} // namespace doris::iceberg
diff --git a/be/src/format/table/iceberg_default_value.h
b/be/src/format/table/iceberg_default_value.h
index ae75924336f..5fe1834e3bc 100644
--- a/be/src/format/table/iceberg_default_value.h
+++ b/be/src/format/table/iceberg_default_value.h
@@ -24,6 +24,7 @@
#include <cstddef>
#include <deque>
+#include <limits>
#include <string>
#include <string_view>
#include <unordered_map>
@@ -46,6 +47,28 @@ namespace doris::iceberg {
namespace detail {
+inline 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;
+}
+
inline const schema::external::TField* get_field_ptr(const
schema::external::TFieldPtr& field_ptr) {
if (!field_ptr.__isset.field_ptr || field_ptr.field_ptr == nullptr) {
return nullptr;
@@ -135,7 +158,7 @@ inline std::string json_scalar_text(const rapidjson::Value&
value) {
return {buffer.GetString(), buffer.GetSize()};
}
-inline void normalize_timestamp_for_doris(PrimitiveType primitive_type,
std::string* value) {
+inline void normalize_timestamp_for_doris(doris::PrimitiveType primitive_type,
std::string* value) {
if (primitive_type != TYPE_DATETIME && primitive_type != TYPE_DATETIMEV2 &&
primitive_type != TYPE_TIMESTAMPTZ) {
return;
@@ -344,6 +367,9 @@ inline Status build_json_scalar_default(const
schema::external::TField& field,
return Status::OK();
}
normalize_timestamp_for_doris(primitive_type, &serialized_value);
+ if (parse_non_finite_default(primitive_type, serialized_value, result)) {
+ return Status::OK();
+ }
RETURN_IF_ERROR(value_type->get_serde()->from_fe_string(serialized_value,
*result));
return Status::OK();
}
@@ -422,6 +448,9 @@ inline Status build_initial_default_field(const
schema::external::TField& field,
return Status::OK();
}
+ if (parse_non_finite_default(primitive_type, field.initial_default_value,
result)) {
+ return Status::OK();
+ }
RETURN_IF_ERROR(value_type->get_serde()->from_fe_string(field.initial_default_value,
*result));
return Status::OK();
}
diff --git a/be/src/format/transformer/iceberg_partition_function.cpp
b/be/src/format/transformer/iceberg_partition_function.cpp
index 3030fed3d60..4d134b062f3 100644
--- a/be/src/format/transformer/iceberg_partition_function.cpp
+++ b/be/src/format/transformer/iceberg_partition_function.cpp
@@ -24,6 +24,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,21 @@ 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");
+ }
+ 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) {
LOG(WARNING) << "Merge partitioning fallback to RR: " <<
e.what();
_fallback_to_random = true;
@@ -173,6 +189,7 @@ 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));
}
}
@@ -180,6 +197,53 @@ Status IcebergInsertPartitionFunction::clone(RuntimeState*
state,
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();
@@ -202,8 +266,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 d2c1a25724b..0ab36c91a0e 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"
@@ -52,8 +53,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..94f489747e9 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,9 @@ 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.
+
RETURN_IF_ERROR(prepare_iceberg_initial_default_exprs(&columns->back()));
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 097e6bab111..37ed4c00e28 100644
--- a/be/src/format_v2/table/iceberg_reader.cpp
+++ b/be/src/format_v2/table/iceberg_reader.cpp
@@ -47,6 +47,7 @@
#include "exprs/vliteral.h"
#include "exprs/vslot_ref.h"
#include "format/table/deletion_vector_reader.h"
+#include "format/table/iceberg_default_value.h"
#include "format_v2/expr/cast.h"
#include "format_v2/expr/equality_delete_predicate.h"
#include "format_v2/orc/orc_reader.h"
@@ -357,6 +358,10 @@ static Status build_v2_json_scalar_default(const
format::ColumnDefinition& field
return Status::OK();
}
normalize_iceberg_json_timestamp(primitive_type, &serialized_value);
+ if (doris::iceberg::detail::parse_non_finite_default(primitive_type,
serialized_value,
+ result)) {
+ return Status::OK();
+ }
RETURN_IF_ERROR(value_type->get_serde()->from_fe_string(serialized_value,
*result));
return Status::OK();
}
@@ -432,6 +437,10 @@ static Status build_v2_initial_default_field(const
format::ColumnDefinition& fie
return Status::OK();
}
+ if (doris::iceberg::detail::parse_non_finite_default(primitive_type,
+
*field.initial_default_value, result)) {
+ return Status::OK();
+ }
RETURN_IF_ERROR(value_type->get_serde()->from_fe_string(*field.initial_default_value,
*result));
return Status::OK();
}
@@ -451,7 +460,7 @@ static Status build_initial_default_literal(const
format::ColumnDefinition& tabl
return Status::OK();
}
-static Status build_initial_default_exprs(format::ColumnDefinition* column) {
+Status prepare_iceberg_initial_default_exprs(format::ColumnDefinition* column)
{
DORIS_CHECK(column != nullptr);
if (column->initial_default_value.has_value()) {
VExprSPtr literal;
@@ -459,7 +468,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));
+ RETURN_IF_ERROR(prepare_iceberg_initial_default_exprs(&child));
}
return Status::OK();
}
@@ -771,7 +780,7 @@ Status IcebergTableReader::annotate_projected_column(const
TFileScanSlotInfo& sl
}
auto& schema_column = *context->schema_column;
- RETURN_IF_ERROR(build_initial_default_exprs(&schema_column));
+ RETURN_IF_ERROR(prepare_iceberg_initial_default_exprs(&schema_column));
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 2768e4cd3e8..2b29d26ee3a 100644
--- a/be/src/format_v2/table/iceberg_reader.h
+++ b/be/src/format_v2/table/iceberg_reader.h
@@ -42,6 +42,8 @@ struct FileSystemProperties;
namespace doris::format::iceberg {
+Status prepare_iceberg_initial_default_exprs(format::ColumnDefinition* column);
+
// 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
@@ -75,6 +77,7 @@ public:
protected:
void configure_mapper_options(format::TableColumnMapperOptions* options)
const override {
options->enable_row_lineage_virtual_columns = true;
+ options->reject_missing_required_field =
supports_iceberg_scan_semantics_v2(_scan_params);
options->allow_idless_complex_wrapper_projection =
supports_iceberg_scan_semantics_v1(_scan_params) && _format ==
FileFormat::PARQUET;
}
diff --git a/be/test/core/value/merge_partitioner_test.cpp
b/be/test/core/value/merge_partitioner_test.cpp
index f2682cf657a..43c494832d0 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();
@@ -204,11 +246,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;
@@ -219,6 +263,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) {
@@ -317,6 +362,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("identity");
+ 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 3800ef4f86e..06066de0fd7 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
@@ -20,6 +20,15 @@
#include <gtest/gtest.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 "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"
@@ -50,4 +59,58 @@ TEST(VIcebergTableWriterTest, RejectMissingPartitionSource) {
}
}
+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, nullptr, nullptr);
+ 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);
+}
+
} // namespace doris
diff --git a/be/test/format/table/iceberg/iceberg_reader_test.cpp
b/be/test/format/table/iceberg/iceberg_reader_test.cpp
index a212de6adfc..ef1c469ca23 100644
--- a/be/test/format/table/iceberg/iceberg_reader_test.cpp
+++ b/be/test/format/table/iceberg/iceberg_reader_test.cpp
@@ -28,6 +28,7 @@
#include <parquet/arrow/writer.h>
#include <array>
+#include <cmath>
#include <filesystem>
#include <fstream>
#include <iostream>
@@ -1796,6 +1797,22 @@ TEST_F(IcebergReaderTest,
initial_default_rejects_invalid_nullability) {
EXPECT_TRUE(value.is_null());
}
+TEST_F(IcebergReaderTest, v1_materializes_non_finite_initial_defaults) {
+ schema::external::TField field;
+ field.__set_name("value");
+ field.__set_id(1);
+ field.__set_is_optional(false);
+ field.__set_initial_default_value("NaN");
+
+ ColumnPtr column;
+ ASSERT_TRUE(iceberg::create_initial_default_column(field,
std::make_shared<DataTypeFloat32>(),
+ &column)
+ .ok());
+ Field value;
+ column->get(0, value);
+ EXPECT_TRUE(std::isnan(value.get<TYPE_FLOAT>()));
+}
+
// GTest assertion macros inflate clang-tidy's cognitive-complexity score.
// NOLINTNEXTLINE(readability-function-cognitive-complexity)
TEST_F(IcebergReaderTest,
v1_reuses_prepared_complex_initial_default_across_block_types) {
diff --git a/be/test/format/table/iceberg/schema_test.cpp
b/be/test/format/table/iceberg/schema_test.cpp
index bccf0f1f341..91c9ae778b3 100644
--- a/be/test/format/table/iceberg/schema_test.cpp
+++ b/be/test/format/table/iceberg/schema_test.cpp
@@ -19,6 +19,10 @@
#include <gtest/gtest.h>
+#include <cmath>
+
+#include "format/table/iceberg_default_value.h"
+
namespace doris {
namespace iceberg {
@@ -66,5 +70,32 @@ 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);
+}
+
+TEST(SchemaTest, ParsesIcebergNonFiniteDefaults) {
+ Field value;
+ EXPECT_TRUE(detail::parse_non_finite_default(TYPE_FLOAT, "NaN", &value));
+ EXPECT_TRUE(std::isnan(value.get<TYPE_FLOAT>()));
+ EXPECT_TRUE(detail::parse_non_finite_default(TYPE_DOUBLE, "Infinity",
&value));
+ EXPECT_TRUE(std::isinf(value.get<TYPE_DOUBLE>()));
+ EXPECT_GT(value.get<TYPE_DOUBLE>(), 0);
+ EXPECT_TRUE(detail::parse_non_finite_default(TYPE_DOUBLE, "-Infinity",
&value));
+ EXPECT_LT(value.get<TYPE_DOUBLE>(), 0);
+}
+
} // 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..6db534fa06d 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,8 @@
#include <gtest/gtest.h>
+#include "core/data_type/data_type_number.h"
+#include "core/data_type/data_type_struct.h"
#include "runtime/runtime_profile.h"
#include "runtime/runtime_state.h"
@@ -59,5 +61,28 @@ TEST(IcebergPositionDeleteSysTableV2ProfileTest,
UsesDistinctProfileForNestedPos
reader._position_reader_profile);
}
+TEST(IcebergPositionDeleteSysTableV2ProfileTest,
PreparesNestedRowInitialDefaults) {
+ IcebergPositionDeleteSysTableV2Reader reader;
+ 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 806cc7e729a..5c78f6b19ef 100644
--- a/be/test/format_v2/table/iceberg_reader_test.cpp
+++ b/be/test/format_v2/table/iceberg_reader_test.cpp
@@ -27,6 +27,7 @@
#include <algorithm>
#include <array>
#include <chrono>
+#include <cmath>
#include <cstring>
#include <filesystem>
#include <fstream>
@@ -1475,6 +1476,38 @@ TEST(IcebergV2ReaderTest,
AnnotateBuildsTypedNestedInitialDefault) {
EXPECT_EQ(value.get<TYPE_INT>(), 7);
}
+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, AnnotateBuildsComplexInitialDefaults) {
const auto required_int_type = std::make_shared<DataTypeInt32>();
const auto optional_string_type =
make_nullable(std::make_shared<DataTypeString>());
diff --git
a/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/IcebergConnectorMetadata.java
b/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/IcebergConnectorMetadata.java
index a75da020914..0e6425a8f07 100644
---
a/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/IcebergConnectorMetadata.java
+++
b/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/IcebergConnectorMetadata.java
@@ -709,7 +709,31 @@ public class IcebergConnectorMetadata implements
ConnectorMetadata {
// metadata-table columns (t$snapshots -> committed_at/...) so the
generic scan node can look up
// its pruned sys-table slots by name; a data handle resolves the base
table's columns.
Table table = iceHandle.isSystemTable() ? loadSysTable(session,
iceHandle) : loadTable(session, iceHandle);
- List<Types.NestedField> fields = table.schema().columns();
+ return buildColumnHandles(table.schema());
+ }
+
+ @Override
+ public Map<String, ConnectorColumnHandle> getColumnHandles(
+ ConnectorSession session, ConnectorTableHandle handle,
+ ConnectorMvccSnapshot snapshot) {
+ IcebergTableHandle iceHandle = (IcebergTableHandle) handle;
+ if (iceHandle.isSystemTable() || snapshot == null ||
snapshot.getSchemaId() < 0) {
+ return getColumnHandles(session, handle);
+ }
+ Table table = loadTable(session, iceHandle);
+ Schema schema = table.currentSnapshot() == null
+ ? table.schema() : table.schemas().get((int)
snapshot.getSchemaId());
+ // Keep the handle-schema fallback identical to getTableSchema so
slots and handles cannot diverge.
+ return buildColumnHandles(schema == null ? table.schema() : schema);
+ }
+
+ @Override
+ public boolean supportsColumnHandleSnapshotPin(ConnectorSession session) {
+ return true;
+ }
+
+ private static Map<String, ConnectorColumnHandle>
buildColumnHandles(Schema schema) {
+ List<Types.NestedField> fields = schema.columns();
Map<String, ConnectorColumnHandle> handles = new
LinkedHashMap<>(fields.size());
for (Types.NestedField field : fields) {
String name = field.name();
diff --git
a/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/IcebergManifestCache.java
b/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/IcebergManifestCache.java
index f3c46732fb2..7790d43062b 100644
---
a/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/IcebergManifestCache.java
+++
b/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/IcebergManifestCache.java
@@ -30,12 +30,17 @@ import org.apache.iceberg.Table;
import java.io.IOException;
import java.util.ArrayList;
+import java.util.Collections;
+import java.util.HashSet;
import java.util.List;
import java.util.Map;
+import java.util.Objects;
+import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ForkJoinPool;
import java.util.concurrent.TimeUnit;
import java.util.function.LongSupplier;
+import java.util.function.Supplier;
/**
* Per-catalog cache of an iceberg manifest's parsed files, keyed by {@link
IcebergManifestEntryKey}
@@ -46,8 +51,9 @@ import java.util.function.LongSupplier;
* <p>Consumed by {@link IcebergScanPlanProvider}'s manifest-level planning
path (gated by
* {@code meta.cache.iceberg.manifest.enable}, default off — the default scan
path is the iceberg SDK
* {@code planFiles()}). The external enable-gate lives in the scan provider
(which decides whether to take the
- * manifest-planning path at all); this cache is unconditionally on when
consulted. Within one catalog the same
- * manifest file is parsed once and shared across queries (and across tables
that reference it).
+ * full manifest-planning path); the compact equality-delete field-id
projection is always reused per immutable
+ * snapshot. Within one catalog the same manifest file is parsed once and
shared across queries (and across
+ * tables that reference it).
*
* <p><b>No TTL; capacity-bounded; cleared on REFRESH CATALOG.</b> This
mirrors the legacy entry's
* {@code contextualOnly(CacheSpec.of(false, CACHE_NO_TTL, 100_000))} default
spec: a manifest's content is
@@ -68,6 +74,35 @@ final class IcebergManifestCache {
private static final long DEFAULT_STATS_TTL_SECONDS = 300L;
private final MetaCacheEntry<IcebergManifestEntryKey, ManifestCacheValue>
entry;
+ private final MetaCacheEntry<SnapshotKey, Set<Integer>>
equalityDeleteFieldIds;
+
+ /** Immutable snapshot key for the compact equality-delete field-id
projection. */
+ private static final class SnapshotKey {
+ private final String tableLocation;
+ private final long snapshotId;
+
+ private SnapshotKey(String tableLocation, long snapshotId) {
+ this.tableLocation = tableLocation;
+ this.snapshotId = snapshotId;
+ }
+
+ @Override
+ public boolean equals(Object o) {
+ if (this == o) {
+ return true;
+ }
+ if (!(o instanceof SnapshotKey)) {
+ return false;
+ }
+ SnapshotKey that = (SnapshotKey) o;
+ return snapshotId == that.snapshotId &&
Objects.equals(tableLocation, that.tableLocation);
+ }
+
+ @Override
+ public int hashCode() {
+ return Objects.hash(tableLocation, snapshotId);
+ }
+ }
// Per-scan manifest-cache access tally, keyed by the statement's stable
queryId
// (ConnectorSession.getQueryId()), so VERBOSE EXPLAIN can report THIS
scan's hits/misses/failures (the
@@ -106,10 +141,24 @@ final class IcebergManifestCache {
CacheSpec spec = CacheSpec.of(true, CacheSpec.CACHE_NO_TTL,
Math.max(1, maxSize));
this.entry = new MetaCacheEntry<>("iceberg-manifest", null, spec,
ForkJoinPool.commonPool(), false, true, 0L, true);
+ this.equalityDeleteFieldIds = new
MetaCacheEntry<>("iceberg-equality-delete-field-ids", null, spec,
+ ForkJoinPool.commonPool(), false, true, 0L, true);
this.statsTtlNanos = TimeUnit.SECONDS.toNanos(Math.max(1L,
statsTtlSeconds));
this.nanoClock = nanoClock;
}
+ /**
+ * Returns the equality-delete field ids for one immutable snapshot.
Unlike the optional full manifest-file
+ * cache, this compact projection is always reused so scan properties do
not re-read every delete manifest on
+ * every query. Loader failures are deliberately not cached, preserving
retry after transient storage errors.
+ */
+ Set<Integer> getOrLoadEqualityDeleteFieldIds(
+ String tableLocation, long snapshotId, Supplier<Set<Integer>>
loader) {
+ SnapshotKey key = new SnapshotKey(tableLocation, snapshotId);
+ return equalityDeleteFieldIds.get(key,
+ ignored -> Collections.unmodifiableSet(new
HashSet<>(loader.get())));
+ }
+
/**
* Returns the parsed files for {@code manifest}, loading (and reading
from storage) only on a miss. The
* loader runs OUTSIDE Caffeine's compute lock (manual miss-load;
single-flight per key), so a same-key
@@ -223,6 +272,7 @@ final class IcebergManifestCache {
*/
void invalidateAll() {
entry.invalidateAll();
+ equalityDeleteFieldIds.invalidateAll();
statsByQuery.clear();
}
diff --git
a/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/IcebergScanPlanProvider.java
b/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/IcebergScanPlanProvider.java
index a38af307a3d..09f8fa44615 100644
---
a/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/IcebergScanPlanProvider.java
+++
b/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/IcebergScanPlanProvider.java
@@ -55,6 +55,8 @@ import org.apache.iceberg.FileScanTask;
import org.apache.iceberg.HasTableOperations;
import org.apache.iceberg.ManifestContent;
import org.apache.iceberg.ManifestFile;
+import org.apache.iceberg.ManifestFiles;
+import org.apache.iceberg.ManifestReader;
import org.apache.iceberg.MetadataColumns;
import org.apache.iceberg.MetadataTableType;
import org.apache.iceberg.MetadataTableUtils;
@@ -66,7 +68,6 @@ import org.apache.iceberg.ScanTask;
import org.apache.iceberg.Schema;
import org.apache.iceberg.SchemaParser;
import org.apache.iceberg.Snapshot;
-import org.apache.iceberg.SnapshotSummary;
import org.apache.iceberg.SplittableScanTask;
import org.apache.iceberg.Table;
import org.apache.iceberg.TableOperations;
@@ -100,10 +101,8 @@ import java.io.Closeable;
import java.io.IOException;
import java.nio.ByteBuffer;
import java.time.ZoneId;
-import java.util.ArrayDeque;
import java.util.ArrayList;
import java.util.Collections;
-import java.util.Deque;
import java.util.HashMap;
import java.util.HashSet;
import java.util.Iterator;
@@ -230,8 +229,9 @@ public class IcebergScanPlanProvider implements
ConnectorScanPlanProvider {
// null in offline unit tests via the 2-arg ctor, in which case
resolveTable resolves directly.
private final ConnectorContext context;
// T08: per-catalog manifest cache, owned by the long-lived
IcebergConnector and injected via getScanPlanProvider.
- // Nullable — null via the 2-/3-arg ctors (offline tests, default-disabled
gate); when null the gate is
- // forced off and planScan uses the SDK splitFiles path.
+ // Its compact equality-delete field-id projection is used regardless of
the full-cache feature gate. Nullable
+ // via the 2-/3-arg ctors (offline tests); when null the projection is
loaded directly, the full-cache gate is
+ // forced off, and planScan uses the SDK splitFiles path.
private final IcebergManifestCache manifestCache;
// PERF-01: cross-query RAW-table cache shared with the metadata layer,
owned by the long-lived
// IcebergConnector and injected via getScanPlanProvider. Nullable — null
via the offline-test ctors and
@@ -1601,11 +1601,13 @@ public class IcebergScanPlanProvider implements
ConnectorScanPlanProvider {
boolean systemTable = iceHandle.isSystemTable();
Schema scanSchema = null;
TableScan exactScan = null;
+ Set<Integer> applicableEqualityDeleteFieldIds = Collections.emptySet();
boolean hasApplicableEqualityDeletes = false;
if (!systemTable) {
scanSchema = pinnedSchema(table, iceHandle);
exactScan = buildScan(table, iceHandle, filter, session);
- hasApplicableEqualityDeletes =
hasApplicableEqualityDeletes(exactScan);
+ applicableEqualityDeleteFieldIds =
cachedApplicableEqualityDeleteFieldIds(table, exactScan);
+ hasApplicableEqualityDeletes =
!applicableEqualityDeleteFieldIds.isEmpty();
Optional<Map<Integer, List<String>>> nameMapping =
IcebergSchemaUtils.extractNameMapping(table);
if (requiresCurrentScanSemantics(
table, exactScan, scanSchema, columns,
hasApplicableEqualityDeletes, nameMapping)) {
@@ -1701,7 +1703,7 @@ public class IcebergScanPlanProvider implements
ConnectorScanPlanProvider {
// every branch so the default and current field type match BE's
read.
if (hasApplicableEqualityDeletes) {
List<NestedField> equalityFields =
schemaForPotentialEqualityDeletes(
- table, exactScan, scanSchema);
+ table, scanSchema, applicableEqualityDeleteFieldIds);
dict = IcebergSchemaUtils.encodeEqualitySchemaEvolutionProp(
table, equalityFields, appendRowLineage,
enableVarbinary, enableTimestampTz);
@@ -1725,6 +1727,14 @@ public class IcebergScanPlanProvider implements
ConnectorScanPlanProvider {
}
props.put(SCHEMA_EVOLUTION_PROP, dict);
} else if (isPositionDeletesSysTable(iceHandle)) {
+ // The native position-delete row reader depends on the current
nested-default and requiredness
+ // semantics, so rolling upgrades must not route this system-table
scan to an older backend.
+ // Metadata-only projections never materialize `row`, so fencing
those scans needlessly reduces
+ // rolling-upgrade availability without preserving a reader
invariant.
+ if (requestsColumn(columns, "row")) {
+
props.put(ScanNodePropertyKeys.REQUIRED_CURRENT_BACKEND_SEMANTICS,
+ "Current Iceberg position delete semantics");
+ }
// [D-065] narrowed: $position_deletes is the ONE system table BE
reads with a NATIVE reader, so
// the "schema rides inside the serialized FileScanTask" rationale
above does not hold for it — no
// FileScanTask is serialized on this path. Both native readers
resolve the `row` column through
@@ -1801,47 +1811,77 @@ public class IcebergScanPlanProvider implements
ConnectorScanPlanProvider {
return names;
}
+ private static boolean requestsColumn(List<ConnectorColumnHandle> columns,
String requestedName) {
+ if (columns == null || columns.isEmpty()) {
+ return true;
+ }
+ for (ConnectorColumnHandle column : columns) {
+ if (requestedName.equalsIgnoreCase(((IcebergColumnHandle)
column).getName())) {
+ return true;
+ }
+ }
+ return false;
+ }
+
@VisibleForTesting
- static boolean hasApplicableEqualityDeletes(TableScan scan) {
+ static Set<Integer> applicableEqualityDeleteFieldIds(Table table,
TableScan scan) {
Snapshot snapshot = scan.snapshot();
- if (snapshot == null
- || "0".equals(snapshot.summary().get(TOTAL_EQUALITY_DELETES)))
{
- return false;
+ if (snapshot == null ||
"0".equals(snapshot.summary().get(TOTAL_EQUALITY_DELETES))) {
+ return Collections.emptySet();
}
- // planFiles binds delete files to the exact filtered data-file tasks
after partition and sequence
- // pruning. A snapshot summary of zero returns above without planning;
a positive or missing summary
- // needs this exact proof. Iterate whole-file tasks lazily and stop at
the first equality delete: this
- // keeps memory O(1), does not create or retain byte-split tasks, and
avoids snapshot-wide delete
- // counters forcing new-BE-only semantics when no dispatched task can
consume an equality delete.
- try (CloseableIterable<FileScanTask> tasks = scan.planFiles()) {
- for (FileScanTask task : tasks) {
- for (DeleteFile delete : task.deletes()) {
- if (delete.content() == FileContent.EQUALITY_DELETES) {
- return true;
+ Set<Integer> fieldIds = new HashSet<>();
+ for (ManifestFile manifest : snapshot.deleteManifests(table.io())) {
+ if (!manifest.hasAddedFiles() && !manifest.hasExistingFiles()) {
+ continue;
+ }
+ try (ManifestReader<DeleteFile> reader =
ManifestFiles.readDeleteManifest(
+ manifest, table.io(), table.specs())) {
+ for (DeleteFile deleteFile : reader) {
+ if (deleteFile.content() == FileContent.EQUALITY_DELETES) {
+ fieldIds.addAll(deleteFile.equalityFieldIds());
}
}
+ } catch (IOException e) {
+ throw new DorisConnectorException(
+ "Failed to read iceberg delete manifest " +
manifest.path() + ": " + e.getMessage(), e);
}
- } catch (IOException e) {
- throw new DorisConnectorException(
- "Failed to inspect applicable Iceberg equality deletes: "
+ e.getMessage(), e);
}
- return false;
+ return fieldIds;
+ }
+
+ private Set<Integer> cachedApplicableEqualityDeleteFieldIds(Table table,
TableScan scan) {
+ Snapshot snapshot = scan.snapshot();
+ if (snapshot == null ||
"0".equals(snapshot.summary().get(TOTAL_EQUALITY_DELETES))) {
+ return Collections.emptySet();
+ }
+ if (manifestCache == null) {
+ return applicableEqualityDeleteFieldIds(table, scan);
+ }
+ // Snapshot contents are immutable, so this compact projection can be
shared even when the optional
+ // full manifest cache is disabled; a loader failure must still escape
and remain retryable.
+ return manifestCache.getOrLoadEqualityDeleteFieldIds(
+ table.location(), snapshot.snapshotId(), () ->
applicableEqualityDeleteFieldIds(table, scan));
}
/**
- * Build a schema carrier that can resolve any equality key reachable
before the selected schema without
- * enumerating data files, manifests, or byte-split tasks. Its retained
state is bounded by table schema
- * history rather than scan cardinality. At execution time BE looks fields
up by the exact IDs on each
- * {@link FileScanTask#deletes()}; unrelated carrier fields never
participate in delete matching.
+ * Build a schema carrier that can resolve the field IDs referenced by
live equality delete files. Reading
+ * delete manifests does not enumerate data files or byte-split tasks, and
retained state is bounded by the
+ * number of equality keys rather than the table's entire schema history.
*
- * <p>The selected snapshot lineage wins when a field was renamed. The
metadata schema list, in its actual
- * chronology up to the selected schema (schema IDs are identifiers, not a
sequence), fills schema-only
- * changes and expired ancestors. Current fields remain first, so a
dropped/re-added name still resolves the
- * projected current field by name while a historical equality key
resolves by its stable field ID.</p>
+ * <p>The metadata schema list is searched in reverse chronology up to the
selected schema (schema IDs are
+ * identifiers, not a sequence), so the latest definition of each stable
field ID wins. Current fields remain
+ * first, allowing a dropped/re-added name to resolve the projected field
by name while a live historical
+ * equality key resolves by its stable field ID.</p>
*/
@VisibleForTesting
static List<NestedField> schemaForPotentialEqualityDeletes(
Table table, TableScan scan, Schema scanSchema) {
+ return schemaForPotentialEqualityDeletes(
+ table, scanSchema, applicableEqualityDeleteFieldIds(table,
scan));
+ }
+
+ private static List<NestedField> schemaForPotentialEqualityDeletes(
+ Table table, Schema scanSchema, Set<Integer> equalityFieldIds) {
List<Schema> metadataSchemas = metadataSchemaHistory(table);
int selectedSchemaIndex = -1;
for (int index = 0; index < metadataSchemas.size(); index++) {
@@ -1851,45 +1891,19 @@ public class IcebergScanPlanProvider implements
ConnectorScanPlanProvider {
}
int lastRelevantIndex = selectedSchemaIndex >= 0
? selectedSchemaIndex : metadataSchemas.size() - 1;
- Set<Integer> missing = new HashSet<>();
- for (int index = 0; index <= lastRelevantIndex; index++) {
- Schema schema = metadataSchemas.get(index);
- for (NestedField field :
TypeUtil.indexById(schema.asStruct()).values()) {
- if (field.type().isPrimitiveType()) {
- missing.add(field.fieldId());
- }
- }
- }
+ Set<Integer> missing = new HashSet<>(equalityFieldIds);
missing.removeAll(TypeUtil.indexById(scanSchema.asStruct()).keySet());
if (missing.isEmpty()) {
return scanSchema.columns();
}
List<NestedField> fields = new ArrayList<>(scanSchema.columns());
- Map<Integer, Schema> schemasById = table.schemas();
- Snapshot snapshot = scan.snapshot();
- while (snapshot != null && !missing.isEmpty()) {
- Integer schemaId = snapshot.schemaId();
- if (schemaId != null) {
- Schema historicalSchema = schemasById.get(schemaId);
- if (historicalSchema == null) {
- throw new IllegalStateException(
- "Iceberg snapshot schema " + schemaId + " is
absent from table metadata");
- }
- addHistoricalEqualityFields(fields, missing, historicalSchema);
- }
- if (missing.isEmpty()) {
- break;
- }
- Long parentId = snapshot.parentId();
- snapshot = parentId == null ? null : table.snapshot(parentId);
- }
for (int index = lastRelevantIndex; index >= 0 && !missing.isEmpty();
index--) {
addHistoricalEqualityFields(fields, missing,
metadataSchemas.get(index));
}
if (!missing.isEmpty()) {
throw new IllegalStateException(
- "Iceberg historical primitive fields are absent from
schema history: " + missing);
+ "Iceberg equality-delete fields are absent from schema
history: " + missing);
}
return fields;
}
@@ -2081,62 +2095,22 @@ public class IcebergScanPlanProvider implements
ConnectorScanPlanProvider {
static boolean selectedHistoryRequiresMissingRequiredFieldRejection(
Table table, Schema scanSchema, Set<Integer> projectedFieldIds,
Snapshot selectedSnapshot) {
- Map<Integer, Schema> schemasById = table.schemas();
- Set<Integer> relevantSchemaIds =
schemaIdsRequiringMissingRequiredFieldRejection(
- scanSchema, projectedFieldIds, schemasById.values());
- if (relevantSchemaIds.isEmpty()) {
+ if (!schemaHistoryRequiresMissingRequiredFieldRejection(
+ scanSchema, projectedFieldIds, table.schemas().values())) {
return false;
}
- Deque<Snapshot> snapshots = new ArrayDeque<>();
- if (selectedSnapshot != null) {
- snapshots.add(selectedSnapshot);
- }
- Set<Long> visitedSnapshotIds = new HashSet<>();
- while (!snapshots.isEmpty()) {
- Snapshot snapshot = snapshots.removeFirst();
- if (!visitedSnapshotIds.add(snapshot.snapshotId())) {
- continue;
- }
- Integer schemaId = snapshot.schemaId();
- if (schemaId != null) {
- Schema historical = schemasById.get(schemaId);
- if (historical == null) {
- throw new IllegalStateException(
- "Iceberg snapshot schema " + schemaId + " is
absent from table metadata");
- }
- if (relevantSchemaIds.contains(schemaId)) {
- return true;
- }
- }
- Long parentId = snapshot.parentId();
- if (parentId != null) {
- Snapshot parent = table.snapshot(parentId);
- if (parent == null) {
- return true;
- }
- snapshots.addLast(parent);
- }
- String sourceSnapshotId =
-
snapshot.summary().get(SnapshotSummary.SOURCE_SNAPSHOT_ID_PROP);
- if (sourceSnapshotId != null) {
- Snapshot source =
table.snapshot(Long.parseLong(sourceSnapshotId));
- if (source == null) {
- return true;
- }
- snapshots.addLast(source);
- }
- }
- return false;
+ // Snapshot schema IDs are optional and proving ancestry is O(snapshot
count). Once schema history
+ // exposes a requiredness hazard, conservatively fence every non-empty
selected snapshot.
+ return selectedSnapshot != null;
}
- private static Set<Integer>
schemaIdsRequiringMissingRequiredFieldRejection(
+ private static boolean schemaHistoryRequiresMissingRequiredFieldRejection(
Schema scanSchema, Set<Integer> projectedFieldIds,
Iterable<Schema> historicalSchemas) {
Map<Integer, NestedField> currentFields =
TypeUtil.indexById(scanSchema.asStruct());
Map<Integer, Integer> parentById =
TypeUtil.indexParents(scanSchema.asStruct());
Set<Integer> collectionWrapperIds = new HashSet<>();
collectCollectionWrapperFieldIds(scanSchema.asStruct(),
collectionWrapperIds);
- Set<Integer> schemaIds = new HashSet<>();
for (Schema historicalSchema : historicalSchemas) {
Map<Integer, NestedField> historicalFields =
TypeUtil.indexById(historicalSchema.asStruct());
@@ -2149,8 +2123,7 @@ public class IcebergScanPlanProvider implements
ConnectorScanPlanProvider {
NestedField historicalField = historicalFields.get(fieldId);
if (historicalField != null) {
if (historicalField.isOptional()) {
- schemaIds.add(historicalSchema.schemaId());
- break;
+ return true;
}
continue;
}
@@ -2164,12 +2137,11 @@ public class IcebergScanPlanProvider implements
ConnectorScanPlanProvider {
if (!collectionWrapperIds.contains(highestMissing.fieldId())
&& highestMissing.isRequired()
&& highestMissing.initialDefault() == null) {
- schemaIds.add(historicalSchema.schemaId());
- break;
+ return true;
}
}
}
- return schemaIds;
+ return false;
}
private static void collectCollectionWrapperFieldIds(Type type,
Set<Integer> result) {
diff --git
a/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/IcebergWritePlanProvider.java
b/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/IcebergWritePlanProvider.java
index 02bb47ac08c..16b6ea50c19 100644
---
a/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/IcebergWritePlanProvider.java
+++
b/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/IcebergWritePlanProvider.java
@@ -61,6 +61,7 @@ import org.apache.iceberg.SortField;
import org.apache.iceberg.SortOrder;
import org.apache.iceberg.Table;
import org.apache.iceberg.TableProperties;
+import org.apache.iceberg.types.Type;
import org.apache.iceberg.types.Types.NestedField;
import org.apache.iceberg.util.LocationUtil;
@@ -534,18 +535,39 @@ public class IcebergWritePlanProvider implements
ConnectorWritePlanProvider {
? active.get().getSchema() : table.schema();
List<ConnectorWritePartitionField> fields = new ArrayList<>();
for (PartitionField field : spec.fields()) {
- // sourceColumnName mirrors the legacy
schema.findField(field.sourceId()).name() lookup the engine
- // used to map a partition field back to a bound output expr id.
transform/param mirror
- // field.transform().toString() + parseTransformParam (kept
connector-side so fe-core never parses).
- NestedField sourceField = schema.findField(field.sourceId());
- String sourceColumnName = sourceField == null ? null :
sourceField.name();
+ // Bind a nested source to its top-level slot. fe-core recovers
child indexes from the stable Iceberg
+ // field ids already carried by the Doris Column tree, so the
public connector SPI stays unchanged.
+ String sourceColumnName = findPartitionSourceColumnName(schema,
field.sourceId());
String transform = field.transform().toString();
fields.add(new ConnectorWritePartitionField(
- transform, parseTransformParam(transform),
sourceColumnName, field.name(), field.sourceId()));
+ transform, parseTransformParam(transform),
sourceColumnName,
+ field.name(), field.sourceId()));
}
return new ConnectorWritePartitionSpec(spec.specId(), fields);
}
+ private static String findPartitionSourceColumnName(Schema schema, int
sourceId) {
+ List<NestedField> columns = schema.columns();
+ for (NestedField column : columns) {
+ if (column.fieldId() == sourceId ||
containsStructField(column.type(), sourceId)) {
+ return column.name();
+ }
+ }
+ return null;
+ }
+
+ private static boolean containsStructField(Type type, int sourceId) {
+ if (!type.isStructType()) {
+ return false;
+ }
+ for (NestedField field : type.asStructType().fields()) {
+ if (field.fieldId() == sourceId ||
containsStructField(field.type(), sourceId)) {
+ return true;
+ }
+ }
+ return false;
+ }
+
@Override
public List<ConnectorColumn> getSyntheticWriteColumns(ConnectorSession
session,
ConnectorTableHandle tableHandle) {
diff --git
a/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/IcebergWriteSchemaContext.java
b/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/IcebergWriteSchemaContext.java
index ae16a1ee4fb..b04c2fa4216 100644
---
a/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/IcebergWriteSchemaContext.java
+++
b/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/IcebergWriteSchemaContext.java
@@ -202,13 +202,12 @@ 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 field as a partition source;
field IDs are schema-wide.
+ if (schema.findField(field.sourceId()) == null) {
throw new DorisConnectorException("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-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/IcebergConnectorMetadataTest.java
b/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/IcebergConnectorMetadataTest.java
index 5e98e90fe0a..61f275f3593 100644
---
a/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/IcebergConnectorMetadataTest.java
+++
b/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/IcebergConnectorMetadataTest.java
@@ -25,13 +25,17 @@ import
org.apache.doris.connector.spi.DorisConnectorException;
import org.apache.doris.connector.spi.handle.ConnectorColumnHandle;
import org.apache.doris.connector.spi.handle.ConnectorTableHandle;
import org.apache.doris.connector.spi.handle.WriteOperation;
+import org.apache.doris.connector.spi.mvcc.ConnectorMvccSnapshot;
+import org.apache.iceberg.DataFiles;
import org.apache.iceberg.PartitionSpec;
import org.apache.iceberg.RowLevelOperationMode;
import org.apache.iceberg.Schema;
import org.apache.iceberg.SortOrder;
import org.apache.iceberg.TableProperties;
import org.apache.iceberg.catalog.Namespace;
+import org.apache.iceberg.catalog.TableIdentifier;
+import org.apache.iceberg.inmemory.InMemoryCatalog;
import org.apache.iceberg.types.Types;
import org.apache.iceberg.view.ImmutableSQLViewRepresentation;
import org.apache.iceberg.view.ImmutableViewVersion;
@@ -1313,6 +1317,38 @@ public class IcebergConnectorMetadataTest {
"getColumnHandles must load the table via the seam using the
handle coordinates");
}
+ @Test
+ public void getColumnHandlesUsesPinnedHistoricalSchema() {
+ RecordingIcebergCatalogOps ops = new RecordingIcebergCatalogOps();
+ Schema oldSchema = new Schema(
+ Types.NestedField.required(7, "old_name",
Types.IntegerType.get()),
+ Types.NestedField.optional(9, "survivor",
Types.StringType.get()));
+ InMemoryCatalog catalog = new InMemoryCatalog();
+ catalog.initialize("test", Collections.emptyMap());
+ catalog.createNamespace(Namespace.of("db1"));
+ org.apache.iceberg.Table table = catalog.createTable(
+ TableIdentifier.of("db1", "t1"), oldSchema,
PartitionSpec.unpartitioned());
+
table.newAppend().appendFile(DataFiles.builder(PartitionSpec.unpartitioned())
+ .withPath("s3://bucket/db1/t1/old.parquet")
+ .withFileSizeInBytes(1).withRecordCount(1).build()).commit();
+ Schema historicalSchema = table.schema();
+ table.updateSchema().renameColumn("old_name", "new_name").commit();
+ ops.table = table;
+
+ ConnectorMvccSnapshot pin = ConnectorMvccSnapshot.builder()
+ .snapshotId(11L).schemaId(historicalSchema.schemaId()).build();
+ IcebergConnectorMetadata metadata = metadataWith(ops);
+ Map<String, ConnectorColumnHandle> handles = metadata.getColumnHandles(
+ null, new IcebergTableHandle("db1", "t1"), pin);
+
+ Assertions.assertTrue(metadata.supportsColumnHandleSnapshotPin(null));
+ Assertions.assertTrue(handles.containsKey("old_name"));
+ Assertions.assertTrue(handles.containsKey("survivor"));
+ Assertions.assertFalse(handles.containsKey("new_name"));
+
Assertions.assertEquals(historicalSchema.findField("old_name").fieldId(),
+ ((IcebergColumnHandle) handles.get("old_name")).getFieldId());
+ }
+
// ---------------------------------------------------------------------
// P6.3-T03: write transaction wiring (gate-closed / dormant)
// ---------------------------------------------------------------------
diff --git
a/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/IcebergManifestCacheTest.java
b/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/IcebergManifestCacheTest.java
index 28fb5019dec..920e5ae0c6d 100644
---
a/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/IcebergManifestCacheTest.java
+++
b/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/IcebergManifestCacheTest.java
@@ -32,6 +32,8 @@ import org.junit.jupiter.api.Test;
import java.util.Collections;
import java.util.List;
+import java.util.Set;
+import java.util.concurrent.atomic.AtomicInteger;
/**
* Unit tests for {@link IcebergManifestCache} (T08). Uses a real {@link
InMemoryCatalog} table so the cache is
@@ -78,6 +80,49 @@ public class IcebergManifestCacheTest {
Assertions.assertEquals(1, cache.size());
}
+ @Test
+ public void equalityDeleteFieldIdsLoadOncePerSnapshot() {
+ IcebergManifestCache cache = new IcebergManifestCache();
+ AtomicInteger loads = new AtomicInteger();
+
+ Set<Integer> first =
cache.getOrLoadEqualityDeleteFieldIds("/warehouse/db/t", 10L, () -> {
+ loads.incrementAndGet();
+ return Collections.singleton(7);
+ });
+ Set<Integer> second =
cache.getOrLoadEqualityDeleteFieldIds("/warehouse/db/t", 10L, () -> {
+ loads.incrementAndGet();
+ return Collections.singleton(8);
+ });
+ Set<Integer> nextSnapshot =
cache.getOrLoadEqualityDeleteFieldIds("/warehouse/db/t", 11L, () -> {
+ loads.incrementAndGet();
+ return Collections.singleton(9);
+ });
+
+ Assertions.assertEquals(Collections.singleton(7), first);
+ Assertions.assertEquals(first, second, "the same immutable snapshot
must reuse its field-id set");
+ Assertions.assertEquals(Collections.singleton(9), nextSnapshot);
+ Assertions.assertEquals(2, loads.get(), "only one manifest walk is
allowed per snapshot");
+ }
+
+ @Test
+ public void equalityDeleteFieldIdFailureIsNotCached() {
+ IcebergManifestCache cache = new IcebergManifestCache();
+ AtomicInteger loads = new AtomicInteger();
+
+ Assertions.assertThrows(IllegalStateException.class,
+ () -> cache.getOrLoadEqualityDeleteFieldIds("/warehouse/db/t",
10L, () -> {
+ loads.incrementAndGet();
+ throw new IllegalStateException("transient manifest
failure");
+ }));
+ Set<Integer> retry =
cache.getOrLoadEqualityDeleteFieldIds("/warehouse/db/t", 10L, () -> {
+ loads.incrementAndGet();
+ return Collections.singleton(7);
+ });
+
+ Assertions.assertEquals(Collections.singleton(7), retry);
+ Assertions.assertEquals(2, loads.get(), "a failed manifest walk must
be retried, not memoized");
+ }
+
@Test
public void capacityOverflowFlushesWholesale() {
Table table = tableWithTwoDataFiles();
@@ -97,11 +142,21 @@ public class IcebergManifestCacheTest {
ManifestFile manifest =
table.currentSnapshot().dataManifests(table.io()).get(0);
IcebergManifestCache cache = new IcebergManifestCache();
cache.getManifestCacheValue(manifest, table);
+ AtomicInteger equalityLoads = new AtomicInteger();
+ cache.getOrLoadEqualityDeleteFieldIds(table.location(),
table.currentSnapshot().snapshotId(), () -> {
+ equalityLoads.incrementAndGet();
+ return Collections.singleton(1);
+ });
Assertions.assertEquals(1, cache.size());
// REFRESH CATALOG hook (H-5): invalidateAll drops every cached
manifest (legacy catalog-wide
// group.invalidateAll parity). MUTATION: a no-op invalidateAll ->
size stays 1 -> red.
cache.invalidateAll();
Assertions.assertEquals(0, cache.size());
+ cache.getOrLoadEqualityDeleteFieldIds(table.location(),
table.currentSnapshot().snapshotId(), () -> {
+ equalityLoads.incrementAndGet();
+ return Collections.singleton(1);
+ });
+ Assertions.assertEquals(2, equalityLoads.get(), "catalog refresh must
also clear the snapshot projection");
}
@Test
diff --git
a/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/IcebergScanPlanProviderTest.java
b/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/IcebergScanPlanProviderTest.java
index 55f6c087f08..26fa913062a 100644
---
a/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/IcebergScanPlanProviderTest.java
+++
b/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/IcebergScanPlanProviderTest.java
@@ -67,6 +67,7 @@ import org.apache.iceberg.io.StorageCredential;
import org.apache.iceberg.io.SupportsStorageCredentials;
import org.apache.iceberg.types.Conversions;
import org.apache.iceberg.types.Types;
+import org.apache.iceberg.types.Types.NestedField;
import org.apache.iceberg.util.SerializationUtil;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Test;
@@ -814,6 +815,31 @@ public class IcebergScanPlanProviderTest {
"the unprojected equality-delete key column must be
force-included (#65502), got " + top);
}
+ @Test
+ public void
getScanNodePropertiesCachesEqualityDeleteFieldIdsWithManifestCacheDisabled() {
+ Schema schema = new Schema(
+ Types.NestedField.required(1, "id", Types.IntegerType.get()),
+ Types.NestedField.optional(2, "value",
Types.StringType.get()));
+ Table table = createTable("cached_eq_ids", schema,
PartitionSpec.unpartitioned(),
+ Collections.singletonMap(TableProperties.FORMAT_VERSION, "2"));
+ table.newAppend().appendFile(dataFile(table.spec(),
+ "s3://b/db/cached_eq_ids/f1.parquet", 1024, null,
null)).commit();
+ table.newRowDelta().addDeletes(equalityDeleteFile(
+ "s3://b/db/cached_eq_ids/eq.parquet", FileFormat.PARQUET,
1)).commit();
+ IcebergManifestCache cache = new IcebergManifestCache();
+ // Keep construction behind the shared helper so catalog-property API
migrations do not break this test.
+ IcebergScanPlanProvider provider =
manifestProvider(Collections.emptyMap(), table, cache);
+
+ provider.getScanNodeProperties(null, new IcebergTableHandle("db1",
"cached_eq_ids"),
+ Collections.singletonList(new IcebergColumnHandle("value",
2)), Optional.empty());
+
+ Set<Integer> cached = cache.getOrLoadEqualityDeleteFieldIds(
+ table.location(), table.currentSnapshot().snapshotId(), () -> {
+ throw new AssertionError("the provider must populate the
snapshot-scoped projection cache");
+ });
+ Assertions.assertEquals(Collections.singleton(1), cached);
+ }
+
@Test
public void equalityCarrierAllowsUnrelatedDropAndReaddNames() throws
Exception {
Schema schema = new Schema(
@@ -833,8 +859,6 @@ public class IcebergScanPlanProviderTest {
"s3://b/db/drop_readd/eq.parquet", FileFormat.PARQUET,
1))
.commit();
- int oldTopLevelId = table.schema().findField("same_name").fieldId();
- int oldNestedId =
table.schema().findField("payload.same_name").fieldId();
table.updateSchema().deleteColumn("same_name").deleteColumn("payload.same_name").commit();
table.updateSchema()
.addColumn("same_name", Types.IntegerType.get())
@@ -863,7 +887,8 @@ public class IcebergScanPlanProviderTest {
payload = field;
}
}
- Assertions.assertEquals(Arrays.asList(currentTopLevelId,
oldTopLevelId), sameNameIds);
+ Assertions.assertEquals(Collections.singletonList(currentTopLevelId),
sameNameIds,
+ "unrelated historical fields must not inflate the
equality-delete schema carrier");
Assertions.assertNotNull(payload);
List<Integer> nestedSameNameIds = new ArrayList<>();
for (TFieldPtr field :
payload.getFieldPtr().getNestedField().getStructField().getFields()) {
@@ -871,11 +896,60 @@ public class IcebergScanPlanProviderTest {
nestedSameNameIds.add(field.getFieldPtr().getId());
}
}
- Assertions.assertEquals(Arrays.asList(currentNestedId, oldNestedId),
nestedSameNameIds);
+ Assertions.assertEquals(Collections.singletonList(currentNestedId),
nestedSameNameIds,
+ "only field IDs referenced by live equality deletes may be
retained");
+ }
+
+ @Test
+ public void
equalityCarrierResolvesDroppedLiveEqualityKeyFromSchemaHistory() throws
Exception {
+ Schema schema = new Schema(
+ Types.NestedField.required(1, "id", Types.IntegerType.get()),
+ Types.NestedField.optional(2, "old_key",
Types.StringType.get()));
+ Table table = createTable("dropped_eq_key", schema,
PartitionSpec.unpartitioned(),
+ Collections.singletonMap(TableProperties.FORMAT_VERSION, "2"));
+ table.newAppend().appendFile(dataFile(table.spec(),
+ "s3://b/db/dropped_eq_key/f1.parquet", 1024, null,
null)).commit();
+ table.newRowDelta().addDeletes(equalityDeleteFile(
+ "s3://b/db/dropped_eq_key/eq.parquet", FileFormat.PARQUET,
2)).commit();
+ table.updateSchema().deleteColumn("old_key").commit();
+
+ IcebergScanPlanProvider provider = providerOver(table);
+ Map<String, String> props = provider.getScanNodeProperties(
+ null, new IcebergTableHandle("db1", "dropped_eq_key"),
+ Collections.singletonList(new IcebergColumnHandle("id", 1)),
Optional.empty());
+ TFileScanRangeParams params = new TFileScanRangeParams();
+ provider.populateScanLevelParams(params, props);
+
+ List<TFieldPtr> fields =
params.getHistorySchemaInfo().get(0).getRootField().getFields();
+ Assertions.assertTrue(fields.stream().anyMatch(field ->
field.getFieldPtr().getId() == 2),
+ "a dropped field still referenced by a live equality delete
must remain resolvable by ID");
}
@Test
- public void
partitionPrunedEqualityDeleteDoesNotRequireCurrentBackendSemantics() {
+ public void equalityCarrierSizeDoesNotGrowWithUnrelatedSchemaHistory()
throws Exception {
+ Schema schema = new Schema(
+ Types.NestedField.required(1, "id", Types.IntegerType.get()),
+ Types.NestedField.optional(2, "churn",
Types.IntegerType.get()));
+ Table table = createTable("bounded_eq_carrier", schema,
PartitionSpec.unpartitioned(),
+ Collections.singletonMap(TableProperties.FORMAT_VERSION, "2"));
+ table.newAppend().appendFile(dataFile(table.spec(),
+ "s3://b/db/bounded_eq_carrier/f1.parquet", 1024, null,
null)).commit();
+ table.newRowDelta().addDeletes(equalityDeleteFile(
+ "s3://b/db/bounded_eq_carrier/eq.parquet", FileFormat.PARQUET,
1)).commit();
+ for (int index = 0; index < 64; index++) {
+ table.updateSchema().deleteColumn("churn").commit();
+ table.updateSchema().addColumn("churn",
Types.IntegerType.get()).commit();
+ }
+
+ List<NestedField> carrier =
IcebergScanPlanProvider.schemaForPotentialEqualityDeletes(
+ table, table.newScan(), table.schema());
+ Assertions.assertEquals(2, carrier.size(),
+ "unrelated schema churn must not increase FE heap or schema
RPC size");
+ Assertions.assertEquals(table.schema().columns(), carrier);
+ }
+
+ @Test
+ public void
partitionPrunedEqualityDeleteConservativelyRequiresCurrentBackendSemantics() {
PartitionSpec spec =
PartitionSpec.builderFor(PART_SCHEMA).identity("p").build();
Table table = createTable("partition_pruned_eqdel", PART_SCHEMA, spec,
Collections.singletonMap(TableProperties.FORMAT_VERSION, "2"));
@@ -901,9 +975,9 @@ public class IcebergScanPlanProviderTest {
Map<String, String> prunedProps = provider.getScanNodeProperties(
null, new IcebergTableHandle("db1", "partition_pruned_eqdel"),
columns, Optional.of(eqInt("p", 1)));
- Assertions.assertFalse(prunedProps.containsKey(
+ Assertions.assertTrue(prunedProps.containsKey(
ScanNodePropertyKeys.REQUIRED_CURRENT_BACKEND_SEMANTICS),
- "an equality delete in a pruned partition must not gate the
selected tasks");
+ "delete-manifest inspection must stay conservative without
enumerating data tasks");
Map<String, String> applicableProps = provider.getScanNodeProperties(
null, new IcebergTableHandle("db1", "partition_pruned_eqdel"),
@@ -914,7 +988,7 @@ public class IcebergScanPlanProviderTest {
}
@Test
- public void
sequencePrunedEqualityDeleteDoesNotRequireCurrentBackendSemantics() {
+ public void
sequencePrunedEqualityDeleteConservativelyRequiresCurrentBackendSemantics() {
Table table = createTable("sequence_pruned_eqdel", SCHEMA,
PartitionSpec.unpartitioned(),
Collections.singletonMap(TableProperties.FORMAT_VERSION, "2"));
DataFile oldFile = dataFile(table.spec(),
@@ -935,9 +1009,9 @@ public class IcebergScanPlanProviderTest {
Map<String, String> props = provider.getScanNodeProperties(
null, new IcebergTableHandle("db1", "sequence_pruned_eqdel"),
Collections.singletonList(new IcebergColumnHandle("id", 1)),
Optional.empty());
- Assertions.assertFalse(props.containsKey(
+ Assertions.assertTrue(props.containsKey(
ScanNodePropertyKeys.REQUIRED_CURRENT_BACKEND_SEMANTICS),
- "an older equality delete must not gate a later-sequence
replacement data file");
+ "snapshot metadata must gate conservatively without
synchronously replanning all files");
}
@Test
@@ -1032,6 +1106,28 @@ public class IcebergScanPlanProviderTest {
"the equality carrier must not walk snapshots when no
historical field is missing");
}
+ @Test
+ public void
schemaHistoryPlanningStaysBoundedWhenHistoricalRequirednessNeedsTheFence() {
+ Schema oldSchema = new Schema(
+ Types.NestedField.optional(1, "id", Types.IntegerType.get()));
+ Table table = createTable("requiredness_history", oldSchema,
PartitionSpec.unpartitioned());
+ table.newAppend().appendFile(dataFile(table.spec(),
+ "s3://b/db/requiredness_history/old.parquet", 128, null,
null)).commit();
+
table.updateSchema().allowIncompatibleChanges().requireColumn("id").commit();
+ table.newAppend().appendFile(dataFile(table.spec(),
+ "s3://b/db/requiredness_history/new.parquet", 128, null,
null)).commit();
+
+ FakeIcebergTable countingTable = new FakeIcebergTable(
+ table.name(), table.schema(), table.spec(), table.location(),
table.properties());
+ countingTable.setScanTable(table);
+ Assertions.assertTrue(
+
IcebergScanPlanProvider.selectedHistoryRequiresMissingRequiredFieldRejection(
+ countingTable, table.schema(),
Collections.singleton(1),
+ table.currentSnapshot()));
+ Assertions.assertEquals(0, countingTable.getSnapshotLookupCount(),
+ "the upgrade fence must be conservative and bounded by schema
history, not snapshot count");
+ }
+
@Test
public void
getScanNodePropertiesEmitsSchemaEvolutionDictForPartitionedTableToo() {
// The dict is emitted alongside path_partition_keys (it is
unconditional, like legacy
@@ -1361,10 +1457,29 @@ public class IcebergScanPlanProviderTest {
Assertions.assertTrue(props.containsKey("iceberg.schema_evolution"),
"position_deletes reads natively and needs the field-id dict
to resolve `row`");
+ Assertions.assertFalse(props.containsKey(
+
ScanNodePropertyKeys.REQUIRED_CURRENT_BACKEND_SEMANTICS),
+ "metadata-only projections do not depend on
position_deletes.row semantics");
Assertions.assertFalse(props.containsKey("path_partition_keys"),
"a metadata table is still not base-spec partitioned -> no
path_partition_keys");
}
+ @Test
+ public void
getScanNodePropertiesForPositionDeletesRowRequiresCurrentBackendSemantics() {
+ Table table = tableWithPositionDelete(
+ positionDeleteFile("s3://b/db/t1/pos.parquet",
FileFormat.PARQUET, null, null));
+ // The helper follows the active catalog-property wrapper while this
test stays focused on scan semantics.
+ IcebergScanPlanProvider provider = providerOver(table);
+
+ Map<String, String> props = provider.getScanNodeProperties(
+ null, IcebergTableHandle.forSystemTable("db1", "t1",
"position_deletes", -1L, null, -1L),
+ Collections.singletonList(new IcebergColumnHandle("row", 3)),
Optional.empty());
+
+ Assertions.assertTrue(props.containsKey(
+
ScanNodePropertyKeys.REQUIRED_CURRENT_BACKEND_SEMANTICS),
+ "projecting position_deletes.row depends on current
nested-default semantics");
+ }
+
@Test
public void
getScanNodePropertiesForPositionDeletesLoadsTheBaseTableOnlyOnce() {
// WHY: the dict branch needs the METADATA table, and the obvious way
to get one is resolveSysTable().
diff --git
a/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/IcebergWritePlanProviderTest.java
b/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/IcebergWritePlanProviderTest.java
index bcfaf94a932..4ab51cbe4e7 100644
---
a/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/IcebergWritePlanProviderTest.java
+++
b/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/IcebergWritePlanProviderTest.java
@@ -351,6 +351,20 @@ public class IcebergWritePlanProviderTest {
Assertions.assertFalse(plan.getDataSink().getIcebergTableSink().getSchemaJson().contains("renamed_name"));
}
+ @Test
+ public void writeSchemaAllowsNestedPrimitivePartitionSource() {
+ InMemoryCatalog catalog = freshCatalog();
+ Schema schema = new Schema(
+ Types.NestedField.required(1, "id", Types.IntegerType.get()),
+ Types.NestedField.optional(2, "payload", Types.StructType.of(
+ Types.NestedField.optional(3, "part",
Types.IntegerType.get()))));
+ Table table = catalog.createTable(TableIdentifier.of("db1",
"nested_partition"), schema,
+
PartitionSpec.builderFor(schema).identity("payload.part").build());
+
+ Assertions.assertDoesNotThrow(() -> IcebergWriteSchemaContext.create(
+ table, "db1.nested_partition", Optional.empty(), false,
false));
+ }
+
@Test
public void branchWriteRejectsCurrentRequiredFieldAbsentWithoutDefault() {
InMemoryCatalog catalog = freshCatalog();
@@ -1292,6 +1306,26 @@ public class IcebergWritePlanProviderTest {
Assertions.assertEquals(table.schema().findField("id").fieldId(),
f.getSourceId());
}
+ @Test
+ public void getWritePartitioningRoutesNestedSourceByItsTopLevelColumn() {
+ InMemoryCatalog catalog = freshCatalog();
+ Schema schema = new Schema(
+ Types.NestedField.required(1, "id", Types.IntegerType.get()),
+ Types.NestedField.optional(2, "payload", Types.StructType.of(
+ Types.NestedField.optional(3, "part",
Types.IntegerType.get()))));
+ Table table = catalog.createTable(TableIdentifier.of("db1",
"nested_partition"), schema,
+ PartitionSpec.builderFor(schema).bucket("payload.part",
8).build());
+
+ ConnectorWritePartitionField field = providerFor(table,
contextWithStorage())
+ .getWritePartitioning(sessionFor(table, contextWithStorage()),
+ new IcebergTableHandle("db1", "nested_partition"))
+ .getFields().get(0);
+
+ Assertions.assertEquals("payload", field.getSourceColumnName(),
+ "merge exchange must route a nested source through its bound
top-level struct slot");
+ Assertions.assertEquals(Integer.valueOf(3), field.getSourceId());
+ }
+
// ───────────────────── getSyntheticWriteColumns (connector declares the
row-id STRUCT, ③ C3b-core) ─────────────────────
//
// WHY: post-flip the iceberg DML hidden column
__DORIS_ICEBERG_ROWID_COL__ that legacy
diff --git
a/fe/fe-core/src/main/java/org/apache/doris/datasource/scan/PluginDrivenScanNode.java
b/fe/fe-core/src/main/java/org/apache/doris/datasource/scan/PluginDrivenScanNode.java
index 51f45809c27..7710b46224c 100644
---
a/fe/fe-core/src/main/java/org/apache/doris/datasource/scan/PluginDrivenScanNode.java
+++
b/fe/fe-core/src/main/java/org/apache/doris/datasource/scan/PluginDrivenScanNode.java
@@ -2200,7 +2200,7 @@ public class PluginDrivenScanNode extends
FileQueryScanNode {
ConnectorColumnHandle ch = allHandles.get(name);
if (ch != null) {
selected.add(withProjectedFieldIds(ch, slot));
- } else if (pinnedNames.contains(name)) {
+ } else if (requiresPinnedColumnHandle(slot.getColumn(),
pinnedNames)) {
throw new UserException("Column '" + name + "' of table "
+ getTargetTable().getName() + " resolves in the
pinned time-travel schema"
+ " but has no connector column handle; refusing
to silently drop it"
@@ -2211,6 +2211,12 @@ public class PluginDrivenScanNode extends
FileQueryScanNode {
return selected;
}
+ static boolean requiresPinnedColumnHandle(Column column, Set<String>
pinnedNames) {
+ // A connector-reserved passthrough column is generated by the scan
provider rather than resolved
+ // from the physical table schema, so it may legitimately be absent
from the column-handle map.
+ return pinnedNames.contains(column.getName()) &&
!column.isReservedPassthrough();
+ }
+
static ConnectorColumnHandle withProjectedFieldIds(
ConnectorColumnHandle handle, SlotDescriptor slot) {
Set<Integer> projectedFieldIds = new HashSet<>();
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 099d6a1edb3..09c8bc24628 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
@@ -3388,7 +3388,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 f1c567b2515..6e722aba19d 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 MergePartitionField(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 MergePartitionField(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 b40366aa3b9..7c7a2c5003a 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
@@ -259,6 +259,9 @@ public class InsertIntoTableCommand extends Command
implements NeedAuditEncrypti
int retryTimes = 0;
ctx.getStatementContext().setIsInsert(true);
while (++retryTimes <
Math.max(ctx.getSessionVariable().dmlPlanRetryTimes, 3)) {
+ // Each internal attempt must repin connector metadata; retaining
the previous writer schema can
+ // plan defaults and partition fields against the table version
that triggered the retry.
+ ctx.getStatementContext().resetConnectorStatementScope();
TableIf targetTableIf = getTargetTableIf(ctx,
qualifiedTargetTableName);
DatabaseIf<?> targetDatabase = getTargetDatabase(targetTableIf);
// check auth
diff --git
a/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/physical/PhysicalExternalRowLevelMergeSink.java
b/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/physical/PhysicalExternalRowLevelMergeSink.java
index 292cf34fd1d..95de03f2e03 100644
---
a/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/physical/PhysicalExternalRowLevelMergeSink.java
+++
b/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/physical/PhysicalExternalRowLevelMergeSink.java
@@ -369,7 +369,8 @@ public class PhysicalExternalRowLevelMergeSink<CHILD_TYPE
extends Plan>
return new InsertPartitionFieldResult(false, false, null);
}
ConnectorWritePartitionSpec spec =
writePlanProvider.getWritePartitioning(session, handle);
- return reconstructPartitionFields(insertPartitionFields, spec,
columnExprIdMap, columnIdToExprId);
+ return reconstructPartitionFields(
+ insertPartitionFields, spec, columnExprIdMap,
columnIdToExprId, cols);
}
/**
@@ -397,7 +398,7 @@ public class PhysicalExternalRowLevelMergeSink<CHILD_TYPE
extends Plan>
ConnectorWritePartitionSpec spec,
Map<String, ExprId> columnExprIdMap) {
return reconstructPartitionFields(insertPartitionFields, spec,
columnExprIdMap,
- java.util.Collections.emptyMap());
+ java.util.Collections.emptyMap(), null);
}
static InsertPartitionFieldResult reconstructPartitionFields(
@@ -405,6 +406,25 @@ public class PhysicalExternalRowLevelMergeSink<CHILD_TYPE
extends Plan>
ConnectorWritePartitionSpec spec,
Map<String, ExprId> columnExprIdMap,
Map<Integer, ExprId> columnIdToExprId) {
+ return reconstructPartitionFields(
+ insertPartitionFields, spec, columnExprIdMap,
columnIdToExprId, null);
+ }
+
+ static InsertPartitionFieldResult reconstructPartitionFields(
+ List<DistributionSpecMerge.MergePartitionField>
insertPartitionFields,
+ ConnectorWritePartitionSpec spec,
+ Map<String, ExprId> columnExprIdMap,
+ List<Column> tableColumns) {
+ return reconstructPartitionFields(insertPartitionFields, spec,
columnExprIdMap,
+ java.util.Collections.emptyMap(), tableColumns);
+ }
+
+ static InsertPartitionFieldResult reconstructPartitionFields(
+ List<DistributionSpecMerge.MergePartitionField>
insertPartitionFields,
+ ConnectorWritePartitionSpec spec,
+ Map<String, ExprId> columnExprIdMap,
+ Map<Integer, ExprId> columnIdToExprId,
+ List<Column> tableColumns) {
if (spec == null) {
return new InsertPartitionFieldResult(false, false, null);
}
@@ -424,16 +444,32 @@ public class PhysicalExternalRowLevelMergeSink<CHILD_TYPE
extends Plan>
}
// Prefer the stable source field id carried by the bind-time
schema. A same-name replacement
// must not inherit the old output expression after concurrent
Iceberg schema evolution.
- ExprId exprId = columnIdToExprId.isEmpty()
- ? columnExprIdMap.get(sourceColumnName)
- : columnIdToExprId.get(field.getSourceId());
+ Column sourceColumn = findSourceColumn(tableColumns,
sourceColumnName);
+ ExprId exprId;
+ if (columnIdToExprId.isEmpty()) {
+ exprId = columnExprIdMap.get(sourceColumnName);
+ } else if (sourceColumn == null) {
+ // The id-only test seam has no column tree, so preserve its
exact top-level-id lookup.
+ exprId = tableColumns == null ?
columnIdToExprId.get(field.getSourceId()) : null;
+ } else {
+ // A nested Iceberg source id identifies a child, but the
Nereids slot and its ExprId belong to
+ // the top-level struct. Resolve the slot by its root id and
use sourceFieldPath for the child.
+ exprId = sourceColumn.getUniqueId() < 0
+ ? null :
columnIdToExprId.get(sourceColumn.getUniqueId());
+ }
if (exprId == null) {
insertPartitionFields.clear();
return new InsertPartitionFieldResult(false, hasNonIdentity,
spec.getSpecId());
}
+ List<Integer> sourceFieldPath = resolveSourceFieldPath(
+ tableColumns, sourceColumnName, field.getSourceId());
+ if (sourceFieldPath == null) {
+ insertPartitionFields.clear();
+ return new InsertPartitionFieldResult(false, hasNonIdentity,
spec.getSpecId());
+ }
insertPartitionFields.add(new
DistributionSpecMerge.MergePartitionField(
field.getTransform(), exprId, field.getTransformParam(),
- field.getFieldName(), field.getSourceId()));
+ field.getFieldName(), field.getSourceId(),
sourceFieldPath));
}
if (insertPartitionFields.isEmpty()) {
return new InsertPartitionFieldResult(false, hasNonIdentity,
spec.getSpecId());
@@ -441,6 +477,57 @@ public class PhysicalExternalRowLevelMergeSink<CHILD_TYPE
extends Plan>
return new InsertPartitionFieldResult(true, hasNonIdentity,
spec.getSpecId());
}
+ private static List<Integer> resolveSourceFieldPath(
+ List<Column> tableColumns, String sourceColumnName, int sourceId) {
+ if (tableColumns == null) {
+ return ImmutableList.of();
+ }
+ Column sourceColumn = findSourceColumn(tableColumns, sourceColumnName);
+ if (sourceColumn == null) {
+ return null;
+ }
+ if (sourceColumn.getUniqueId() < 0) {
+ // Without the root id an empty path cannot distinguish a
top-level source from an unstamped child.
+ return null;
+ }
+ if (sourceColumn.getUniqueId() == sourceId) {
+ return ImmutableList.of();
+ }
+ List<Integer> path = new ArrayList<>();
+ // Iceberg field ids are stable across rename/evolution; resolving by
id avoids ambiguous dotted names
+ // and keeps exchange routing on the same nested value used by the
writer.
+ return findSourceFieldPath(sourceColumn.getChildren(), sourceId, path)
+ ? ImmutableList.copyOf(path) : null;
+ }
+
+ private static Column findSourceColumn(List<Column> tableColumns, String
sourceColumnName) {
+ if (tableColumns == null) {
+ return null;
+ }
+ for (Column column : tableColumns) {
+ if (column.getName().equalsIgnoreCase(sourceColumnName)) {
+ return column;
+ }
+ }
+ return 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;
+ }
+
// Package-private (not private) so the same-package parity test can
assert on the reconstructed
// result of {@link #reconstructPartitionFields} directly, without driving
the full distribution.
static class InsertPartitionFieldResult {
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 e156fa4336f..0ef85f8ee67 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
@@ -161,14 +161,21 @@ public class DataPartition {
private final Integer param;
private final String name;
private final Integer sourceId;
+ private final ImmutableList<Integer> sourceFieldPath;
public MergePartitionField(Expr sourceExpr, String transform, Integer
param,
String name, Integer sourceId) {
+ this(sourceExpr, transform, param, name, sourceId,
ImmutableList.of());
+ }
+
+ public MergePartitionField(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() {
@@ -184,6 +191,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/scan/PluginDrivenScanNodeColumnPruningTest.java
b/fe/fe-core/src/test/java/org/apache/doris/datasource/scan/PluginDrivenScanNodeColumnPruningTest.java
index 4f7c8cbe9ca..9eccf6102ed 100644
---
a/fe/fe-core/src/test/java/org/apache/doris/datasource/scan/PluginDrivenScanNodeColumnPruningTest.java
+++
b/fe/fe-core/src/test/java/org/apache/doris/datasource/scan/PluginDrivenScanNodeColumnPruningTest.java
@@ -31,6 +31,7 @@ import org.junit.jupiter.api.Test;
import org.mockito.Mockito;
import java.util.ArrayList;
+import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
@@ -162,6 +163,20 @@ public class PluginDrivenScanNodeColumnPruningTest {
Assertions.assertSame(all.get("c1"), selected.get(0));
}
+ @Test
+ public void
testPinnedHandleGuardExemptsConnectorReservedPassthroughColumn() {
+ // Iceberg v3 row-lineage columns are part of the bound Doris schema
but are generated scan slots,
+ // not physical Iceberg schema columns. Requiring a connector handle
for one rejects every pinned
+ // v3 scan before the scan provider can append the generated field to
its schema dictionary.
+ Column rowId = new Column("_row_id", PrimitiveType.BIGINT);
+ rowId.setReservedPassthrough(true);
+
+ Assertions.assertFalse(PluginDrivenScanNode.requiresPinnedColumnHandle(
+ rowId, Collections.singleton("_row_id")));
+ Assertions.assertTrue(PluginDrivenScanNode.requiresPinnedColumnHandle(
+ new Column("physical_col", PrimitiveType.INT),
Collections.singleton("physical_col")));
+ }
+
@Test
public void testEmptyTupleProjectsNothing() {
// A tuple with no slots projects nothing — the ONLY input that makes
the jdbc connector fall back to
diff --git
a/fe/fe-core/src/test/java/org/apache/doris/nereids/trees/plans/physical/PhysicalExternalRowLevelMergeSinkTest.java
b/fe/fe-core/src/test/java/org/apache/doris/nereids/trees/plans/physical/PhysicalExternalRowLevelMergeSinkTest.java
index 793f14a3a83..970e80d380d 100644
---
a/fe/fe-core/src/test/java/org/apache/doris/nereids/trees/plans/physical/PhysicalExternalRowLevelMergeSinkTest.java
+++
b/fe/fe-core/src/test/java/org/apache/doris/nereids/trees/plans/physical/PhysicalExternalRowLevelMergeSinkTest.java
@@ -19,6 +19,9 @@ package org.apache.doris.nereids.trees.plans.physical;
import org.apache.doris.catalog.Column;
import org.apache.doris.catalog.PrimitiveType;
+import org.apache.doris.catalog.StructField;
+import org.apache.doris.catalog.StructType;
+import org.apache.doris.catalog.Type;
import org.apache.doris.common.jmockit.Deencapsulation;
import org.apache.doris.connector.spi.Connector;
import org.apache.doris.connector.spi.ConnectorMetadata;
@@ -132,6 +135,87 @@ public class PhysicalExternalRowLevelMergeSinkTest {
"transform param/name/sourceId must be carried verbatim from
the connector field");
}
+ @Test
+ public void reconstructCarriesNestedSourcePathWithoutRandomFallback() {
+ ExprId payload = exprId("payload");
+ List<MergePartitionField> out = new ArrayList<>();
+ ConnectorWritePartitionField nested = new ConnectorWritePartitionField(
+ "bucket[8]", 8, "payload", "payload_part_bucket", 3);
+ Column payloadColumn = new Column("payload", new StructType(
+ new StructField("part", Type.INT)));
+ payloadColumn.setUniqueId(2);
+ payloadColumn.getChildren().get(0).setUniqueId(3);
+
+ InsertPartitionFieldResult result =
PhysicalExternalRowLevelMergeSink.reconstructPartitionFields(
+ out, spec(4, nested), map("payload", payload),
ImmutableList.of(payloadColumn));
+
+ Assertions.assertTrue(result.success);
+ Assertions.assertEquals(ImmutableList.of(
+ new MergePartitionField("bucket[8]", payload, 8,
"payload_part_bucket", 3,
+ ImmutableList.of(0))), out);
+ }
+
+ @Test
+ public void reconstructNestedSourceUsesTopLevelIdMapFromProductionPath() {
+ ExprId payload = exprId("payload");
+ List<MergePartitionField> out = new ArrayList<>();
+ ConnectorWritePartitionField nested = new ConnectorWritePartitionField(
+ "bucket[8]", 8, "payload", "payload_part_bucket", 3);
+ Column payloadColumn = new Column("payload", new StructType(
+ new StructField("part", Type.INT)));
+ payloadColumn.setUniqueId(2);
+ payloadColumn.getChildren().get(0).setUniqueId(3);
+
+ InsertPartitionFieldResult result =
PhysicalExternalRowLevelMergeSink.reconstructPartitionFields(
+ out, spec(4, nested), map("payload", payload),
+ java.util.Collections.singletonMap(2, payload),
ImmutableList.of(payloadColumn));
+
+ // Production maps expressions by top-level column id, while Iceberg
partition sources carry the
+ // nested child id. The root id must select the slot and the child id
must select the path within it.
+ Assertions.assertTrue(result.success);
+ Assertions.assertEquals(ImmutableList.of(
+ new MergePartitionField("bucket[8]", payload, 8,
"payload_part_bucket", 3,
+ ImmutableList.of(0))), out);
+ }
+
+ @Test
+ public void reconstructUnstampedNestedSourceHardFails() {
+ ExprId payload = exprId("payload");
+ List<MergePartitionField> out = new ArrayList<>();
+ ConnectorWritePartitionField nested = new ConnectorWritePartitionField(
+ "bucket[8]", 8, "payload", "payload_part_bucket", 3);
+ Column payloadColumn = new Column("payload", new StructType(
+ new StructField("part", Type.INT)));
+
+ InsertPartitionFieldResult result =
PhysicalExternalRowLevelMergeSink.reconstructPartitionFields(
+ out, spec(4, nested), map("payload", payload),
ImmutableList.of(payloadColumn));
+
+ // An unstamped tree cannot prove whether the requested id is
top-level or nested. Treating it as an
+ // empty path would hash the whole struct and silently diverge from
the writer's partition source.
+ Assertions.assertFalse(result.success);
+ Assertions.assertTrue(out.isEmpty());
+ }
+
+ @Test
+ public void reconstructMissingNestedSourceIdHardFails() {
+ Column payloadColumn = new Column("payload", new StructType(
+ new StructField("part", Type.INT)));
+ payloadColumn.setUniqueId(2);
+ payloadColumn.getChildren().get(0).setUniqueId(3);
+ List<MergePartitionField> out = new ArrayList<>();
+
+ InsertPartitionFieldResult result =
PhysicalExternalRowLevelMergeSink.reconstructPartitionFields(
+ out,
+ spec(4, new ConnectorWritePartitionField(
+ "bucket[8]", 8, "payload", "payload_part_bucket", 99)),
+ map("payload", exprId("payload")),
+ ImmutableList.of(payloadColumn));
+
+ Assertions.assertFalse(result.success,
+ "an evolved schema must not hash the whole struct when the
nested source id disappeared");
+ Assertions.assertTrue(out.isEmpty());
+ }
+
@Test
public void reconstructNullSourceColumnNameHardFailsAndClears() {
// PARITY-1a: a null source-column-name field hard-fails the whole
spec; the already-added prior
@@ -220,6 +304,7 @@ public class PhysicalExternalRowLevelMergeSinkTest {
// must flow into the DistributionSpecMerge: one identity partition
column 'id' resolved to the
// child's id slot, insertRandom=false, spec id carried.
Column id = new Column("id", PrimitiveType.INT);
+ id.setUniqueId(1);
SlotReference idSlot = new SlotReference("id", IntegerType.INSTANCE);
SlotReference opSlot = new
SlotReference(MergeOperation.OPERATION_COLUMN, IntegerType.INSTANCE);
SlotReference rowidSlot = new SlotReference(Column.ICEBERG_ROWID_COL,
IntegerType.INSTANCE);
diff --git a/gensrc/thrift/Partitions.thrift b/gensrc/thrift/Partitions.thrift
index 19ab0a17dc7..da172fac735 100644
--- a/gensrc/thrift/Partitions.thrift
+++ b/gensrc/thrift/Partitions.thrift
@@ -183,6 +183,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]