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 8305fe71867 [fix](iceberg) Enforce external write correctness (#66112)
8305fe71867 is described below
commit 8305fe71867fb2acab5a769fe9a098ada59aff50
Author: Gabriel <[email protected]>
AuthorDate: Wed Jul 29 15:49:02 2026 +0800
[fix](iceberg) Enforce external write correctness (#66112)
## Proposed changes
- Validate unsupported CTAS sinks before publishing table metadata, so
setup failures cannot leave an unusable table or remove a concurrent
replacement.
- Preserve nullable string wrappers in truncate partition transforms.
- Enforce Iceberg MERGE cardinality routing independently of
`enable_strict_consistency_dml`.
- Detect duplicate target matches with file-path-interned Roaring
bitmaps and expose retained validation-state bytes in the sink profile.
- Activate the negative regressions for the three retained Jira fixes
and align the duplicate-match oracle with the emitted diagnostic.
## Test
- Full build: `./build.sh --fe --be -j 48`
- FE: `CreateTableCommandTest`, `IcebergDDLAndDMLPlanTest` (19 tests)
- BE: `VIcebergMergeSinkTest` (8 tests, including 100,000 matched rows)
- Regression: `test_paimon_ctas_atomicity_negative` (1 suite, passed)
- Iceberg end-to-end SQL: duplicate-source MERGE rejected atomically;
nullable/merge truncate writes and logical rows verified; physical
partition values verified from the same Iceberg tables through Spark
metadata.
The Doris-side `$partitions`/`$snapshots` checks in the Iceberg suites
currently hit an unrelated JNI scanner initialization abort on the
current master test binary; the equivalent table state and metadata
assertions above passed.
---
be/src/agent/be_exec_version_manager.cpp | 4 +-
be/src/agent/be_exec_version_manager.h | 1 +
be/src/exec/sink/viceberg_delete_sink.cpp | 53 +++-
be/src/exec/sink/viceberg_delete_sink.h | 8 +
be/src/exec/sink/viceberg_merge_sink.cpp | 142 ++++++++++-
be/src/exec/sink/viceberg_merge_sink.h | 7 +
.../sink/writer/iceberg/partition_transformers.h | 5 +-
.../writer/iceberg/viceberg_delete_file_writer.h | 2 +
.../writer/iceberg/viceberg_partition_writer.cpp | 30 ++-
.../writer/iceberg/viceberg_partition_writer.h | 9 +-
.../sink/writer/iceberg/viceberg_table_writer.cpp | 33 ++-
.../sink/writer/iceberg/viceberg_table_writer.h | 12 +
be/test/exec/sink/viceberg_merge_sink_test.cpp | 275 ++++++++++++++++++++-
.../writer/iceberg/partition_transformers_test.cpp | 26 ++
.../main/java/org/apache/doris/common/Config.java | 2 +-
.../doris/datasource/paimon/PaimonMetadataOps.java | 27 +-
.../glue/translator/PhysicalPlanTranslator.java | 3 +-
.../nereids/properties/RequestPropertyDeriver.java | 5 +-
...IcebergMergeSinkToPhysicalIcebergMergeSink.java | 1 +
.../trees/plans/commands/CreateTableCommand.java | 35 ++-
.../trees/plans/commands/IcebergMergeCommand.java | 1 +
.../trees/plans/commands/IcebergUpdateCommand.java | 1 +
.../plans/logical/LogicalIcebergMergeSink.java | 26 +-
.../plans/physical/PhysicalIcebergMergeSink.java | 40 ++-
.../org/apache/doris/planner/IcebergMergeSink.java | 7 +-
.../iceberg/IcebergDDLAndDMLPlanTest.java | 149 ++++++++++-
.../datasource/paimon/PaimonMetadataOpsTest.java | 64 ++++-
.../plans/commands/CreateTableCommandTest.java | 133 ++++++++++
.../commands/insert/IcebergMergeExecutorTest.java | 3 +-
.../apache/doris/planner/IcebergMergeSinkTest.java | 18 +-
gensrc/thrift/DataSinks.thrift | 2 +
...eberg_write_merge_duplicate_source_negative.out | 2 +
.../PAIMON_ICEBERG_READ_WRITE_P0_COVERAGE.md | 9 +-
.../iceberg/write/ICEBERG_WRITE_P0_COVERAGE.md | 16 +-
...rg_write_merge_duplicate_source_negative.groovy | 93 ++++++-
...st_iceberg_write_merge_truncate_negative.groovy | 15 +-
...iceberg_write_nullable_truncate_negative.groovy | 8 -
.../test_paimon_ctas_atomicity_negative.groovy | 30 ++-
38 files changed, 1175 insertions(+), 122 deletions(-)
diff --git a/be/src/agent/be_exec_version_manager.cpp
b/be/src/agent/be_exec_version_manager.cpp
index 9ad9d9e9a59..4c3bdbd9f31 100644
--- a/be/src/agent/be_exec_version_manager.cpp
+++ b/be/src/agent/be_exec_version_manager.cpp
@@ -124,8 +124,10 @@ void
BeExecVersionManager::check_function_compatibility(int current_be_exec_vers
// 10: start from doris 4.0.3
// a. use new fixed object serialization way.
+// 11: start from master
+// a. enforce Iceberg SQL MERGE cardinality only when every executing BE
supports it.
-const int BeExecVersionManager::max_be_exec_version = 10;
+const int BeExecVersionManager::max_be_exec_version = 11;
const int BeExecVersionManager::min_be_exec_version = 0;
std::map<std::string, std::set<int>>
BeExecVersionManager::_function_change_map {};
std::set<std::string> BeExecVersionManager::_function_restrict_map;
diff --git a/be/src/agent/be_exec_version_manager.h
b/be/src/agent/be_exec_version_manager.h
index 92c2c945f25..c1a40e35a07 100644
--- a/be/src/agent/be_exec_version_manager.h
+++ b/be/src/agent/be_exec_version_manager.h
@@ -26,6 +26,7 @@
namespace doris {
constexpr inline int USE_NEW_FIXED_OBJECT_SERIALIZATION_VERSION = 10;
+constexpr inline int SUPPORT_ICEBERG_MERGE_CARDINALITY_VERSION = 11;
class BeExecVersionManager {
public:
diff --git a/be/src/exec/sink/viceberg_delete_sink.cpp
b/be/src/exec/sink/viceberg_delete_sink.cpp
index ef167ecdbca..172fdd28177 100644
--- a/be/src/exec/sink/viceberg_delete_sink.cpp
+++ b/be/src/exec/sink/viceberg_delete_sink.cpp
@@ -40,6 +40,7 @@
#include "format/transformer/vfile_format_transformer.h"
#include "io/file_factory.h"
#include "runtime/runtime_state.h"
+#include "util/debug_points.h"
#include "util/slice.h"
#include "util/string_util.h"
#include "util/uid_util.h"
@@ -252,17 +253,26 @@ Status VIcebergDeleteSink::close(Status close_status) {
if (!close_status.ok()) {
LOG(WARNING) << fmt::format("VIcebergDeleteSink close with error: {}",
close_status.to_string());
+ _cleanup_created_files();
return close_status;
}
+ Status write_status = Status::OK();
+ DBUG_EXECUTE_IF("VIcebergDeleteSink.close.inject_failure", {
+ write_status = Status::InternalError("injected Iceberg delete close
failure");
+ });
+
if (_delete_type == TFileContent::POSITION_DELETES &&
!_file_deletions.empty()) {
SCOPED_TIMER(_write_delete_files_timer);
- if (_format_version >= 3) {
- RETURN_IF_ERROR(_write_deletion_vector_files(_file_deletions));
- } else {
- RETURN_IF_ERROR(_write_position_delete_files(_file_deletions));
+ if (write_status.ok()) {
+ write_status = _format_version >= 3 ?
_write_deletion_vector_files(_file_deletions)
+ :
_write_position_delete_files(_file_deletions);
}
}
+ if (!write_status.ok()) {
+ _cleanup_created_files();
+ return write_status;
+ }
// Update counters
if (_delete_file_count_counter) {
@@ -278,9 +288,33 @@ Status VIcebergDeleteSink::close(Status close_status) {
}
}
+ if (!_defer_file_cleanup_until_outer_close) {
+ _created_files.clear();
+ }
+
return Status::OK();
}
+void VIcebergDeleteSink::finish_deferred_file_cleanup(Status outer_status) {
+ if (!outer_status.ok()) {
+ _cleanup_created_files();
+ } else {
+ _created_files.clear();
+ }
+ _defer_file_cleanup_until_outer_close = false;
+}
+
+void VIcebergDeleteSink::_cleanup_created_files() {
+ for (const auto& [fs, path] : _created_files) {
+ Status delete_status = fs->delete_file(path);
+ if (!delete_status.ok() && !delete_status.is<ErrorCode::NOT_FOUND>()) {
+ LOG(WARNING) << fmt::format("Failed to delete Iceberg delete file
{}: {}", path,
+ delete_status.to_string());
+ }
+ }
+ _created_files.clear();
+}
+
int VIcebergDeleteSink::_get_row_id_column_index(const Block& block) {
// Find __DORIS_ICEBERG_ROWID_COL__ column in block
for (size_t i = 0; i < block.columns(); ++i) {
@@ -448,9 +482,13 @@ Status VIcebergDeleteSink::_write_position_delete_files(
}
// Open writer
- RETURN_IF_ERROR(writer->open(_state, _state->runtime_profile(),
- _position_delete_output_expr_ctxs,
column_names, _hadoop_conf,
- _file_type, _broker_addresses));
+ Status open_status =
+ writer->open(_state, _state->runtime_profile(),
_position_delete_output_expr_ctxs,
+ column_names, _hadoop_conf, _file_type,
_broker_addresses);
+ if (writer->file_system() != nullptr) {
+ _created_files.emplace_back(writer->file_system(),
delete_file_path);
+ }
+ RETURN_IF_ERROR(open_status);
// Build block with (file_path, pos) columns
std::vector<int64_t> positions;
@@ -669,6 +707,7 @@ Status VIcebergDeleteSink::_write_puffin_file(const
std::string& puffin_path,
auto fs = DORIS_TRY(FileFactory::create_fs(fs_properties,
file_description));
io::FileWriterOptions file_writer_options = {.used_by_s3_committer =
false};
io::FileWriterPtr file_writer;
+ _created_files.emplace_back(fs, puffin_path);
RETURN_IF_ERROR(fs->create_file(file_description.path, &file_writer,
&file_writer_options));
constexpr char PUFFIN_MAGIC[] = {'\x50', '\x46', '\x41', '\x31'};
diff --git a/be/src/exec/sink/viceberg_delete_sink.h
b/be/src/exec/sink/viceberg_delete_sink.h
index 647ee92f525..55698ae0404 100644
--- a/be/src/exec/sink/viceberg_delete_sink.h
+++ b/be/src/exec/sink/viceberg_delete_sink.h
@@ -77,6 +77,10 @@ public:
Status close(Status) override;
+ void defer_file_cleanup_until_outer_close() {
_defer_file_cleanup_until_outer_close = true; }
+
+ void finish_deferred_file_cleanup(Status outer_status);
+
private:
/**
* Extract $row_id column from block and group by file_path.
@@ -129,6 +133,7 @@ private:
const std::vector<int64_t>& positions,
Block& output_block);
Status _init_position_delete_output_exprs();
std::string _get_file_extension() const;
+ void _cleanup_created_files();
TDataSink _t_sink;
RuntimeState* _state = nullptr;
@@ -141,6 +146,9 @@ private:
// Collected commit data from all writers
std::vector<TIcebergCommitData> _commit_data_list;
+ // MERGE owns both data and delete objects until both inner sinks close
successfully.
+ std::vector<std::pair<std::shared_ptr<io::FileSystem>, std::string>>
_created_files;
+ bool _defer_file_cleanup_until_outer_close = false;
// TODO: All deletions are held in memory until close(). Consider flushing
// per-file when the upstream guarantees file_path ordering, or flushing
// when estimated memory exceeds a threshold, to reduce peak memory usage.
diff --git a/be/src/exec/sink/viceberg_merge_sink.cpp
b/be/src/exec/sink/viceberg_merge_sink.cpp
index e9f4d6bf5c6..5ff5a0a1f28 100644
--- a/be/src/exec/sink/viceberg_merge_sink.cpp
+++ b/be/src/exec/sink/viceberg_merge_sink.cpp
@@ -19,12 +19,17 @@
#include <fmt/format.h>
+#include "agent/be_exec_version_manager.h"
#include "common/consts.h"
#include "common/exception.h"
#include "common/logging.h"
#include "core/block/block.h"
#include "core/column/column_nullable.h"
+#include "core/column/column_string.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/sink_common.h"
#include "exec/sink/viceberg_delete_sink.h"
#include "exec/sink/writer/iceberg/viceberg_table_writer.h"
@@ -52,8 +57,10 @@ Status VIcebergMergeSink::init_properties(ObjectPool* pool,
const RowDescriptor&
_table_writer = std::make_unique<VIcebergTableWriter>(_table_sink,
_table_output_expr_ctxs,
nullptr, nullptr);
+ _table_writer->defer_file_cleanup_until_outer_close();
_delete_writer = std::make_unique<VIcebergDeleteSink>(_delete_sink,
_delete_output_expr_ctxs,
nullptr, nullptr);
+ _delete_writer->defer_file_cleanup_until_outer_close();
RETURN_IF_ERROR(_table_writer->init_properties(pool, row_desc));
RETURN_IF_ERROR(_delete_writer->init_properties(pool));
return Status::OK();
@@ -65,6 +72,14 @@ Status VIcebergMergeSink::open(RuntimeState* state,
RuntimeProfile* profile) {
_written_rows_counter = ADD_COUNTER(profile, "RowsWritten", TUnit::UNIT);
_insert_rows_counter = ADD_COUNTER(profile, "InsertRows", TUnit::UNIT);
_delete_rows_counter = ADD_COUNTER(profile, "DeleteRows", TUnit::UNIT);
+ // The query-wide version keeps validation all-or-nothing during a rolling
BE upgrade.
+ _require_merge_cardinality_check =
+ _require_merge_cardinality_check &&
+ state->be_exec_version() >=
SUPPORT_ICEBERG_MERGE_CARDINALITY_VERSION;
+ if (_require_merge_cardinality_check) {
+ _matched_row_id_state_bytes_counter =
+ ADD_COUNTER(profile, "MatchedRowIdStateBytes", TUnit::BYTES);
+ }
_send_data_timer = ADD_TIMER(profile, "SendDataTime");
_open_timer = ADD_TIMER(profile, "OpenTime");
_close_timer = ADD_TIMER(profile, "CloseTime");
@@ -94,8 +109,6 @@ Status VIcebergMergeSink::write(RuntimeState* state, Block&
block) {
return Status::OK();
}
- _row_count += output_block.rows();
-
if (_operation_idx < 0 || _row_id_idx < 0) {
return Status::InternalError("Iceberg merge sink missing
operation/row_id columns");
}
@@ -120,17 +133,26 @@ Status VIcebergMergeSink::write(RuntimeState* state,
Block& block) {
if (delete_op) {
delete_filter[i] = 1;
has_delete = true;
- ++_delete_row_count;
++delete_rows;
}
if (insert_op) {
insert_filter[i] = 1;
has_insert = true;
- ++_insert_row_count;
++insert_rows;
}
}
+ if (_require_merge_cardinality_check) {
+ // The physical sink hashes matched rows by row_id, so exact state
retained across blocks
+ // enforces SQL MERGE cardinality for the whole query without changing
UPDATE semantics.
+ RETURN_IF_ERROR(_validate_matched_row_ids(output_block,
delete_filter.data()));
+ COUNTER_SET(_matched_row_id_state_bytes_counter,
+ static_cast<int64_t>(_matched_row_id_state_size));
+ }
+ _row_count += output_block.rows();
+ _delete_row_count += delete_rows;
+ _insert_row_count += insert_rows;
+
bool skip_io = false;
#ifdef BE_TEST
skip_io = _skip_io;
@@ -167,6 +189,105 @@ Status VIcebergMergeSink::write(RuntimeState* state,
Block& block) {
return Status::OK();
}
+Status VIcebergMergeSink::_validate_matched_row_ids(const Block& block,
+ const uint8_t*
delete_filter) {
+ const auto& row_id = block.get_by_position(_row_id_idx);
+ const IColumn* row_id_data = row_id.column.get();
+ const IDataType* row_id_type = row_id.type.get();
+ const auto* nullable_row_id =
check_and_get_column<ColumnNullable>(row_id_data);
+ if (nullable_row_id != nullptr) {
+ row_id_data = nullable_row_id->get_nested_column_ptr().get();
+ }
+ if (const auto* nullable_type =
check_and_get_data_type<DataTypeNullable>(row_id_type)) {
+ row_id_type = nullable_type->get_nested_type().get();
+ }
+
+ const auto* struct_column =
check_and_get_column<ColumnStruct>(row_id_data);
+ const auto* struct_type =
check_and_get_data_type<DataTypeStruct>(row_id_type);
+ if (struct_column == nullptr || struct_type == nullptr) {
+ return Status::InternalError("Iceberg merge row_id column is not a
struct");
+ }
+
+ int file_path_idx = -1;
+ int row_position_idx = -1;
+ const auto& field_names = struct_type->get_element_names();
+ for (size_t i = 0; i < field_names.size(); ++i) {
+ std::string field_name = doris::to_lower(field_names[i]);
+ if (field_name == "file_path") {
+ file_path_idx = static_cast<int>(i);
+ } else if (field_name == "row_position") {
+ row_position_idx = static_cast<int>(i);
+ }
+ }
+ if (file_path_idx < 0 || row_position_idx < 0) {
+ return Status::InternalError(
+ "Iceberg merge row_id must contain file_path and row_position
fields");
+ }
+
+ const auto& file_path_column =
struct_column->get_column_ptr(file_path_idx);
+ const auto& row_position_column =
struct_column->get_column_ptr(row_position_idx);
+ const auto* nullable_file_path =
check_and_get_column<ColumnNullable>(file_path_column.get());
+ const auto* nullable_row_position =
+ check_and_get_column<ColumnNullable>(row_position_column.get());
+ const auto* file_paths =
+
check_and_get_column<ColumnString>(remove_nullable(file_path_column).get());
+ const auto* row_positions =
check_and_get_column<ColumnVector<TYPE_BIGINT>>(
+ remove_nullable(row_position_column).get());
+ if (file_paths == nullptr || row_positions == nullptr) {
+ return Status::InternalError("Iceberg merge row_id fields have
incorrect types");
+ }
+
+ std::map<roaring::Roaring64Map*, size_t> touched_bitmap_sizes;
+ for (size_t i = 0; i < block.rows(); ++i) {
+ if (delete_filter[i] == 0) {
+ continue;
+ }
+ if ((nullable_row_id != nullptr && nullable_row_id->is_null_at(i)) ||
+ (nullable_file_path != nullptr &&
nullable_file_path->is_null_at(i)) ||
+ (nullable_row_position != nullptr &&
nullable_row_position->is_null_at(i))) {
+ return Status::InternalError("Iceberg merge matched row_id cannot
be null");
+ }
+
+ int64_t row_position = row_positions->get_element(i);
+ if (row_position < 0) {
+ return Status::InternalError("Invalid row_position {} in Iceberg
merge row_id",
+ row_position);
+ }
+ // Intern each file path once and keep exact positions in a compressed
bitmap; retaining a
+ // full path string per matched row makes MERGE memory grow with
path_length * row_count.
+ auto [file_it, inserted] =
+
_matched_row_positions.try_emplace(file_paths->get_data_at(i).to_string());
+ auto* positions = &file_it->second;
+ auto touched_it = touched_bitmap_sizes.find(positions);
+ if (touched_it == touched_bitmap_sizes.end()) {
+ touched_it = touched_bitmap_sizes.emplace(positions,
positions->getSizeInBytes()).first;
+ }
+ if (inserted) {
+ _matched_row_id_state_size +=
+ sizeof(std::pair<const std::string,
roaring::Roaring64Map>);
+ _matched_row_id_state_size += file_it->first.capacity();
+ _matched_row_id_state_size += touched_it->second;
+ }
+ if (!positions->addChecked(static_cast<uint64_t>(row_position))) {
+ return Status::InvalidArgument(
+ "Iceberg MERGE failed because multiple source rows matched
the same target "
+ "row");
+ }
+ }
+
+ // Measure only bitmaps touched by this block; rescanning all retained
files on every write
+ // makes a many-file MERGE quadratic in the number of input blocks.
+ for (const auto& [positions, previous_size] : touched_bitmap_sizes) {
+ size_t current_size = positions->getSizeInBytes();
+ if (current_size >= previous_size) {
+ _matched_row_id_state_size += current_size - previous_size;
+ } else {
+ _matched_row_id_state_size -= previous_size - current_size;
+ }
+ }
+ return Status::OK();
+}
+
Status VIcebergMergeSink::close(Status close_status) {
SCOPED_TIMER(_close_timer);
@@ -201,10 +322,14 @@ Status VIcebergMergeSink::close(Status close_status) {
COUNTER_SET(_delete_rows_counter,
static_cast<int64_t>(_delete_row_count));
}
- if (!table_status.ok()) {
- return table_status;
+ Status result_status = table_status.ok() ? delete_status : table_status;
+ if (_table_writer) {
+ _table_writer->finish_deferred_file_cleanup(result_status);
+ }
+ if (_delete_writer) {
+ _delete_writer->finish_deferred_file_cleanup(result_status);
}
- return delete_status;
+ return result_status;
}
Status VIcebergMergeSink::_build_inner_sinks() {
@@ -213,6 +338,9 @@ Status VIcebergMergeSink::_build_inner_sinks() {
}
const auto& merge_sink = _t_sink.iceberg_merge_sink;
+ // Missing means an old FE plan, which predates SQL MERGE cardinality
validation.
+ _require_merge_cardinality_check =
merge_sink.__isset.require_merge_cardinality_check &&
+
merge_sink.require_merge_cardinality_check;
TIcebergTableSink table_sink;
if (merge_sink.__isset.db_name) {
diff --git a/be/src/exec/sink/viceberg_merge_sink.h
b/be/src/exec/sink/viceberg_merge_sink.h
index d7f87b87012..f3733a33182 100644
--- a/be/src/exec/sink/viceberg_merge_sink.h
+++ b/be/src/exec/sink/viceberg_merge_sink.h
@@ -19,6 +19,7 @@
#include <gen_cpp/DataSinks_types.h>
+#include <map>
#include <memory>
#include <string>
#include <vector>
@@ -26,6 +27,7 @@
#include "common/status.h"
#include "exec/sink/writer/async_result_writer.h"
#include "exprs/vexpr_fwd.h"
+#include "roaring/roaring64map.hh"
#include "runtime/descriptors.h"
#include "runtime/runtime_profile.h"
@@ -60,6 +62,7 @@ public:
private:
Status _build_inner_sinks();
Status _prepare_output_layout();
+ Status _validate_matched_row_ids(const Block& block, const uint8_t*
delete_filter);
TDataSink _t_sink;
TDataSink _table_sink;
@@ -73,6 +76,9 @@ private:
int _operation_idx = -1;
int _row_id_idx = -1;
std::vector<int> _data_column_indices;
+ std::map<std::string, roaring::Roaring64Map> _matched_row_positions;
+ size_t _matched_row_id_state_size = sizeof(std::map<std::string,
roaring::Roaring64Map>);
+ bool _require_merge_cardinality_check = false;
VExprContextSPtrs _table_output_expr_ctxs;
VExprContextSPtrs _delete_output_expr_ctxs;
@@ -84,6 +90,7 @@ private:
RuntimeProfile::Counter* _written_rows_counter = nullptr;
RuntimeProfile::Counter* _insert_rows_counter = nullptr;
RuntimeProfile::Counter* _delete_rows_counter = nullptr;
+ RuntimeProfile::Counter* _matched_row_id_state_bytes_counter = nullptr;
RuntimeProfile::Counter* _send_data_timer = nullptr;
RuntimeProfile::Counter* _open_timer = nullptr;
RuntimeProfile::Counter* _close_timer = nullptr;
diff --git a/be/src/exec/sink/writer/iceberg/partition_transformers.h
b/be/src/exec/sink/writer/iceberg/partition_transformers.h
index b9dcceb3e80..51bca7000ad 100644
--- a/be/src/exec/sink/writer/iceberg/partition_transformers.h
+++ b/be/src/exec/sink/writer/iceberg/partition_transformers.h
@@ -167,7 +167,10 @@ public:
// Create a temp_block to execute substring function.
Block temp_block;
- temp_block.insert(column_with_type_and_name);
+ // Substring requires the physical ColumnString; preserve nullability
separately and
+ // restore the original null map after transforming the nested values.
+ temp_block.insert({string_column_ptr,
remove_nullable(column_with_type_and_name.type),
+ column_with_type_and_name.name});
temp_block.insert({int_type->create_column_const(temp_block.rows(),
to_field<TYPE_INT>(1)),
int_type, "const 1"});
temp_block.insert(
diff --git a/be/src/exec/sink/writer/iceberg/viceberg_delete_file_writer.h
b/be/src/exec/sink/writer/iceberg/viceberg_delete_file_writer.h
index c242731dc15..4ceb7080a3c 100644
--- a/be/src/exec/sink/writer/iceberg/viceberg_delete_file_writer.h
+++ b/be/src/exec/sink/writer/iceberg/viceberg_delete_file_writer.h
@@ -94,6 +94,8 @@ public:
*/
int64_t get_file_size() const { return _file_size; }
+ std::shared_ptr<io::FileSystem> file_system() const { return _fs; }
+
private:
TFileContent::type _delete_type;
std::string _output_path;
diff --git a/be/src/exec/sink/writer/iceberg/viceberg_partition_writer.cpp
b/be/src/exec/sink/writer/iceberg/viceberg_partition_writer.cpp
index 5958165ad03..ba7644daec7 100644
--- a/be/src/exec/sink/writer/iceberg/viceberg_partition_writer.cpp
+++ b/be/src/exec/sink/writer/iceberg/viceberg_partition_writer.cpp
@@ -36,7 +36,8 @@ VIcebergPartitionWriter::VIcebergPartitionWriter(
const std::string* iceberg_schema_json, std::vector<std::string>
write_column_names,
WriteInfo write_info, std::string file_name, int file_name_index,
TFileFormatType::type file_format_type, TFileCompressType::type
compress_type,
- const std::map<std::string, std::string>& hadoop_conf)
+ const std::map<std::string, std::string>& hadoop_conf,
+ ClosedFileCallback closed_file_callback)
: _partition_values(std::move(partition_values)),
_write_output_expr_ctxs(write_output_expr_ctxs),
_schema(schema),
@@ -47,7 +48,8 @@ VIcebergPartitionWriter::VIcebergPartitionWriter(
_file_name_index(file_name_index),
_file_format_type(file_format_type),
_compress_type(compress_type),
- _hadoop_conf(hadoop_conf) {
+ _hadoop_conf(hadoop_conf),
+ _closed_file_callback(std::move(closed_file_callback)) {
if (t_sink.iceberg_table_sink.__isset.collect_column_stats) {
_collect_column_stats = t_sink.iceberg_table_sink.collect_column_stats;
}
@@ -61,9 +63,8 @@ Status VIcebergPartitionWriter::open(RuntimeState* state,
RuntimeProfile* profil
if (!_write_info.broker_addresses.empty()) {
fs_properties.broker_addresses = &(_write_info.broker_addresses);
}
- io::FileDescription file_description = {
- .path = fmt::format("{}/{}", _write_info.write_path,
_get_target_file_name()),
- .fs_name {}};
+ _path = fmt::format("{}/{}", _write_info.write_path,
_get_target_file_name());
+ io::FileDescription file_description = {.path = _path, .fs_name {}};
_fs = DORIS_TRY(FileFactory::create_fs(fs_properties, file_description));
io::FileWriterOptions file_writer_options = {.used_by_s3_committer =
false};
RETURN_IF_ERROR(_fs->create_file(file_description.path, &_file_writer,
&file_writer_options));
@@ -129,16 +130,27 @@ Status VIcebergPartitionWriter::close(const Status&
status) {
bool status_ok = result_status.ok() && status.ok();
if (!status_ok && _fs != nullptr) {
// delete the actual created file, otherwise an orphan file is left
behind
- auto path = fmt::format("{}/{}", _write_info.write_path,
_get_target_file_name());
- Status st = _fs->delete_file(path);
+ Status st = _fs->delete_file(_path);
if (!st.ok()) {
- LOG(WARNING) << fmt::format("Delete file {} failed, reason: {}",
path, st.to_string());
+ LOG(WARNING) << fmt::format("Delete file {} failed, reason: {}",
_path, st.to_string());
}
}
if (status_ok) {
TIcebergCommitData commit_data;
- RETURN_IF_ERROR(_build_iceberg_commit_data(&commit_data));
+ Status commit_status = _build_iceberg_commit_data(&commit_data);
+ if (!commit_status.ok()) {
+ // A closed object without commit data can never be published, so
remove it immediately.
+ Status delete_status = _fs->delete_file(_path);
+ if (!delete_status.ok()) {
+ LOG(WARNING) << fmt::format("Delete file {} failed, reason:
{}", _path,
+ delete_status.to_string());
+ }
+ return commit_status;
+ }
_state->add_iceberg_commit_datas(commit_data);
+ if (_closed_file_callback) {
+ _closed_file_callback(_fs, _path);
+ }
}
return result_status;
}
diff --git a/be/src/exec/sink/writer/iceberg/viceberg_partition_writer.h
b/be/src/exec/sink/writer/iceberg/viceberg_partition_writer.h
index b0839a82ed3..f3a7a6002f8 100644
--- a/be/src/exec/sink/writer/iceberg/viceberg_partition_writer.h
+++ b/be/src/exec/sink/writer/iceberg/viceberg_partition_writer.h
@@ -19,6 +19,8 @@
#include <gen_cpp/DataSinks_types.h>
+#include <functional>
+
#include "exec/sink/writer/iceberg/vpartition_writer_base.h"
#include "exprs/vexpr_fwd.h"
#include "format/table/iceberg/schema.h"
@@ -43,6 +45,9 @@ class VFileFormatTransformer;
class VIcebergPartitionWriter : public IPartitionWriterBase {
public:
+ using ClosedFileCallback =
+ std::function<void(std::shared_ptr<io::FileSystem>, const
std::string&)>;
+
VIcebergPartitionWriter(const TDataSink& t_sink, std::vector<std::string>
partition_values,
const VExprContextSPtrs& write_output_expr_ctxs,
const doris::iceberg::Schema& schema,
@@ -51,7 +56,8 @@ public:
std::string file_name, int file_name_index,
TFileFormatType::type file_format_type,
TFileCompressType::type compress_type,
- const std::map<std::string, std::string>&
hadoop_conf);
+ const std::map<std::string, std::string>&
hadoop_conf,
+ ClosedFileCallback closed_file_callback = nullptr);
Status open(RuntimeState* state, RuntimeProfile* profile,
const RowDescriptor* row_desc) override;
@@ -93,6 +99,7 @@ private:
TFileFormatType::type _file_format_type;
TFileCompressType::type _compress_type;
const std::map<std::string, std::string>& _hadoop_conf;
+ ClosedFileCallback _closed_file_callback;
bool _collect_column_stats = true;
std::shared_ptr<io::FileSystem> _fs = nullptr;
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 0a3b0843115..80db0876a9b 100644
--- a/be/src/exec/sink/writer/iceberg/viceberg_table_writer.cpp
+++ b/be/src/exec/sink/writer/iceberg/viceberg_table_writer.cpp
@@ -33,6 +33,7 @@
#include "exprs/vexpr_context.h"
#include "format/table/iceberg/partition_spec_parser.h"
#include "format/table/iceberg/schema_parser.h"
+#include "io/fs/file_system.h"
#include "runtime/runtime_state.h"
namespace doris {
@@ -485,9 +486,36 @@ Status VIcebergTableWriter::close(Status status) {
COUNTER_SET(_close_timer, _close_ns);
COUNTER_SET(_write_file_counter, _write_file_count);
}
+ if (!status.ok() || !result_status.ok()) {
+ _cleanup_closed_files();
+ } else if (!_defer_file_cleanup_until_outer_close) {
+ _closed_files.clear();
+ }
return result_status;
}
+void VIcebergTableWriter::finish_deferred_file_cleanup(Status outer_status) {
+ // A successful inner close does not publish MERGE files; the sibling
delete close still
+ // decides whether these data objects must be removed or released to the
transaction.
+ if (!outer_status.ok()) {
+ _cleanup_closed_files();
+ } else {
+ _closed_files.clear();
+ }
+ _defer_file_cleanup_until_outer_close = false;
+}
+
+void VIcebergTableWriter::_cleanup_closed_files() {
+ for (const auto& [fs, path] : _closed_files) {
+ Status delete_status = fs->delete_file(path);
+ if (!delete_status.ok()) {
+ LOG(WARNING) << fmt::format("Delete rolled Iceberg file {} failed,
reason: {}", path,
+ delete_status.to_string());
+ }
+ }
+ _closed_files.clear();
+}
+
std::string VIcebergTableWriter::_partition_to_path(const
doris::iceberg::StructLike& data) {
std::stringstream ss;
for (size_t i = 0; i < _iceberg_partition_columns.size(); i++) {
@@ -611,7 +639,10 @@ std::shared_ptr<IPartitionWriterBase>
VIcebergTableWriter::_create_partition_wri
&_t_sink.iceberg_table_sink.schema_json, column_names,
write_info,
(file_name == nullptr) ? _compute_file_name() : *file_name,
file_name_index,
iceberg_table_sink.file_format,
iceberg_table_sink.compression_type,
- iceberg_table_sink.hadoop_config);
+ iceberg_table_sink.hadoop_config,
+ [this](std::shared_ptr<io::FileSystem> fs, const std::string&
path) {
+ _closed_files.emplace_back(std::move(fs), path);
+ });
};
auto partition_write = create_writer_lambda(file_name, file_name_index);
if (iceberg_table_sink.__isset.sort_info) {
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 cc7cec1fdad..070019d85db 100644
--- a/be/src/exec/sink/writer/iceberg/viceberg_table_writer.h
+++ b/be/src/exec/sink/writer/iceberg/viceberg_table_writer.h
@@ -32,6 +32,10 @@
namespace doris {
+namespace io {
+class FileSystem;
+}
+
class ObjectPool;
class RuntimeState;
@@ -60,6 +64,10 @@ public:
Status close(Status) override;
+ void defer_file_cleanup_until_outer_close() {
_defer_file_cleanup_until_outer_close = true; }
+
+ void finish_deferred_file_cleanup(Status outer_status);
+
bool is_rewrite_compaction() const { return _write_type ==
TIcebergWriteType::REWRITE; }
TIcebergWriteType::type write_type() const { return _write_type; }
@@ -137,6 +145,7 @@ private:
Status _write_prepared_block(Block& output_block);
Status _process_row_lineage_columns(Block& block);
+ void _cleanup_closed_files();
// Currently it is a copy, maybe it is better to use move semantics to
eliminate it.
TDataSink _t_sink;
@@ -171,6 +180,9 @@ private:
std::vector<std::string> _static_partition_value_list;
std::unordered_map<std::string, std::shared_ptr<IPartitionWriterBase>>
_partitions_to_writers;
+ // Rolled writers are no longer active, so retain their physical paths
until statement outcome is known.
+ std::vector<std::pair<std::shared_ptr<io::FileSystem>, std::string>>
_closed_files;
+ bool _defer_file_cleanup_until_outer_close = false;
VExprContextSPtrs _write_output_vexpr_ctxs;
size_t _row_count = 0;
const RowDescriptor* _row_desc = nullptr;
diff --git a/be/test/exec/sink/viceberg_merge_sink_test.cpp
b/be/test/exec/sink/viceberg_merge_sink_test.cpp
index d5c9ac1b891..eb7c0159d5f 100644
--- a/be/test/exec/sink/viceberg_merge_sink_test.cpp
+++ b/be/test/exec/sink/viceberg_merge_sink_test.cpp
@@ -19,6 +19,11 @@
#include <gtest/gtest.h>
+#include <filesystem>
+#include <fstream>
+
+#include "agent/be_exec_version_manager.h"
+#include "common/config.h"
#include "common/consts.h"
#include "common/object_pool.h"
#include "core/block/block.h"
@@ -28,13 +33,17 @@
#include "core/data_type/data_type_number.h"
#include "core/data_type/data_type_string.h"
#include "core/data_type/data_type_struct.h"
+#include "exec/sink/viceberg_delete_sink.h"
+#include "exec/sink/writer/iceberg/viceberg_table_writer.h"
#include "exprs/vexpr_context.h"
#include "gen_cpp/DataSinks_types.h"
#include "gen_cpp/Types_types.h"
+#include "io/fs/local_file_system.h"
#include "runtime/runtime_profile.h"
#include "testutil/mock/mock_descriptors.h"
#include "testutil/mock/mock_runtime_state.h"
#include "testutil/mock/mock_slot_ref.h"
+#include "util/debug_points.h"
namespace doris {
@@ -47,7 +56,7 @@ protected:
"]}";
}
- TDataSink build_sink() {
+ TDataSink build_sink(bool require_cardinality_check = true, bool
set_cardinality_check = true) {
TDataSink t_sink;
t_sink.__set_type(TDataSinkType::ICEBERG_MERGE_SINK);
@@ -64,6 +73,9 @@ protected:
merge_sink.__set_file_type(TFileType::FILE_LOCAL);
merge_sink.__set_delete_type(TFileContent::POSITION_DELETES);
merge_sink.__set_partition_spec_id_for_delete(0);
+ if (set_cardinality_check) {
+
merge_sink.__set_require_merge_cardinality_check(require_cardinality_check);
+ }
t_sink.__set_iceberg_merge_sink(merge_sink);
return t_sink;
@@ -106,7 +118,8 @@ protected:
return output_exprs;
}
- Block build_block_with_ops(const std::vector<int8_t>& ops) {
+ Block build_block_with_ops(const std::vector<int8_t>& ops, bool
distinct_files = true,
+ size_t file_index_offset = 0) {
Block block;
auto op_col = ColumnInt8::create();
@@ -121,9 +134,11 @@ protected:
auto id_col = ColumnInt32::create();
auto name_col = ColumnString::create();
for (size_t i = 0; i < ops.size(); ++i) {
- std::string file_path = "file" + std::to_string(i + 1) +
".parquet";
+ std::string file_path =
+ distinct_files ? "file" + std::to_string(file_index_offset
+ i + 1) + ".parquet"
+ : "shared-file.parquet";
file_path_col->insert_data(file_path.data(), file_path.size());
- row_pos_col->insert_value(static_cast<int64_t>((i + 1) * 10));
+ row_pos_col->insert_value(static_cast<int64_t>((file_index_offset
+ i + 1) * 10));
id_col->insert_value(static_cast<int32_t>(i + 1));
char name_value = static_cast<char>('a' + i);
name_col->insert_data(&name_value, 1);
@@ -315,4 +330,256 @@ TEST_F(VIcebergMergeSinkTest, TestSchemaMismatch) {
ASSERT_NE(std::string::npos, status.to_string().find("do not match schema
columns"));
}
+TEST_F(VIcebergMergeSinkTest, TestRejectsDuplicateMatchedTargetAcrossBlocks) {
+ ObjectPool pool;
+ MockRuntimeState state;
+
+ DataTypes types {std::make_shared<DataTypeInt8>(),
+ std::make_shared<DataTypeStruct>(DataTypes
{std::make_shared<DataTypeString>(),
+
std::make_shared<DataTypeInt64>()},
+ Strings {"file_path",
"row_position"}),
+ std::make_shared<DataTypeInt32>(),
std::make_shared<DataTypeString>()};
+ MockRowDescriptor row_desc(types, &pool);
+
+ auto output_exprs = build_output_exprs(&pool, &state, row_desc);
+ auto sink = std::make_shared<VIcebergMergeSink>(build_sink(),
output_exprs, nullptr, nullptr);
+ sink->set_skip_io(true);
+
+ ASSERT_TRUE(sink->init_properties(&pool, row_desc).ok());
+ RuntimeProfile profile("iceberg_merge_sink");
+ ASSERT_TRUE(sink->open(&state, &profile).ok());
+
+ Block first = build_block_with_ops({3});
+ ASSERT_TRUE(sink->write(&state, first).ok());
+ Block duplicate = build_block_with_ops({3});
+ Status status = sink->write(&state, duplicate);
+
+ ASSERT_FALSE(status.ok());
+ ASSERT_NE(std::string::npos,
+ status.to_string().find("multiple source rows matched the same
target row"));
+}
+
+TEST_F(VIcebergMergeSinkTest, TestUpdateSkipsCardinalityState) {
+ ObjectPool pool;
+ MockRuntimeState state;
+
+ DataTypes types {std::make_shared<DataTypeInt8>(),
+ std::make_shared<DataTypeStruct>(DataTypes
{std::make_shared<DataTypeString>(),
+
std::make_shared<DataTypeInt64>()},
+ Strings {"file_path",
"row_position"}),
+ std::make_shared<DataTypeInt32>(),
std::make_shared<DataTypeString>()};
+ MockRowDescriptor row_desc(types, &pool);
+
+ auto output_exprs = build_output_exprs(&pool, &state, row_desc);
+ auto sink =
+ std::make_shared<VIcebergMergeSink>(build_sink(false),
output_exprs, nullptr, nullptr);
+ sink->set_skip_io(true);
+
+ ASSERT_TRUE(sink->init_properties(&pool, row_desc).ok());
+ RuntimeProfile profile("iceberg_update_sink");
+ ASSERT_TRUE(sink->open(&state, &profile).ok());
+
+ Block first = build_block_with_ops({3});
+ ASSERT_TRUE(sink->write(&state, first).ok());
+ Block duplicate = build_block_with_ops({3});
+ ASSERT_TRUE(sink->write(&state, duplicate).ok());
+ EXPECT_TRUE(sink->_matched_row_positions.empty());
+ EXPECT_EQ(nullptr, profile.get_counter("MatchedRowIdStateBytes"));
+}
+
+TEST_F(VIcebergMergeSinkTest, TestOldFePlanSkipsCardinalityState) {
+ ObjectPool pool;
+ MockRuntimeState state;
+
+ DataTypes types {std::make_shared<DataTypeInt8>(),
+ std::make_shared<DataTypeStruct>(DataTypes
{std::make_shared<DataTypeString>(),
+
std::make_shared<DataTypeInt64>()},
+ Strings {"file_path",
"row_position"}),
+ std::make_shared<DataTypeInt32>(),
std::make_shared<DataTypeString>()};
+ MockRowDescriptor row_desc(types, &pool);
+
+ auto output_exprs = build_output_exprs(&pool, &state, row_desc);
+ auto sink = std::make_shared<VIcebergMergeSink>(build_sink(false, false),
output_exprs, nullptr,
+ nullptr);
+ sink->set_skip_io(true);
+
+ ASSERT_TRUE(sink->init_properties(&pool, row_desc).ok());
+ RuntimeProfile profile("old_fe_iceberg_update_sink");
+ ASSERT_TRUE(sink->open(&state, &profile).ok());
+ Block duplicate = build_block_with_ops({3, 3}, false);
+ ASSERT_TRUE(sink->write(&state, duplicate).ok());
+ EXPECT_TRUE(sink->_matched_row_positions.empty());
+}
+
+TEST_F(VIcebergMergeSinkTest, TestRollingUpgradeSkipsCardinalityState) {
+ ObjectPool pool;
+ MockRuntimeState state;
+ state.set_be_exec_version(SUPPORT_ICEBERG_MERGE_CARDINALITY_VERSION - 1);
+
+ DataTypes types {std::make_shared<DataTypeInt8>(),
+ std::make_shared<DataTypeStruct>(DataTypes
{std::make_shared<DataTypeString>(),
+
std::make_shared<DataTypeInt64>()},
+ Strings {"file_path",
"row_position"}),
+ std::make_shared<DataTypeInt32>(),
std::make_shared<DataTypeString>()};
+ MockRowDescriptor row_desc(types, &pool);
+
+ auto output_exprs = build_output_exprs(&pool, &state, row_desc);
+ auto sink = std::make_shared<VIcebergMergeSink>(build_sink(),
output_exprs, nullptr, nullptr);
+ sink->set_skip_io(true);
+
+ ASSERT_TRUE(sink->init_properties(&pool, row_desc).ok());
+ RuntimeProfile profile("rolling_upgrade_iceberg_merge_sink");
+ ASSERT_TRUE(sink->open(&state, &profile).ok());
+ Block duplicate = build_block_with_ops({3, 3}, false);
+ ASSERT_TRUE(sink->write(&state, duplicate).ok());
+ EXPECT_TRUE(sink->_matched_row_positions.empty());
+}
+
+TEST_F(VIcebergMergeSinkTest, TestErrorCloseRemovesRolledDataFiles) {
+ ObjectPool pool;
+ MockRuntimeState state;
+
+ DataTypes types {std::make_shared<DataTypeInt8>(),
+ std::make_shared<DataTypeStruct>(DataTypes
{std::make_shared<DataTypeString>(),
+
std::make_shared<DataTypeInt64>()},
+ Strings {"file_path",
"row_position"}),
+ std::make_shared<DataTypeInt32>(),
std::make_shared<DataTypeString>()};
+ MockRowDescriptor row_desc(types, &pool);
+ auto output_exprs = build_output_exprs(&pool, &state, row_desc);
+ auto sink = std::make_shared<VIcebergMergeSink>(build_sink(),
output_exprs, nullptr, nullptr);
+ sink->set_skip_io(true);
+
+ ASSERT_TRUE(sink->init_properties(&pool, row_desc).ok());
+ RuntimeProfile profile("iceberg_merge_cleanup_sink");
+ ASSERT_TRUE(sink->open(&state, &profile).ok());
+
+ std::filesystem::path path =
+ std::filesystem::temp_directory_path() /
"doris_iceberg_merge_rolled_file.parquet";
+ {
+ std::ofstream output(path);
+ output << "rolled-data";
+ }
+ ASSERT_TRUE(std::filesystem::exists(path));
+
sink->_table_writer->_closed_files.emplace_back(io::global_local_filesystem(),
path.string());
+
+ Status failure = Status::InvalidArgument("late duplicate");
+ EXPECT_FALSE(sink->close(failure).ok());
+ EXPECT_FALSE(std::filesystem::exists(path));
+}
+
+TEST_F(VIcebergMergeSinkTest, TestDeleteCloseFailureRemovesBothInnerSinkFiles)
{
+ ObjectPool pool;
+ MockRuntimeState state;
+
+ DataTypes types {std::make_shared<DataTypeInt8>(),
+ std::make_shared<DataTypeStruct>(DataTypes
{std::make_shared<DataTypeString>(),
+
std::make_shared<DataTypeInt64>()},
+ Strings {"file_path",
"row_position"}),
+ std::make_shared<DataTypeInt32>(),
std::make_shared<DataTypeString>()};
+ MockRowDescriptor row_desc(types, &pool);
+ auto output_exprs = build_output_exprs(&pool, &state, row_desc);
+ auto sink = std::make_shared<VIcebergMergeSink>(build_sink(),
output_exprs, nullptr, nullptr);
+ sink->set_skip_io(true);
+
+ ASSERT_TRUE(sink->init_properties(&pool, row_desc).ok());
+ RuntimeProfile profile("iceberg_merge_delete_close_cleanup_sink");
+ ASSERT_TRUE(sink->open(&state, &profile).ok());
+
+ std::filesystem::path data_path = std::filesystem::temp_directory_path() /
+
"doris_iceberg_merge_delete_close_data.parquet";
+ std::filesystem::path delete_path = std::filesystem::temp_directory_path()
/
+
"doris_iceberg_merge_delete_close_position.parquet";
+ {
+ std::ofstream output(data_path);
+ output << "closed-data";
+ }
+ {
+ std::ofstream output(delete_path);
+ output << "closed-delete";
+ }
+ ASSERT_TRUE(std::filesystem::exists(data_path));
+ ASSERT_TRUE(std::filesystem::exists(delete_path));
+
sink->_table_writer->_closed_files.emplace_back(io::global_local_filesystem(),
+ data_path.string());
+
sink->_delete_writer->_created_files.emplace_back(io::global_local_filesystem(),
+ delete_path.string());
+
+ bool previous_enable_debug_points = config::enable_debug_points;
+ config::enable_debug_points = true;
+ DebugPoints::instance()->add("VIcebergDeleteSink.close.inject_failure");
+ Status status = sink->close(Status::OK());
+ DebugPoints::instance()->clear();
+ config::enable_debug_points = previous_enable_debug_points;
+
+ EXPECT_FALSE(status.ok());
+ EXPECT_NE(std::string::npos, status.to_string().find("injected Iceberg
delete close failure"));
+ EXPECT_FALSE(std::filesystem::exists(data_path));
+ EXPECT_FALSE(std::filesystem::exists(delete_path));
+}
+
+TEST_F(VIcebergMergeSinkTest, TestMatchedRowIdsUseCompactRetainedState) {
+ ObjectPool pool;
+ MockRuntimeState state;
+
+ DataTypes types {std::make_shared<DataTypeInt8>(),
+ std::make_shared<DataTypeStruct>(DataTypes
{std::make_shared<DataTypeString>(),
+
std::make_shared<DataTypeInt64>()},
+ Strings {"file_path",
"row_position"}),
+ std::make_shared<DataTypeInt32>(),
std::make_shared<DataTypeString>()};
+ MockRowDescriptor row_desc(types, &pool);
+
+ auto output_exprs = build_output_exprs(&pool, &state, row_desc);
+ auto sink = std::make_shared<VIcebergMergeSink>(build_sink(),
output_exprs, nullptr, nullptr);
+ sink->set_skip_io(true);
+
+ ASSERT_TRUE(sink->init_properties(&pool, row_desc).ok());
+ RuntimeProfile profile("iceberg_merge_sink");
+ ASSERT_TRUE(sink->open(&state, &profile).ok());
+
+ constexpr size_t row_count = 100000;
+ std::vector<int8_t> operations(row_count, 3);
+ Block block = build_block_with_ops(operations, false);
+ ASSERT_TRUE(sink->write(&state, block).ok());
+
+ auto* retained_bytes = profile.get_counter("MatchedRowIdStateBytes");
+ ASSERT_NE(nullptr, retained_bytes);
+ EXPECT_LT(retained_bytes->value(), static_cast<int64_t>(row_count *
sizeof(int64_t)));
+}
+
+TEST_F(VIcebergMergeSinkTest, TestMatchedRowIdStateAcrossManyFilesAndWrites) {
+ ObjectPool pool;
+ MockRuntimeState state;
+
+ DataTypes types {std::make_shared<DataTypeInt8>(),
+ std::make_shared<DataTypeStruct>(DataTypes
{std::make_shared<DataTypeString>(),
+
std::make_shared<DataTypeInt64>()},
+ Strings {"file_path",
"row_position"}),
+ std::make_shared<DataTypeInt32>(),
std::make_shared<DataTypeString>()};
+ MockRowDescriptor row_desc(types, &pool);
+
+ auto output_exprs = build_output_exprs(&pool, &state, row_desc);
+ auto sink = std::make_shared<VIcebergMergeSink>(build_sink(),
output_exprs, nullptr, nullptr);
+ sink->set_skip_io(true);
+
+ ASSERT_TRUE(sink->init_properties(&pool, row_desc).ok());
+ RuntimeProfile profile("iceberg_merge_sink");
+ ASSERT_TRUE(sink->open(&state, &profile).ok());
+
+ auto* retained_bytes = profile.get_counter("MatchedRowIdStateBytes");
+ ASSERT_NE(nullptr, retained_bytes);
+ constexpr size_t files_per_write = 32;
+ constexpr size_t write_count = 64;
+ std::vector<int8_t> operations(files_per_write, 3);
+ int64_t previous_bytes = 0;
+ for (size_t write_index = 0; write_index < write_count; ++write_index) {
+ Block block = build_block_with_ops(operations, true, write_index *
files_per_write);
+ ASSERT_TRUE(sink->write(&state, block).ok());
+ EXPECT_GT(retained_bytes->value(), previous_bytes);
+ previous_bytes = retained_bytes->value();
+ }
+
+ EXPECT_EQ(files_per_write * write_count,
sink->_matched_row_positions.size());
+ EXPECT_EQ(static_cast<int64_t>(sink->_matched_row_id_state_size),
retained_bytes->value());
+}
+
} // namespace doris
diff --git a/be/test/exec/sink/writer/iceberg/partition_transformers_test.cpp
b/be/test/exec/sink/writer/iceberg/partition_transformers_test.cpp
index 94b8aec8c77..974eb817e88 100644
--- a/be/test/exec/sink/writer/iceberg/partition_transformers_test.cpp
+++ b/be/test/exec/sink/writer/iceberg/partition_transformers_test.cpp
@@ -520,4 +520,30 @@ TEST_F(PartitionTransformersTest,
test_nullable_column_integer_truncate_transfor
}
}
+TEST_F(PartitionTransformersTest,
test_nullable_column_string_truncate_transform) {
+ auto column = ColumnNullable::create(ColumnString::create(),
ColumnUInt8::create());
+ column->insert_data(nullptr, 0);
+ column->insert_data("iceberg", sizeof("iceberg") - 1);
+ column->insert_data("db", sizeof("db") - 1);
+ ColumnWithTypeAndName test_string(
+ column->get_ptr(),
+
std::make_shared<DataTypeNullable>(std::make_shared<DataTypeString>()),
"test_string");
+
+ Block block({test_string});
+ auto source_type =
DataTypeFactory::instance().create_data_type(TYPE_STRING, true);
+ StringTruncatePartitionColumnTransform transform(source_type, 3);
+
+ auto result = transform.apply(block, 0);
+
+ const auto* result_column = assert_cast<const
ColumnNullable*>(result.column.get());
+ const auto* result_strings =
+ assert_cast<const
ColumnString*>(result_column->get_nested_column_ptr().get());
+ EXPECT_EQ(3, result_column->size());
+ EXPECT_EQ(Field::create_field<TYPE_BOOLEAN>(1),
result_column->get_null_map_column()[0]);
+ EXPECT_EQ(Field::create_field<TYPE_BOOLEAN>(0),
result_column->get_null_map_column()[1]);
+ EXPECT_EQ(Field::create_field<TYPE_BOOLEAN>(0),
result_column->get_null_map_column()[2]);
+ EXPECT_EQ("ice", result_strings->get_data_at(1).to_string());
+ EXPECT_EQ("db", result_strings->get_data_at(2).to_string());
+}
+
} // namespace doris
diff --git a/fe/fe-common/src/main/java/org/apache/doris/common/Config.java
b/fe/fe-common/src/main/java/org/apache/doris/common/Config.java
index 66dc4cc881f..24a555fed33 100644
--- a/fe/fe-common/src/main/java/org/apache/doris/common/Config.java
+++ b/fe/fe-common/src/main/java/org/apache/doris/common/Config.java
@@ -2036,7 +2036,7 @@ public class Config extends ConfigBase {
* Max data version of backends serialize block.
*/
@ConfField(mutable = false)
- public static int max_be_exec_version = 10;
+ public static int max_be_exec_version = 11;
/**
* Min data version of backends serialize block.
diff --git
a/fe/fe-core/src/main/java/org/apache/doris/datasource/paimon/PaimonMetadataOps.java
b/fe/fe-core/src/main/java/org/apache/doris/datasource/paimon/PaimonMetadataOps.java
index 2f0be3bc305..c1140f42cf3 100644
---
a/fe/fe-core/src/main/java/org/apache/doris/datasource/paimon/PaimonMetadataOps.java
+++
b/fe/fe-core/src/main/java/org/apache/doris/datasource/paimon/PaimonMetadataOps.java
@@ -191,6 +191,8 @@ public class PaimonMetadataOps implements
ExternalMetadataOps {
if (tableExist(db.getRemoteName(), tableName)) {
if (createTableInfo.isIfNotExists()) {
LOG.info("create table[{}] which already exists", tableName);
+ // Existing-table success skips the normal post-create hook,
so refresh names here.
+ resetTableNameCache(dbName);
return true;
} else {
ErrorReport.reportDdlException(ErrorCode.ERR_TABLE_EXISTS_ERROR, tableName);
@@ -208,6 +210,8 @@ public class PaimonMetadataOps implements
ExternalMetadataOps {
if (dorisTable != null) {
if (createTableInfo.isIfNotExists()) {
LOG.info("create table[{}] which already exists", tableName);
+ // Every successful no-op bypasses the normal post-create hook
and must refresh names.
+ resetTableNameCache(dbName);
return true;
} else {
ErrorReport.reportDdlException(ErrorCode.ERR_TABLE_EXISTS_ERROR, tableName);
@@ -223,9 +227,19 @@ public class PaimonMetadataOps implements
ExternalMetadataOps {
Schema schema = toPaimonSchema(structType, rootFieldNames,
createTableInfo.getPartitionDesc(),
createTableInfo.getProperties());
try {
+ // Let Paimon report a concurrent winner so callers can
distinguish an existing table
+ // from the table created by this statement before deciding
whether rollback is owned.
catalog.createTable(new Identifier(createTableInfo.getDbName(),
createTableInfo.getTableName()),
- schema, createTableInfo.isIfNotExists());
- } catch (TableAlreadyExistException | DatabaseNotExistException e) {
+ schema, false);
+ } catch (TableAlreadyExistException e) {
+ if (createTableInfo.isIfNotExists()) {
+ LOG.info("create table[{}] which already exists", tableName);
+ // A concurrent remote creator also bypasses the normal
post-create hook.
+ resetTableNameCache(dbName);
+ return true;
+ }
+ throw new RuntimeException(e);
+ } catch (DatabaseNotExistException e) {
throw new RuntimeException(e);
}
return false;
@@ -277,12 +291,17 @@ public class PaimonMetadataOps implements
ExternalMetadataOps {
@Override
public void afterCreateTable(String dbName, String tblName) {
+ Optional<ExternalDatabase<?>> db = resetTableNameCache(dbName);
+ LOG.info("after create table {}.{}.{}, is db exists: {}",
+ dorisCatalog.getName(), dbName, tblName, db.isPresent());
+ }
+
+ private Optional<ExternalDatabase<?>> resetTableNameCache(String dbName) {
Optional<ExternalDatabase<?>> db = dorisCatalog.getDbForReplay(dbName);
if (db.isPresent()) {
db.get().resetMetaCacheNames();
}
- LOG.info("after create table {}.{}.{}, is db exists: {}",
- dorisCatalog.getName(), dbName, tblName, db.isPresent());
+ return db;
}
@Override
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 f42112d1e11..4caeeb0883f 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
@@ -638,7 +638,8 @@ public class PhysicalPlanTranslator extends
DefaultPlanVisitor<PlanFragment, Pla
rootFragment.setOutputExprs(outputExprs);
IcebergMergeSink sink = new IcebergMergeSink(
(IcebergExternalTable) icebergMergeSink.getTargetTable(),
- icebergMergeSink.getDeleteContext());
+ icebergMergeSink.getDeleteContext(),
+ icebergMergeSink.isRequireMergeCardinalityCheck());
rootFragment.setSink(sink);
return rootFragment;
}
diff --git
a/fe/fe-core/src/main/java/org/apache/doris/nereids/properties/RequestPropertyDeriver.java
b/fe/fe-core/src/main/java/org/apache/doris/nereids/properties/RequestPropertyDeriver.java
index 6ea2601bbee..9c04507680f 100644
---
a/fe/fe-core/src/main/java/org/apache/doris/nereids/properties/RequestPropertyDeriver.java
+++
b/fe/fe-core/src/main/java/org/apache/doris/nereids/properties/RequestPropertyDeriver.java
@@ -202,9 +202,12 @@ public class RequestPropertyDeriver extends
PlanVisitor<Void, PlanContext> {
@Override
public Void visitPhysicalIcebergMergeSink(
PhysicalIcebergMergeSink<? extends Plan> icebergMergeSink,
PlanContext context) {
- if (connectContext != null &&
!connectContext.getSessionVariable().enableStrictConsistencyDml) {
+ if (!icebergMergeSink.isRequireMergeCardinalityCheck()
+ && connectContext != null
+ &&
!connectContext.getSessionVariable().enableStrictConsistencyDml) {
addRequestPropertyToChildren(PhysicalProperties.ANY);
} else {
+ // SQL MERGE cardinality is mandatory even when optional UPDATE
consistency is disabled.
addRequestPropertyToChildren(icebergMergeSink.getRequirePhysicalProperties());
}
return null;
diff --git
a/fe/fe-core/src/main/java/org/apache/doris/nereids/rules/implementation/LogicalIcebergMergeSinkToPhysicalIcebergMergeSink.java
b/fe/fe-core/src/main/java/org/apache/doris/nereids/rules/implementation/LogicalIcebergMergeSinkToPhysicalIcebergMergeSink.java
index 9447aaf09d1..85b813b7fd6 100644
---
a/fe/fe-core/src/main/java/org/apache/doris/nereids/rules/implementation/LogicalIcebergMergeSinkToPhysicalIcebergMergeSink.java
+++
b/fe/fe-core/src/main/java/org/apache/doris/nereids/rules/implementation/LogicalIcebergMergeSinkToPhysicalIcebergMergeSink.java
@@ -39,6 +39,7 @@ public class
LogicalIcebergMergeSinkToPhysicalIcebergMergeSink extends OneImplem
sink.getCols(),
sink.getOutputExprs(),
sink.getDeleteContext(),
+ sink.isRequireMergeCardinalityCheck(),
Optional.empty(),
sink.getLogicalProperties(),
null,
diff --git
a/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/commands/CreateTableCommand.java
b/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/commands/CreateTableCommand.java
index 7b9837183c6..3031e67be7d 100644
---
a/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/commands/CreateTableCommand.java
+++
b/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/commands/CreateTableCommand.java
@@ -18,10 +18,12 @@
package org.apache.doris.nereids.trees.plans.commands;
import org.apache.doris.analysis.StmtType;
+import org.apache.doris.catalog.DatabaseIf;
import org.apache.doris.catalog.Env;
import org.apache.doris.catalog.ScalarType;
import org.apache.doris.common.ErrorCode;
import org.apache.doris.common.FeConstants;
+import org.apache.doris.datasource.CatalogIf;
import org.apache.doris.nereids.NereidsPlanner;
import org.apache.doris.nereids.analyzer.UnboundResultSink;
import org.apache.doris.nereids.analyzer.UnboundTableSinkCreator;
@@ -99,6 +101,19 @@ public class CreateTableCommand extends Command implements
NeedAuditEncryption,
LOG.debug("Nereids start to execute the ctas command, query id:
{}, tableName: {}",
ctx.queryId(), createTableInfo.getTableName());
}
+ LogicalPlan sinkQuery = null;
+ if (!createTableInfo.isIfNotExists()) {
+ // An existence probe is used only to preserve the catalog
diagnostic; creation still
+ // goes through the atomic catalog API and is the sole proof of
ownership.
+ if (targetTableExists(ctx)) {
+ throw new
AnalysisException(ErrorCode.ERR_TABLE_EXISTS_ERROR.formatErrorMsg(
+ createTableInfo.getTableName()));
+ }
+ // Reject unsupported destinations before publishing metadata;
rollback by table name
+ // cannot distinguish this CTAS table from a concurrent
replacement with the same name.
+ sinkQuery =
UnboundTableSinkCreator.createUnboundTableSink(createTableInfo.getTableNameParts(),
+ ImmutableList.of(), ImmutableList.of(),
ImmutableList.of(), query);
+ }
try {
if (Env.getCurrentEnv().createTable(this.createTableInfo)) {
return;
@@ -107,12 +122,16 @@ public class CreateTableCommand extends Command
implements NeedAuditEncryption,
throw new AnalysisException(e.getMessage(), e.getCause());
}
- query =
UnboundTableSinkCreator.createUnboundTableSink(createTableInfo.getTableNameParts(),
- ImmutableList.of(), ImmutableList.of(), ImmutableList.of(),
query);
try {
+ if (sinkQuery == null) {
+ // IF NOT EXISTS must honor the catalog's atomic
existing-table result before sink
+ // validation, otherwise an unsupported connector turns the
required no-op into an error.
+ sinkQuery =
UnboundTableSinkCreator.createUnboundTableSink(createTableInfo.getTableNameParts(),
+ ImmutableList.of(), ImmutableList.of(),
ImmutableList.of(), query);
+ }
InsertIntoTableCommand insertCommand = null;
if (!FeConstants.runningUnitTest) {
- insertCommand = new InsertIntoTableCommand(query,
Optional.empty(),
+ insertCommand = new InsertIntoTableCommand(sinkQuery,
Optional.empty(),
Optional.empty(), Optional.empty(), true,
Optional.empty());
insertCommand.run(ctx, executor);
if (ctx.getState().getStateType() == MysqlStateType.OK) {
@@ -227,6 +246,16 @@ public class CreateTableCommand extends Command implements
NeedAuditEncryption,
}
}
+ boolean targetTableExists(ConnectContext ctx) {
+ List<String> qualifiedName = RelationUtil.getQualifierName(ctx,
createTableInfo.getTableNameParts());
+ CatalogIf<?> catalog =
Env.getCurrentEnv().getCatalogMgr().getCatalog(qualifiedName.get(0));
+ if (catalog == null) {
+ return false;
+ }
+ DatabaseIf<?> database = catalog.getDbNullable(qualifiedName.get(1));
+ return database != null && database.isTableExist(qualifiedName.get(2));
+ }
+
private String getAutoRangePartitionNameOrNull() {
try {
if (createTableInfo.getPartitionTableInfo().isAutoPartition()
diff --git
a/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/commands/IcebergMergeCommand.java
b/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/commands/IcebergMergeCommand.java
index 59eb6a6d6ac..4d4b9b2014f 100644
---
a/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/commands/IcebergMergeCommand.java
+++
b/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/commands/IcebergMergeCommand.java
@@ -465,6 +465,7 @@ public class IcebergMergeCommand extends Command implements
ForwardWithSync, Exp
icebergTable.getBaseSchema(true),
outputExprs,
deleteCtx,
+ true,
Optional.empty(),
Optional.empty(),
projectPlan);
diff --git
a/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/commands/IcebergUpdateCommand.java
b/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/commands/IcebergUpdateCommand.java
index 8759343e055..d9d0ab51b58 100644
---
a/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/commands/IcebergUpdateCommand.java
+++
b/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/commands/IcebergUpdateCommand.java
@@ -241,6 +241,7 @@ public class IcebergUpdateCommand extends Command
implements ForwardWithSync, Ex
icebergTable.getBaseSchema(true),
outputExprs,
deleteCtx,
+ false,
Optional.empty(),
Optional.empty(),
queryPlan);
diff --git
a/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/logical/LogicalIcebergMergeSink.java
b/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/logical/LogicalIcebergMergeSink.java
index 7f528020890..c8198c27dcc 100644
---
a/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/logical/LogicalIcebergMergeSink.java
+++
b/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/logical/LogicalIcebergMergeSink.java
@@ -47,6 +47,7 @@ public class LogicalIcebergMergeSink<CHILD_TYPE extends Plan>
extends LogicalTab
private final IcebergExternalDatabase database;
private final IcebergExternalTable targetTable;
private final DeleteCommandContext deleteContext;
+ private final boolean requireMergeCardinalityCheck;
/**
* Constructor
@@ -56,6 +57,7 @@ public class LogicalIcebergMergeSink<CHILD_TYPE extends Plan>
extends LogicalTab
List<Column> cols,
List<NamedExpression> outputExprs,
DeleteCommandContext deleteContext,
+ boolean requireMergeCardinalityCheck,
Optional<GroupExpression> groupExpression,
Optional<LogicalProperties>
logicalProperties,
CHILD_TYPE child) {
@@ -63,6 +65,7 @@ public class LogicalIcebergMergeSink<CHILD_TYPE extends Plan>
extends LogicalTab
this.database = Objects.requireNonNull(database, "database != null in
LogicalIcebergMergeSink");
this.targetTable = Objects.requireNonNull(targetTable, "targetTable !=
null in LogicalIcebergMergeSink");
this.deleteContext = Objects.requireNonNull(deleteContext,
"deleteContext != null in LogicalIcebergMergeSink");
+ this.requireMergeCardinalityCheck = requireMergeCardinalityCheck;
}
public Plan withChildAndUpdateOutput(Plan child) {
@@ -70,19 +73,19 @@ public class LogicalIcebergMergeSink<CHILD_TYPE extends
Plan> extends LogicalTab
.map(NamedExpression.class::cast)
.collect(ImmutableList.toImmutableList());
return new LogicalIcebergMergeSink<>(database, targetTable, cols,
output,
- deleteContext, Optional.empty(), Optional.empty(), child);
+ deleteContext, requireMergeCardinalityCheck, Optional.empty(),
Optional.empty(), child);
}
@Override
public Plan withChildren(List<Plan> children) {
Preconditions.checkArgument(children.size() == 1,
"LogicalIcebergMergeSink only accepts one child");
return new LogicalIcebergMergeSink<>(database, targetTable, cols,
outputExprs,
- deleteContext, Optional.empty(), Optional.empty(),
children.get(0));
+ deleteContext, requireMergeCardinalityCheck, Optional.empty(),
Optional.empty(), children.get(0));
}
public LogicalIcebergMergeSink<CHILD_TYPE>
withOutputExprs(List<NamedExpression> outputExprs) {
return new LogicalIcebergMergeSink<>(database, targetTable, cols,
outputExprs,
- deleteContext, Optional.empty(), Optional.empty(), child());
+ deleteContext, requireMergeCardinalityCheck, Optional.empty(),
Optional.empty(), child());
}
public IcebergExternalDatabase getDatabase() {
@@ -97,6 +100,10 @@ public class LogicalIcebergMergeSink<CHILD_TYPE extends
Plan> extends LogicalTab
return deleteContext;
}
+ public boolean isRequireMergeCardinalityCheck() {
+ return requireMergeCardinalityCheck;
+ }
+
@Override
public boolean equals(Object o) {
if (this == o) {
@@ -112,12 +119,14 @@ public class LogicalIcebergMergeSink<CHILD_TYPE extends
Plan> extends LogicalTab
return Objects.equals(database, that.database)
&& Objects.equals(targetTable, that.targetTable)
&& Objects.equals(deleteContext, that.deleteContext)
+ && requireMergeCardinalityCheck ==
that.requireMergeCardinalityCheck
&& Objects.equals(cols, that.cols);
}
@Override
public int hashCode() {
- return Objects.hash(super.hashCode(), database, targetTable, cols,
deleteContext);
+ return Objects.hash(super.hashCode(), database, targetTable, cols,
deleteContext,
+ requireMergeCardinalityCheck);
}
@Override
@@ -127,7 +136,8 @@ public class LogicalIcebergMergeSink<CHILD_TYPE extends
Plan> extends LogicalTab
"database", database.getFullName(),
"targetTable", targetTable.getName(),
"cols", cols,
- "deleteFileType", deleteContext.getDeleteFileType());
+ "deleteFileType", deleteContext.getDeleteFileType(),
+ "requireMergeCardinalityCheck", requireMergeCardinalityCheck);
}
@Override
@@ -138,13 +148,15 @@ public class LogicalIcebergMergeSink<CHILD_TYPE extends
Plan> extends LogicalTab
@Override
public Plan withGroupExpression(Optional<GroupExpression> groupExpression)
{
return new LogicalIcebergMergeSink<>(database, targetTable, cols,
outputExprs,
- deleteContext, groupExpression,
Optional.of(getLogicalProperties()), child());
+ deleteContext, requireMergeCardinalityCheck,
+ groupExpression, Optional.of(getLogicalProperties()), child());
}
@Override
public Plan withGroupExprLogicalPropChildren(Optional<GroupExpression>
groupExpression,
Optional<LogicalProperties> logicalProperties, List<Plan>
children) {
return new LogicalIcebergMergeSink<>(database, targetTable, cols,
outputExprs,
- deleteContext, groupExpression, logicalProperties,
children.get(0));
+ deleteContext, requireMergeCardinalityCheck,
+ groupExpression, logicalProperties, children.get(0));
}
}
diff --git
a/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/physical/PhysicalIcebergMergeSink.java
b/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/physical/PhysicalIcebergMergeSink.java
index 0281ad23243..e182c5adc06 100644
---
a/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/physical/PhysicalIcebergMergeSink.java
+++
b/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/physical/PhysicalIcebergMergeSink.java
@@ -22,7 +22,6 @@ import
org.apache.doris.datasource.iceberg.IcebergExternalDatabase;
import org.apache.doris.datasource.iceberg.IcebergExternalTable;
import org.apache.doris.datasource.iceberg.IcebergMergeOperation;
import org.apache.doris.nereids.memo.GroupExpression;
-import org.apache.doris.nereids.properties.DistributionSpecHash.ShuffleType;
import org.apache.doris.nereids.properties.DistributionSpecMerge;
import org.apache.doris.nereids.properties.LogicalProperties;
import org.apache.doris.nereids.properties.PhysicalProperties;
@@ -56,6 +55,7 @@ import java.util.TreeMap;
*/
public class PhysicalIcebergMergeSink<CHILD_TYPE extends Plan> extends
PhysicalBaseExternalTableSink<CHILD_TYPE> {
private final DeleteCommandContext deleteContext;
+ private final boolean requireMergeCardinalityCheck;
/**
* Constructor
@@ -65,10 +65,12 @@ public class PhysicalIcebergMergeSink<CHILD_TYPE extends
Plan> extends PhysicalB
List<Column> cols,
List<NamedExpression> outputExprs,
DeleteCommandContext deleteContext,
+ boolean requireMergeCardinalityCheck,
Optional<GroupExpression> groupExpression,
LogicalProperties logicalProperties,
CHILD_TYPE child) {
- this(database, targetTable, cols, outputExprs, deleteContext,
groupExpression, logicalProperties,
+ this(database, targetTable, cols, outputExprs, deleteContext,
requireMergeCardinalityCheck,
+ groupExpression, logicalProperties,
PhysicalProperties.GATHER, null, child);
}
@@ -80,6 +82,7 @@ public class PhysicalIcebergMergeSink<CHILD_TYPE extends
Plan> extends PhysicalB
List<Column> cols,
List<NamedExpression> outputExprs,
DeleteCommandContext deleteContext,
+ boolean requireMergeCardinalityCheck,
Optional<GroupExpression> groupExpression,
LogicalProperties logicalProperties,
PhysicalProperties physicalProperties,
@@ -89,17 +92,22 @@ public class PhysicalIcebergMergeSink<CHILD_TYPE extends
Plan> extends PhysicalB
logicalProperties, physicalProperties, statistics, child);
this.deleteContext = Objects.requireNonNull(
deleteContext, "deleteContext != null in
PhysicalIcebergMergeSink");
+ this.requireMergeCardinalityCheck = requireMergeCardinalityCheck;
}
public DeleteCommandContext getDeleteContext() {
return deleteContext;
}
+ public boolean isRequireMergeCardinalityCheck() {
+ return requireMergeCardinalityCheck;
+ }
+
@Override
public Plan withChildren(List<Plan> children) {
return new PhysicalIcebergMergeSink<>(
(IcebergExternalDatabase) database, (IcebergExternalTable)
targetTable,
- cols, outputExprs, deleteContext, groupExpression,
+ cols, outputExprs, deleteContext,
requireMergeCardinalityCheck, groupExpression,
getLogicalProperties(), physicalProperties, statistics,
children.get(0));
}
@@ -112,7 +120,8 @@ public class PhysicalIcebergMergeSink<CHILD_TYPE extends
Plan> extends PhysicalB
public Plan withGroupExpression(Optional<GroupExpression> groupExpression)
{
return new PhysicalIcebergMergeSink<>(
(IcebergExternalDatabase) database, (IcebergExternalTable)
targetTable, cols, outputExprs,
- deleteContext, groupExpression, getLogicalProperties(),
child());
+ deleteContext, requireMergeCardinalityCheck,
+ groupExpression, getLogicalProperties(), child());
}
@Override
@@ -120,14 +129,16 @@ public class PhysicalIcebergMergeSink<CHILD_TYPE extends
Plan> extends PhysicalB
Optional<LogicalProperties>
logicalProperties, List<Plan> children) {
return new PhysicalIcebergMergeSink<>(
(IcebergExternalDatabase) database, (IcebergExternalTable)
targetTable, cols, outputExprs,
- deleteContext, groupExpression, logicalProperties.get(),
children.get(0));
+ deleteContext, requireMergeCardinalityCheck,
+ groupExpression, logicalProperties.get(), children.get(0));
}
@Override
public PhysicalPlan withPhysicalPropertiesAndStats(PhysicalProperties
physicalProperties, Statistics statistics) {
return new PhysicalIcebergMergeSink<>(
(IcebergExternalDatabase) database, (IcebergExternalTable)
targetTable, cols, outputExprs,
- deleteContext, groupExpression, getLogicalProperties(),
physicalProperties, statistics, child());
+ deleteContext, requireMergeCardinalityCheck,
+ groupExpression, getLogicalProperties(), physicalProperties,
statistics, child());
}
@Override
@@ -142,12 +153,13 @@ public class PhysicalIcebergMergeSink<CHILD_TYPE extends
Plan> extends PhysicalB
return false;
}
PhysicalIcebergMergeSink<?> that = (PhysicalIcebergMergeSink<?>) o;
- return Objects.equals(deleteContext, that.deleteContext);
+ return Objects.equals(deleteContext, that.deleteContext)
+ && requireMergeCardinalityCheck ==
that.requireMergeCardinalityCheck;
}
@Override
public int hashCode() {
- return Objects.hash(super.hashCode(), deleteContext);
+ return Objects.hash(super.hashCode(), deleteContext,
requireMergeCardinalityCheck);
}
/**
@@ -172,8 +184,16 @@ public class PhysicalIcebergMergeSink<CHILD_TYPE extends
Plan> extends PhysicalB
ConnectContext ctx = ConnectContext.get();
if (ctx == null ||
!ctx.getSessionVariable().isEnableIcebergMergePartitioning()) {
- if (rowIdExprId != null) {
- return
PhysicalProperties.createHash(ImmutableList.of(rowIdExprId),
ShuffleType.REQUIRE);
+ if (rowIdExprId != null && operationExprId != null) {
+ // Route only delete images by row ID; unmatched inserts have
NULL row IDs and must
+ // remain distributable instead of collapsing onto one
exchange channel.
+ return new PhysicalProperties(new DistributionSpecMerge(
+ operationExprId,
+ ImmutableList.of(),
+ ImmutableList.of(rowIdExprId),
+ true,
+ ImmutableList.of(),
+ null));
}
return PhysicalProperties.GATHER;
}
diff --git
a/fe/fe-core/src/main/java/org/apache/doris/planner/IcebergMergeSink.java
b/fe/fe-core/src/main/java/org/apache/doris/planner/IcebergMergeSink.java
index 9fd7dc046fa..bf52a777b04 100644
--- a/fe/fe-core/src/main/java/org/apache/doris/planner/IcebergMergeSink.java
+++ b/fe/fe-core/src/main/java/org/apache/doris/planner/IcebergMergeSink.java
@@ -65,6 +65,7 @@ public class IcebergMergeSink extends
BaseExternalTableDataSink {
private final IcebergExternalTable targetTable;
private final DeleteCommandContext deleteContext;
+ private final boolean requireMergeCardinalityCheck;
private List<TIcebergRewritableDeleteFileSet> rewritableDeleteFileSets =
Collections.emptyList();
private static final HashSet<TFileFormatType> supportedTypes = new
HashSet<TFileFormatType>() {{
@@ -75,13 +76,15 @@ public class IcebergMergeSink extends
BaseExternalTableDataSink {
// Store PropertiesMap, including vended credentials or static credentials
private Map<StorageTypeId, StorageAdapter> storagePropertiesMap;
- public IcebergMergeSink(IcebergExternalTable targetTable,
DeleteCommandContext deleteContext) {
+ public IcebergMergeSink(IcebergExternalTable targetTable,
DeleteCommandContext deleteContext,
+ boolean requireMergeCardinalityCheck) {
super();
if (targetTable.isView()) {
throw new UnsupportedOperationException("UPDATE on iceberg view is
not supported");
}
this.targetTable = targetTable;
this.deleteContext = deleteContext;
+ this.requireMergeCardinalityCheck = requireMergeCardinalityCheck;
IcebergExternalCatalog catalog = (IcebergExternalCatalog)
targetTable.getCatalog();
storagePropertiesMap =
VendedCredentialsFactory.getStoragePropertiesMapWithVendedCredentials(
@@ -133,6 +136,8 @@ public class IcebergMergeSink extends
BaseExternalTableDataSink {
tSink.setFormatVersion(formatVersion);
tSink.setSchemaJson(SchemaParser.toJson(schema));
tSink.setCollectColumnStats(IcebergUtils.shouldCollectColumnStats(icebergTable,
schema));
+ // UPDATE and SQL MERGE share this sink, but only SQL MERGE has the
one-source-row invariant.
+ tSink.setRequireMergeCardinalityCheck(requireMergeCardinalityCheck);
// partition spec
if (icebergTable.spec().isPartitioned()) {
diff --git
a/fe/fe-core/src/test/java/org/apache/doris/datasource/iceberg/IcebergDDLAndDMLPlanTest.java
b/fe/fe-core/src/test/java/org/apache/doris/datasource/iceberg/IcebergDDLAndDMLPlanTest.java
index b4a1c6a5d1b..556ff50225f 100644
---
a/fe/fe-core/src/test/java/org/apache/doris/datasource/iceberg/IcebergDDLAndDMLPlanTest.java
+++
b/fe/fe-core/src/test/java/org/apache/doris/datasource/iceberg/IcebergDDLAndDMLPlanTest.java
@@ -27,7 +27,6 @@ import org.apache.doris.nereids.NereidsPlanner;
import org.apache.doris.nereids.StatementContext;
import org.apache.doris.nereids.analyzer.UnboundAlias;
import org.apache.doris.nereids.glue.LogicalPlanAdapter;
-import org.apache.doris.nereids.properties.DistributionSpecHash;
import org.apache.doris.nereids.properties.DistributionSpecMerge;
import org.apache.doris.nereids.properties.PhysicalProperties;
import org.apache.doris.nereids.trees.expressions.Alias;
@@ -462,6 +461,68 @@ public class IcebergDDLAndDMLPlanTest extends
TestWithFeService {
}
}
+ @Test
+ public void testIcebergMergeIntoExchangeIgnoresStrictConsistencySwitch()
throws Exception {
+ useIceberg();
+ boolean previousMergePartitioning =
connectContext.getSessionVariable().enableIcebergMergePartitioning;
+ boolean previousStrictConsistency =
connectContext.getSessionVariable().enableStrictConsistencyDml;
+ connectContext.getSessionVariable().enableIcebergMergePartitioning =
true;
+ connectContext.getSessionVariable().enableStrictConsistencyDml = false;
+ try {
+ String sql = "merge into " + tableName + " t "
+ + "using (select 1 as id, 'name1' as name, 10 as age, 1 as
score, 1.23 as amount) s "
+ + "on t.id = s.id "
+ + "when matched then update set name = s.name";
+ LogicalPlan mergePlan = parseStmt(sql);
+ Plan explainPlan = ((MergeIntoCommand)
mergePlan).getExplainPlan(connectContext);
+ PhysicalPlan physicalPlan =
+ planPhysicalPlan((LogicalPlan) explainPlan,
PhysicalProperties.GATHER, sql);
+
+ PhysicalIcebergMergeSink<?> sink =
+ getSinglePhysicalSink(physicalPlan,
PhysicalIcebergMergeSink.class);
+ Assertions.assertTrue(sink.isRequireMergeCardinalityCheck());
+ Assertions.assertTrue(sink.child() instanceof PhysicalDistribute,
+ "MERGE cardinality requires row-id routing even when
strict consistency is disabled\n"
+ + physicalPlan.treeString());
+ Assertions.assertTrue(
+ ((PhysicalDistribute<?>)
sink.child()).getDistributionSpec() instanceof DistributionSpecMerge,
+ "Missing merge distribution spec\n" +
physicalPlan.treeString());
+ } finally {
+ connectContext.getSessionVariable().enableIcebergMergePartitioning
= previousMergePartitioning;
+ connectContext.getSessionVariable().enableStrictConsistencyDml =
previousStrictConsistency;
+ }
+ }
+
+ @Test
+ public void testIcebergUpdateAvoidsExchangeWhenStrictConsistencyDisabled()
throws Exception {
+ useIceberg();
+ boolean previousMergePartitioning =
connectContext.getSessionVariable().enableIcebergMergePartitioning;
+ boolean previousStrictConsistency =
connectContext.getSessionVariable().enableStrictConsistencyDml;
+ connectContext.getSessionVariable().enableIcebergMergePartitioning =
true;
+ connectContext.getSessionVariable().enableStrictConsistencyDml = false;
+ try {
+ // UPDATE FROM may produce the same target row more than once, but
SQL MERGE's
+ // one-source-row cardinality rule must not be applied to UPDATE
statements.
+ String sql = "update " + tableName + " t set name = s.name "
+ + "from (select 1 as id, 'first' as name union all "
+ + "select 1 as id, 'second' as name) s where t.id = s.id";
+ LogicalPlan updatePlan = parseStmt(sql);
+ Plan explainPlan = ((UpdateCommand)
updatePlan).getExplainPlan(connectContext);
+ PhysicalPlan physicalPlan =
+ planPhysicalPlan((LogicalPlan) explainPlan,
PhysicalProperties.GATHER, sql);
+
+ PhysicalIcebergMergeSink<?> sink =
+ getSinglePhysicalSink(physicalPlan,
PhysicalIcebergMergeSink.class);
+ Assertions.assertFalse(sink.isRequireMergeCardinalityCheck());
+ Assertions.assertFalse(sink.child() instanceof PhysicalDistribute,
+ "Strict-consistency-off UPDATE should not add an Iceberg
merge exchange\n"
+ + physicalPlan.treeString());
+ } finally {
+ connectContext.getSessionVariable().enableIcebergMergePartitioning
= previousMergePartitioning;
+ connectContext.getSessionVariable().enableStrictConsistencyDml =
previousStrictConsistency;
+ }
+ }
+
@Test
public void testIcebergUpdateCastsConstantForSmallintAndDecimal() throws
Exception {
useIceberg();
@@ -477,7 +538,7 @@ public class IcebergDDLAndDMLPlanTest extends
TestWithFeService {
}
@Test
- public void testIcebergUpdateExchangeUsesRowIdOnlyWhenDisabled() throws
Exception {
+ public void
testIcebergUpdateExchangeRoutesOnlyDeletesByRowIdWhenDisabled() throws
Exception {
useIceberg();
boolean previous =
connectContext.getSessionVariable().enableIcebergMergePartitioning;
connectContext.getSessionVariable().enableIcebergMergePartitioning =
false;
@@ -490,19 +551,93 @@ public class IcebergDDLAndDMLPlanTest extends
TestWithFeService {
PhysicalIcebergMergeSink<?> sink =
getSinglePhysicalSink(physicalPlan,
PhysicalIcebergMergeSink.class);
+ ExprId operationExprId =
findOperationExprId(sink.child().getOutput());
ExprId rowIdExprId = findRowIdExprId(sink.child().getOutput());
Assertions.assertTrue(sink.child() instanceof PhysicalDistribute,
- "Missing row_id exchange\n" + physicalPlan.treeString());
+ "Missing operation-aware exchange\n" +
physicalPlan.treeString());
PhysicalDistribute<?> distribute = (PhysicalDistribute<?>)
sink.child();
- Assertions.assertTrue(distribute.getDistributionSpec() instanceof
DistributionSpecHash,
- "Missing row_id hash distribution\n" +
physicalPlan.treeString());
- DistributionSpecHash hash = (DistributionSpecHash)
distribute.getDistributionSpec();
- Assertions.assertEquals(ImmutableList.of(rowIdExprId),
hash.getOrderedShuffledColumns());
+ Assertions.assertTrue(distribute.getDistributionSpec() instanceof
DistributionSpecMerge,
+ "Missing operation-aware merge distribution\n" +
physicalPlan.treeString());
+ DistributionSpecMerge spec = (DistributionSpecMerge)
distribute.getDistributionSpec();
+ Assertions.assertEquals(operationExprId,
spec.getOperationExprId());
+ Assertions.assertTrue(spec.isInsertRandom());
+ Assertions.assertEquals(ImmutableList.of(rowIdExprId),
spec.getDeletePartitionExprIds());
} finally {
connectContext.getSessionVariable().enableIcebergMergePartitioning
= previous;
}
}
+ @Test
+ public void
testIcebergInsertOnlyMergeDoesNotHashNullRowIdsWhenStrictConsistencyDisabled()
+ throws Exception {
+ useIceberg();
+ boolean previousMergePartitioning =
connectContext.getSessionVariable().enableIcebergMergePartitioning;
+ boolean previousStrictConsistency =
connectContext.getSessionVariable().enableStrictConsistencyDml;
+ connectContext.getSessionVariable().enableIcebergMergePartitioning =
false;
+ connectContext.getSessionVariable().enableStrictConsistencyDml = false;
+ try {
+ String sql = "merge into " + tableName + " t "
+ + "using (select 1 as id, 'name1' as name, 10 as age, 1 as
score, 1.23 as amount) s "
+ + "on t.id = s.id "
+ + "when not matched then insert (id, name, age, score,
amount) "
+ + "values (s.id, s.name, s.age, s.score, s.amount)";
+ LogicalPlan mergePlan = parseStmt(sql);
+ Plan explainPlan = ((MergeIntoCommand)
mergePlan).getExplainPlan(connectContext);
+ PhysicalPlan physicalPlan =
+ planPhysicalPlan((LogicalPlan) explainPlan,
PhysicalProperties.GATHER, sql);
+
+ PhysicalIcebergMergeSink<?> sink =
+ getSinglePhysicalSink(physicalPlan,
PhysicalIcebergMergeSink.class);
+ Assertions.assertTrue(sink.child() instanceof PhysicalDistribute,
+ "Missing operation-aware exchange\n" +
physicalPlan.treeString());
+ DistributionSpecMerge spec = (DistributionSpecMerge)
+ ((PhysicalDistribute<?>)
sink.child()).getDistributionSpec();
+ Assertions.assertTrue(spec.isInsertRandom());
+ Assertions.assertTrue(spec.getInsertPartitionExprIds().isEmpty());
+ Assertions.assertEquals(
+
ImmutableList.of(findRowIdExprId(sink.child().getOutput())),
+ spec.getDeletePartitionExprIds());
+ } finally {
+ connectContext.getSessionVariable().enableIcebergMergePartitioning
= previousMergePartitioning;
+ connectContext.getSessionVariable().enableStrictConsistencyDml =
previousStrictConsistency;
+ }
+ }
+
+ @Test
+ public void
testIcebergMixedMergeRoutesInsertsRandomlyWhenStrictConsistencyDisabled()
+ throws Exception {
+ useIceberg();
+ boolean previousMergePartitioning =
connectContext.getSessionVariable().enableIcebergMergePartitioning;
+ boolean previousStrictConsistency =
connectContext.getSessionVariable().enableStrictConsistencyDml;
+ connectContext.getSessionVariable().enableIcebergMergePartitioning =
false;
+ connectContext.getSessionVariable().enableStrictConsistencyDml = false;
+ try {
+ String sql = "merge into " + tableName + " t "
+ + "using (select 1 as id, 'name1' as name, 10 as age, 1 as
score, 1.23 as amount) s "
+ + "on t.id = s.id "
+ + "when matched then update set name = s.name "
+ + "when not matched then insert (id, name, age, score,
amount) "
+ + "values (s.id, s.name, s.age, s.score, s.amount)";
+ LogicalPlan mergePlan = parseStmt(sql);
+ Plan explainPlan = ((MergeIntoCommand)
mergePlan).getExplainPlan(connectContext);
+ PhysicalPlan physicalPlan =
+ planPhysicalPlan((LogicalPlan) explainPlan,
PhysicalProperties.GATHER, sql);
+
+ PhysicalIcebergMergeSink<?> sink =
+ getSinglePhysicalSink(physicalPlan,
PhysicalIcebergMergeSink.class);
+ DistributionSpecMerge spec = (DistributionSpecMerge)
+ ((PhysicalDistribute<?>)
sink.child()).getDistributionSpec();
+ Assertions.assertTrue(spec.isInsertRandom());
+ Assertions.assertTrue(spec.getInsertPartitionExprIds().isEmpty());
+ Assertions.assertEquals(
+
ImmutableList.of(findRowIdExprId(sink.child().getOutput())),
+ spec.getDeletePartitionExprIds());
+ } finally {
+ connectContext.getSessionVariable().enableIcebergMergePartitioning
= previousMergePartitioning;
+ connectContext.getSessionVariable().enableStrictConsistencyDml =
previousStrictConsistency;
+ }
+ }
+
@Test
public void testIcebergUpdateExchangeUsesMergePartitioningWhenEnabled()
throws Exception {
useIceberg();
diff --git
a/fe/fe-core/src/test/java/org/apache/doris/datasource/paimon/PaimonMetadataOpsTest.java
b/fe/fe-core/src/test/java/org/apache/doris/datasource/paimon/PaimonMetadataOpsTest.java
index dda2c3d2344..c4a887c132a 100644
---
a/fe/fe-core/src/test/java/org/apache/doris/datasource/paimon/PaimonMetadataOpsTest.java
+++
b/fe/fe-core/src/test/java/org/apache/doris/datasource/paimon/PaimonMetadataOpsTest.java
@@ -20,6 +20,8 @@ package org.apache.doris.datasource.paimon;
import org.apache.doris.common.DdlException;
import org.apache.doris.common.UserException;
import org.apache.doris.datasource.CatalogFactory;
+import org.apache.doris.datasource.ExternalCatalog;
+import org.apache.doris.datasource.ExternalDatabase;
import org.apache.doris.nereids.parser.NereidsParser;
import org.apache.doris.nereids.trees.plans.commands.CreateCatalogCommand;
import org.apache.doris.nereids.trees.plans.commands.CreateTableCommand;
@@ -32,6 +34,7 @@ import org.apache.paimon.catalog.Catalog;
import org.apache.paimon.catalog.FileSystemCatalog;
import org.apache.paimon.catalog.Identifier;
import org.apache.paimon.hive.HiveCatalog;
+import org.apache.paimon.schema.Schema;
import org.apache.paimon.table.Table;
import org.apache.paimon.types.BigIntType;
import org.apache.paimon.types.DataField;
@@ -46,12 +49,14 @@ import org.junit.Assert;
import org.junit.BeforeClass;
import org.junit.Test;
import org.junit.jupiter.api.Assertions;
+import org.mockito.Mockito;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
+import java.util.Optional;
import java.util.UUID;
import java.util.stream.Collectors;
@@ -245,13 +250,70 @@ public class PaimonMetadataOpsTest {
Assert.assertEquals("c0", table.options().get("bucket-key"));
}
+ @Test
+ public void testIfNotExistsRefreshesNamesWhenRemoteTableExists() throws
Exception {
+ String tableName = getTableName();
+ Catalog remoteCatalog = Mockito.mock(Catalog.class);
+ ExternalCatalog dorisCatalog = Mockito.mock(ExternalCatalog.class);
+ ExternalDatabase<?> database = Mockito.mock(ExternalDatabase.class);
+ Mockito.doReturn(database).when(dorisCatalog).getDbNullable(dbName);
+
Mockito.doReturn(Optional.of(database)).when(dorisCatalog).getDbForReplay(dbName);
+ Mockito.when(database.getRemoteName()).thenReturn(dbName);
+
+ PaimonMetadataOps existingTableOps = new
PaimonMetadataOps(dorisCatalog, remoteCatalog) {
+ @Override
+ public boolean tableExist(String ignoredDbName, String
ignoredTableName) {
+ return true;
+ }
+ };
+ CreateTableInfo createTableInfo = parseCreateTableInfo(
+ "create table if not exists " + dbName + "." + tableName + "
(id int) engine = paimon");
+
+
Assert.assertTrue(existingTableOps.performCreateTable(createTableInfo));
+ Mockito.verify(database).resetMetaCacheNames();
+ Mockito.verify(remoteCatalog, Mockito.never()).createTable(
+ Mockito.any(Identifier.class), Mockito.any(Schema.class),
Mockito.anyBoolean());
+ }
+
+ @Test
+ public void testIfNotExistsReportsConcurrentWinner() throws Exception {
+ String tableName = getTableName();
+ Identifier identifier = new Identifier(dbName, tableName);
+ Catalog remoteCatalog = Mockito.mock(Catalog.class);
+ ExternalCatalog dorisCatalog = Mockito.mock(ExternalCatalog.class);
+ ExternalDatabase<?> database = Mockito.mock(ExternalDatabase.class);
+ Mockito.doReturn(database).when(dorisCatalog).getDbNullable(dbName);
+
Mockito.doReturn(Optional.of(database)).when(dorisCatalog).getDbForReplay(dbName);
+ Mockito.when(database.getRemoteName()).thenReturn(dbName);
+ Mockito.when(database.getTableNullable(tableName)).thenReturn(null);
+
+ PaimonMetadataOps raceOps = new PaimonMetadataOps(dorisCatalog,
remoteCatalog) {
+ @Override
+ public boolean tableExist(String ignoredDbName, String
ignoredTableName) {
+ return false;
+ }
+ };
+ CreateTableInfo createTableInfo = parseCreateTableInfo(
+ "create table if not exists " + dbName + "." + tableName + "
(id int) engine = paimon");
+ Mockito.doThrow(new Catalog.TableAlreadyExistException(identifier))
+ .when(remoteCatalog).createTable(
+ Mockito.eq(identifier), Mockito.any(Schema.class),
Mockito.eq(false));
+
+ Assert.assertTrue(raceOps.performCreateTable(createTableInfo));
+ Mockito.verify(database).resetMetaCacheNames();
+ }
+
public void createTable(String sql) throws UserException {
+ ops.createTable(parseCreateTableInfo(sql));
+ }
+
+ private CreateTableInfo parseCreateTableInfo(String sql) throws
UserException {
LogicalPlan plan = new NereidsParser().parseSingle(sql);
Assertions.assertTrue(plan instanceof CreateTableCommand);
CreateTableInfo createTableInfo = ((CreateTableCommand)
plan).getCreateTableInfo();
createTableInfo.setIsExternal(true);
createTableInfo.analyzeEngine();
- ops.createTable(createTableInfo);
+ return createTableInfo;
}
public String getTableName() {
diff --git
a/fe/fe-core/src/test/java/org/apache/doris/nereids/trees/plans/commands/CreateTableCommandTest.java
b/fe/fe-core/src/test/java/org/apache/doris/nereids/trees/plans/commands/CreateTableCommandTest.java
new file mode 100644
index 00000000000..38324e828ae
--- /dev/null
+++
b/fe/fe-core/src/test/java/org/apache/doris/nereids/trees/plans/commands/CreateTableCommandTest.java
@@ -0,0 +1,133 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements. See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership. The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License. You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied. See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+package org.apache.doris.nereids.trees.plans.commands;
+
+import org.apache.doris.catalog.Env;
+import org.apache.doris.common.UserException;
+import org.apache.doris.nereids.analyzer.UnboundTableSinkCreator;
+import org.apache.doris.nereids.trees.plans.commands.info.CreateTableInfo;
+import org.apache.doris.nereids.trees.plans.logical.LogicalPlan;
+import org.apache.doris.qe.ConnectContext;
+import org.apache.doris.qe.StmtExecutor;
+
+import com.google.common.collect.ImmutableList;
+import org.junit.jupiter.api.Test;
+import org.mockito.MockedStatic;
+import org.mockito.Mockito;
+
+import java.util.List;
+import java.util.Optional;
+
+class CreateTableCommandTest {
+
+ @Test
+ void ctasIfNotExistsReturnsBeforeUnsupportedSinkValidation() throws
Exception {
+ LogicalPlan query = Mockito.mock(LogicalPlan.class);
+ CreateTableInfo createTableInfo = Mockito.mock(CreateTableInfo.class);
+ ConnectContext context = Mockito.mock(ConnectContext.class);
+ StmtExecutor executor = Mockito.mock(StmtExecutor.class);
+ Env env = Mockito.mock(Env.class);
+ List<String> tableNameParts = ImmutableList.of("catalog", "database",
"target");
+
+
Mockito.when(createTableInfo.getTableNameParts()).thenReturn(tableNameParts);
+ Mockito.when(createTableInfo.isIfNotExists()).thenReturn(true);
+ Mockito.when(env.createTable(createTableInfo)).thenReturn(true);
+ CreateTableCommand command = Mockito.spy(
+ new CreateTableCommand(Optional.of(query), createTableInfo));
+ Mockito.doNothing().when(command).validateCreateTableAsSelect(context,
query);
+
+ try (MockedStatic<Env> envMock = Mockito.mockStatic(Env.class);
+ MockedStatic<UnboundTableSinkCreator> sinkMock =
+ Mockito.mockStatic(UnboundTableSinkCreator.class)) {
+ envMock.when(Env::getCurrentEnv).thenReturn(env);
+
+ org.junit.jupiter.api.Assertions.assertDoesNotThrow(
+ () -> command.run(context, executor));
+
+ sinkMock.verifyNoInteractions();
+ Mockito.verify(env).createTable(createTableInfo);
+ }
+ }
+
+ @Test
+ void ctasValidatesSinkBeforePublishingMetadata() throws Exception {
+ LogicalPlan query = Mockito.mock(LogicalPlan.class);
+ CreateTableInfo createTableInfo = Mockito.mock(CreateTableInfo.class);
+ ConnectContext context = Mockito.mock(ConnectContext.class);
+ StmtExecutor executor = Mockito.mock(StmtExecutor.class);
+ Env env = Mockito.mock(Env.class);
+ List<String> tableNameParts = ImmutableList.of("catalog", "database",
"target");
+
+
Mockito.when(createTableInfo.getTableNameParts()).thenReturn(tableNameParts);
+ CreateTableCommand command = Mockito.spy(
+ new CreateTableCommand(Optional.of(query), createTableInfo));
+ Mockito.doNothing().when(command).validateCreateTableAsSelect(context,
query);
+ Mockito.doReturn(false).when(command).targetTableExists(context);
+
+ try (MockedStatic<Env> envMock = Mockito.mockStatic(Env.class);
+ MockedStatic<UnboundTableSinkCreator> sinkMock =
+ Mockito.mockStatic(UnboundTableSinkCreator.class)) {
+ envMock.when(Env::getCurrentEnv).thenReturn(env);
+ sinkMock.when(() -> UnboundTableSinkCreator.createUnboundTableSink(
+ tableNameParts, ImmutableList.of(), ImmutableList.of(),
ImmutableList.of(), query))
+ .thenThrow(new UserException("unsupported sink"));
+
+ org.junit.jupiter.api.Assertions.assertThrows(
+ Exception.class, () -> command.run(context, executor));
+
+ Mockito.verify(env, Mockito.never()).createTable(createTableInfo);
+ Mockito.verify(env, Mockito.never()).dropTable(
+ Mockito.anyString(), Mockito.anyString(),
Mockito.anyString(),
+ Mockito.anyBoolean(), Mockito.anyBoolean(),
Mockito.anyBoolean(),
+ Mockito.anyBoolean(), Mockito.anyBoolean(),
Mockito.anyBoolean());
+ }
+ }
+
+ @Test
+ void existingCtasTargetPrecedesUnsupportedSinkValidation() throws
Exception {
+ LogicalPlan query = Mockito.mock(LogicalPlan.class);
+ CreateTableInfo createTableInfo = Mockito.mock(CreateTableInfo.class);
+ ConnectContext context = Mockito.mock(ConnectContext.class);
+ StmtExecutor executor = Mockito.mock(StmtExecutor.class);
+ Env env = Mockito.mock(Env.class);
+ List<String> tableNameParts = ImmutableList.of("catalog", "database",
"target");
+
+
Mockito.when(createTableInfo.getTableNameParts()).thenReturn(tableNameParts);
+ Mockito.when(createTableInfo.getTableName()).thenReturn("target");
+ CreateTableCommand command = Mockito.spy(
+ new CreateTableCommand(Optional.of(query), createTableInfo));
+ Mockito.doNothing().when(command).validateCreateTableAsSelect(context,
query);
+ Mockito.doReturn(true).when(command).targetTableExists(context);
+
+ try (MockedStatic<Env> envMock = Mockito.mockStatic(Env.class);
+ MockedStatic<UnboundTableSinkCreator> sinkMock =
+ Mockito.mockStatic(UnboundTableSinkCreator.class)) {
+ envMock.when(Env::getCurrentEnv).thenReturn(env);
+ Exception exception =
org.junit.jupiter.api.Assertions.assertThrows(
+ Exception.class, () -> command.run(context, executor));
+
+
org.junit.jupiter.api.Assertions.assertTrue(exception.getMessage().contains("already
exists"));
+ sinkMock.verifyNoInteractions();
+ Mockito.verify(env, Mockito.never()).createTable(createTableInfo);
+ Mockito.verify(env, Mockito.never()).dropTable(
+ Mockito.anyString(), Mockito.anyString(),
Mockito.anyString(),
+ Mockito.anyBoolean(), Mockito.anyBoolean(),
Mockito.anyBoolean(),
+ Mockito.anyBoolean(), Mockito.anyBoolean(),
Mockito.anyBoolean());
+ }
+ }
+}
diff --git
a/fe/fe-core/src/test/java/org/apache/doris/nereids/trees/plans/commands/insert/IcebergMergeExecutorTest.java
b/fe/fe-core/src/test/java/org/apache/doris/nereids/trees/plans/commands/insert/IcebergMergeExecutorTest.java
index 3a547d06394..f0271b57bb0 100644
---
a/fe/fe-core/src/test/java/org/apache/doris/nereids/trees/plans/commands/insert/IcebergMergeExecutorTest.java
+++
b/fe/fe-core/src/test/java/org/apache/doris/nereids/trees/plans/commands/insert/IcebergMergeExecutorTest.java
@@ -99,7 +99,8 @@ public class IcebergMergeExecutorTest {
ctx.setThreadLocalInfo();
IcebergMergeExecutor executor = new IcebergMergeExecutor(ctx, table,
"label", planner, false, -1L);
- IcebergMergeSink sink = new IcebergMergeSink(table, new
org.apache.doris.nereids.trees.plans.commands.delete.DeleteCommandContext());
+ IcebergMergeSink sink = new IcebergMergeSink(table,
+ new
org.apache.doris.nereids.trees.plans.commands.delete.DeleteCommandContext(),
true);
executor.finalizeSinkForMerge(null, sink, null);
TDataSink tDataSink = getTDataSink(sink);
diff --git
a/fe/fe-core/src/test/java/org/apache/doris/planner/IcebergMergeSinkTest.java
b/fe/fe-core/src/test/java/org/apache/doris/planner/IcebergMergeSinkTest.java
index be73aefe43b..3aea3fc6f67 100644
---
a/fe/fe-core/src/test/java/org/apache/doris/planner/IcebergMergeSinkTest.java
+++
b/fe/fe-core/src/test/java/org/apache/doris/planner/IcebergMergeSinkTest.java
@@ -46,7 +46,8 @@ public class IcebergMergeSinkTest {
@Test
public void
testBindDataSinkIncludesRowLineageSchemaAndRewritableDeleteFileSetsForV3()
throws Exception {
- IcebergMergeSink sink = new
IcebergMergeSink(mockIcebergExternalTable(3), new DeleteCommandContext());
+ IcebergMergeSink sink = new IcebergMergeSink(
+ mockIcebergExternalTable(3), new DeleteCommandContext(), true);
sink.setRewritableDeleteFileSets(Collections.singletonList(buildDeleteFileSet()));
sink.bindDataSink(Optional.empty());
@@ -57,11 +58,14 @@ public class IcebergMergeSinkTest {
Assertions.assertTrue(thriftSink.getSchemaJson().contains(
IcebergUtils.ICEBERG_LAST_UPDATED_SEQUENCE_NUMBER_COL));
Assertions.assertEquals(1,
thriftSink.getRewritableDeleteFileSetsSize());
+ Assertions.assertTrue(thriftSink.isSetRequireMergeCardinalityCheck());
+ Assertions.assertTrue(thriftSink.isRequireMergeCardinalityCheck());
}
@Test
public void
testBindDataSinkSkipsRewritableDeleteFileSetsAndRowLineageSchemaForV2() throws
Exception {
- IcebergMergeSink sink = new
IcebergMergeSink(mockIcebergExternalTable(2), new DeleteCommandContext());
+ IcebergMergeSink sink = new IcebergMergeSink(
+ mockIcebergExternalTable(2), new DeleteCommandContext(),
false);
sink.setRewritableDeleteFileSets(Collections.singletonList(buildDeleteFileSet()));
sink.bindDataSink(Optional.empty());
@@ -72,12 +76,14 @@ public class IcebergMergeSinkTest {
Assertions.assertFalse(thriftSink.getSchemaJson().contains(IcebergUtils.ICEBERG_ROW_ID_COL));
Assertions.assertFalse(thriftSink.getSchemaJson().contains(
IcebergUtils.ICEBERG_LAST_UPDATED_SEQUENCE_NUMBER_COL));
+ Assertions.assertTrue(thriftSink.isSetRequireMergeCardinalityCheck());
+ Assertions.assertFalse(thriftSink.isRequireMergeCardinalityCheck());
}
@Test
public void testBindDataSinkDisablesColumnStatsWhenAllMetricsAreNone()
throws Exception {
IcebergMergeSink sink = new
IcebergMergeSink(mockIcebergExternalTable(2, Map.of(
- TableProperties.DEFAULT_WRITE_METRICS_MODE, "none")), new
DeleteCommandContext());
+ TableProperties.DEFAULT_WRITE_METRICS_MODE, "none")), new
DeleteCommandContext(), false);
sink.bindDataSink(Optional.empty());
@@ -91,7 +97,7 @@ public class IcebergMergeSinkTest {
IcebergMergeSink sink = new
IcebergMergeSink(mockIcebergExternalTable(2, Map.of(
TableProperties.DEFAULT_WRITE_METRICS_MODE, "none",
TableProperties.METRICS_MODE_COLUMN_CONF_PREFIX + "id",
"counts")),
- new DeleteCommandContext());
+ new DeleteCommandContext(), false);
sink.bindDataSink(Optional.empty());
@@ -105,7 +111,7 @@ public class IcebergMergeSinkTest {
IcebergMergeSink sink = new
IcebergMergeSink(mockIcebergExternalTable(3, Map.of(
TableProperties.DEFAULT_WRITE_METRICS_MODE, "counts",
TableProperties.METRICS_MODE_COLUMN_CONF_PREFIX + "id",
"none")),
- new DeleteCommandContext());
+ new DeleteCommandContext(), false);
sink.bindDataSink(Optional.empty());
@@ -122,7 +128,7 @@ public class IcebergMergeSinkTest {
TableProperties.DEFAULT_FILE_FORMAT, "orc",
TableProperties.DEFAULT_WRITE_METRICS_MODE, "none",
TableProperties.METRICS_MODE_COLUMN_CONF_PREFIX + "items",
"counts")),
- new DeleteCommandContext());
+ new DeleteCommandContext(), false);
sink.bindDataSink(Optional.empty());
diff --git a/gensrc/thrift/DataSinks.thrift b/gensrc/thrift/DataSinks.thrift
index 18e152f447d..9efe29a51eb 100644
--- a/gensrc/thrift/DataSinks.thrift
+++ b/gensrc/thrift/DataSinks.thrift
@@ -537,6 +537,8 @@ struct TIcebergMergeSink {
13: optional list<Types.TNetworkAddress> broker_addresses;
// Unset keeps collection enabled for rolling upgrades with older FEs.
14: optional bool collect_column_stats;
+ // Unset preserves old-FE UPDATE behavior; execution version gates SQL
MERGE validation.
+ 15: optional bool require_merge_cardinality_check;
// delete side (position delete only)
20: optional TFileContent delete_type
diff --git
a/regression-test/data/external_table_p0/iceberg/write/test_iceberg_write_merge_duplicate_source_negative.out
b/regression-test/data/external_table_p0/iceberg/write/test_iceberg_write_merge_duplicate_source_negative.out
index 3f8c510addc..5d765df5cac 100644
---
a/regression-test/data/external_table_p0/iceberg/write/test_iceberg_write_merge_duplicate_source_negative.out
+++
b/regression-test/data/external_table_p0/iceberg/write/test_iceberg_write_merge_duplicate_source_negative.out
@@ -1,3 +1,5 @@
-- This file is automatically generated. You should know what you did if you
want to edit this
-- !duplicate_source_atomic_state --
1 A committed
+-- !sibling_close_atomic_state --
+1 A committed
diff --git
a/regression-test/suites/external_table_p0/PAIMON_ICEBERG_READ_WRITE_P0_COVERAGE.md
b/regression-test/suites/external_table_p0/PAIMON_ICEBERG_READ_WRITE_P0_COVERAGE.md
index f3760f4db68..5a551b6d3d8 100644
---
a/regression-test/suites/external_table_p0/PAIMON_ICEBERG_READ_WRITE_P0_COVERAGE.md
+++
b/regression-test/suites/external_table_p0/PAIMON_ICEBERG_READ_WRITE_P0_COVERAGE.md
@@ -43,8 +43,9 @@ boundaries.
Paimon P0 was incomplete. No suite selected a `merge-engine`, and write
rejection was not checked
across all DML shapes with pre/post data and snapshot invariants. PM01-PM03
below close those gaps.
-PM04 exposes one remaining product defect as an opt-in negative regression:
failed Paimon CTAS leaves
-target metadata. Streaming writes, overwrite, delete/update and merge listed
as unsupported by the
+PM04 now runs as an active negative regression: failed Paimon CTAS is rejected
without leaving target
+metadata, while `IF NOT EXISTS` remains a no-op for an existing table.
Streaming writes, overwrite,
+delete/update and merge listed as unsupported by the
Paimon ecosystem matrix are boundary tests rather than positive Doris P0
contracts.
## Risks
@@ -75,7 +76,7 @@ Paimon ecosystem matrix are boundary tests rather than
positive Doris P0 contrac
| Paimon | Deletion vectors, upsert/delete visibility and data/system tables |
Covered | `test_paimon_deletion_vector`, `paimon_data_system_table`,
`paimon_system_table` |
| Paimon | Catalog/database/table create and drop | Covered |
`test_create_paimon_table` |
| Paimon | Doris data write-back | Negative boundary covered |
`test_paimon_write_boundary` |
-| Paimon | Failed CTAS metadata atomicity | Isolated known-bug regression |
`test_paimon_ctas_atomicity_negative` |
+| Paimon | Failed CTAS metadata atomicity | Active negative regression |
`test_paimon_ctas_atomicity_negative` |
| Iceberg | V1/V2/V3, Parquet/ORC, position/equality deletes and deletion
vectors | Covered | `test_iceberg_position_delete`,
`test_iceberg_equality_delete`, `test_iceberg_deletion_vector` |
| Iceberg | Schema, partition and sort-order evolution | Covered |
`test_iceberg_schema_time_travel_matrix`,
`test_iceberg_partition_evolution_format_scanner`, `iceberg_schema_change_ddl` |
| Iceberg | Snapshot/timestamp/tag/branch reads and reference actions |
Covered | `test_iceberg_time_travel`, `iceberg_query_tag_branch`,
`test_iceberg_schema_ref_actions_matrix` |
@@ -95,7 +96,7 @@ Detailed schema/time-travel and Iceberg write combinations
are maintained in
| PM01 | Distinguish all four primary-key merge engines | R01, R02, R04 |
Functional, correctness, compatibility | Paimon Parquet/ORC tables | Duplicate
keys across several commits | Each engine returns its documented merged row
under automatic and forced-JNI routing |
| PM02 | Validate dynamic-bucket cross-partition deduplication | R03, R04 |
Correctness | Primary key excludes partition key, bucket=-1 | Move one key
between partitions | Exactly one current row remains in the new partition |
| PM03 | Preserve the Paimon read-only boundary | R05, R10 | Negative,
atomicity | Existing Paimon PK table | VALUES, SELECT, OVERWRITE, UPDATE,
DELETE, MERGE | Every statement fails before a snapshot or data change |
-| PM04 | Reject or roll back Paimon CTAS atomically | R05, R10 | Isolated
negative, atomicity | Paimon catalog with no target table | CREATE TABLE AS
SELECT | Desired contract: the command fails and no target table remains;
current bug leaves the table |
+| PM04 | Reject or roll back Paimon CTAS atomically | R05, R10 | Negative,
atomicity | Missing and existing Paimon target tables | CREATE TABLE AS SELECT,
including IF NOT EXISTS | Unsupported CTAS leaves no target; an existing target
makes IF NOT EXISTS a successful no-op |
Every P0 risk maps to at least one deterministic positive, boundary, or
isolated known-bug
regression. Catalog authentication and cloud storage permutations stay in
their existing connector
diff --git
a/regression-test/suites/external_table_p0/iceberg/write/ICEBERG_WRITE_P0_COVERAGE.md
b/regression-test/suites/external_table_p0/iceberg/write/ICEBERG_WRITE_P0_COVERAGE.md
index 7f0ba5b849c..6829fd1d4c4 100644
---
a/regression-test/suites/external_table_p0/iceberg/write/ICEBERG_WRITE_P0_COVERAGE.md
+++
b/regression-test/suites/external_table_p0/iceberg/write/ICEBERG_WRITE_P0_COVERAGE.md
@@ -70,10 +70,10 @@ under the License.
| Doris 源 bucket | HASH 固定 bucket、RANDOM bucket、HASH AUTO bucket | 已覆盖(验证通过) |
`test_iceberg_write_source_models` |
| 分区源类型 | STRING/INT/BIGINT/DATE/DATETIME/DECIMAL 的 bucket 与适用
transform;BOOLEAN identity 与非法 bucket | 已覆盖(验证通过) |
`test_iceberg_write_partition_types_null` |
| NULL 分区 | identity NULL、数值/decimal bucket 与 truncate NULL、time transform
NULL、多列组合 NULL | 已覆盖(验证通过) | `test_iceberg_write_partition_types_null` |
-| nullable STRING truncate | nullable STRING 经过 truncate transform 的 INSERT,以及
UPDATE 产生的 Nullable projection 写入 | 已覆盖(隔离负向) |
`test_iceberg_write_nullable_truncate_negative` |
+| nullable STRING truncate | nullable STRING 经过 truncate transform 的 INSERT,以及
UPDATE 产生的 Nullable projection 写入 | 已覆盖(常规回归) |
`test_iceberg_write_nullable_truncate_negative` |
| MERGE 完整语义 | 条件 MATCHED、DELETE/UPDATE、多个条件 NOT MATCHED、NULL-safe 与普通 NULL
key | 已覆盖(验证通过) | `test_iceberg_write_merge_semantics` |
-| MERGE 基数约束 | 多个源行匹配同一目标行必须整句失败且不发布快照 | 已覆盖(隔离负向) |
`test_iceberg_write_merge_duplicate_source_negative` |
-| MERGE + STRING truncate | required truncate 源列经 MERGE nullable projection 写入
| 已覆盖(隔离负向) | `test_iceberg_write_merge_truncate_negative` |
+| MERGE 基数约束 | 多个源行匹配同一目标行必须整句失败且不发布快照 | 已覆盖(常规回归) |
`test_iceberg_write_merge_duplicate_source_negative` |
+| MERGE + STRING truncate | required truncate 源列经 MERGE nullable projection 写入
| 已覆盖(常规回归) | `test_iceberg_write_merge_truncate_negative` |
| branch/tag 写入边界 | branch INSERT/OVERWRITE 隔离;tag 写入和 branch
DELETE/UPDATE/MERGE 明确拒绝 | 已覆盖(验证通过) | `test_iceberg_write_branch_dml_boundary`
|
| nullable 数据 | 顶层 NULL、ARRAY NULL 元素、MAP NULL value、STRUCT NULL child |
已覆盖并增强 | `test_iceberg_write_insert`、`test_iceberg_write_complex_evolution` |
| required 列正向与 schema change | required 列合法写入、nullable 列写 NULL、增加 required 列与
nullable→required 拒绝 | 已覆盖(验证通过) | `test_iceberg_write_nullability_atomicity` |
@@ -100,9 +100,9 @@ under the License.
| W05 | 验证不同类型与 NULL 的 partition/bucket transform | R02、R03、R11 | 功能、正确性、边界 |
Iceberg v2 | identity/bucket/truncate/time transform 多列组合,包含 NULL | 数据与
`$partitions` 统计一致;NULL 行可过滤且可继续写入 |
| W06 | 验证 required/nullable schema change 与合法写入 | R09、R11 | 异常、正确性 | Iceberg
required 列 | 拒绝增加无默认值 required 列和 nullable→required;执行 VALUES/INSERT SELECT
合法写入 | schema change 失败不产生 snapshot;合法写入与 Spark 结果一致 |
| W07 | 验证 required 列 NULL 拒绝和 statement 原子性 | R09、R11 | 隔离负向、正确性 | 隔离 Iceberg
database | VALUES 写 NULL;多 bucket 源表 INSERT SELECT 混合有效与 NULL 行 |
修复前会错误提交并产生不可读文件;修复后整条语句在 snapshot 发布前拒绝 |
-| W08 | 验证 STRING truncate 的 Nullable block 处理 | R03、R05、R12 | 隔离负向、稳定性 |
可重启的隔离 Doris 集群 | nullable STRING INSERT;partition evolution 后 UPDATE 产生
Nullable block | 修复前 BE FATAL;修复后写入成功并保持 NULL 分区语义 |
+| W08 | 验证 STRING truncate 的 Nullable block 处理 | R03、R05、R12 | 常规回归、稳定性 |
Iceberg v2 | nullable STRING INSERT;partition evolution 后 UPDATE 产生 Nullable
block | 写入成功并保持 NULL 分区语义,BE 不发生 FATAL |
| W09 | 验证 MERGE 条件动作、多个 NOT MATCHED 与 NULL key 语义 | R02、R03、R05 | 功能、正确性 |
Iceberg v2 MOR | identity/bucket 分区间移动、删除、插入、NULL-safe 与普通等值匹配 |
每个源行只选择一个动作,Spark 与 Doris 结果一致 |
-| W10 | 验证 MERGE 多源匹配单目标的基数约束 | R13 | 隔离负向、原子性 | Iceberg v2 MOR |
两个源行同时更新一个目标行 | 修复前错误提交重复行;修复后整句拒绝且无新快照和文件 |
+| W10 | 验证 MERGE 多源匹配单目标的基数约束 | R13 | 常规回归、原子性 | Iceberg v2 MOR |
两个源行同时更新一个目标行 | 整句拒绝且无新快照和文件 |
| W11 | 验证 branch/tag 的写入能力边界 | R04、R14 | 功能、异常、原子性 | 已建立 branch 与 tag |
branch INSERT/OVERWRITE;branch 行级 DML 与 tag 写入 | branch 与 main
隔离;不支持操作明确拒绝且引用不变化 |
| W12 | 验证多次 Partition Evolution 后覆盖写和历史引用 | R02、R04、R10、R15 | 功能、正确性 |
Iceberg v2 | ADD/REPLACE/DROP identity、bucket、truncate、day/hour 后动态覆盖写 | 仅替换当前
spec 命中的分区,tag/branch 和旧 spec 保持可读 |
| W13 | 验证 delete files 与覆盖写、演进的交互 | R02、R05、R15 | 正确性、兼容性 | Iceberg v2 MOR |
DELETE/UPDATE/MERGE 生成 delete files,再在新旧 spec 上覆盖写 | replacement 行不被旧 delete
files 隐藏,历史 tag 不受影响 |
@@ -111,10 +111,10 @@ under the License.
| W16 | 验证 CTAS、复杂类型、格式和失败清理 | R08、R09、R16 | 功能、异常、兼容性 | 内部多 bucket 源表 | CTAS
到 ORC 分区表;严格转换失败;向 Avro 表写入 | ORC 与 Spark 一致;失败不遗留表或快照;Avro 明确拒绝 |
| W17 | 验证 sort order、distribution mode 和多文件 flush | R03、R11、R17 | 正确性、稳定性 | 多
BE Doris | NULL sort key、多列升降序、none/hash/range、低 target file size |
计划包含声明排序,多文件总行数正确,三种分布模式结果一致 |
| W18 | 验证并发 MERGE 与 append 的提交不变量 | R11、R13、R17 | 并发、原子性 | 多 BE Doris |
readiness barrier 保证两个独立会话同时具备 dispatch 条件;同行更新与互不冲突 append | 同行提交可串行化且基数为一;仅接受
Iceberg validation/commit conflict;非冲突写入无丢失或重复 |
-| W19 | 验证 MERGE source projection 进入 truncate transform 的类型安全 | R12、R18 |
隔离负向、稳定性 | 可重启的隔离 Doris 集群 | required STRING truncate 列执行匹配更新与未匹配插入 | 修复前 BE
FATAL;修复后 MERGE 成功且物理分区正确 |
+| W19 | 验证 MERGE source projection 进入 truncate transform 的类型安全 | R12、R18 |
常规回归、稳定性 | Iceberg v2 MOR | required STRING truncate 列执行匹配更新与未匹配插入 | MERGE 接受
nullable 物理投影中的非 NULL 值,并生成正确物理分区 |
## P0 覆盖检查
-R01-R18 均映射到至少一个 P0 regression。十五个正向 suite 在最后一次成功写入后均由 Spark/Doris
交叉校验同表逻辑结果;transform、rollover、CTAS property 和失败原子性另有物理 metadata 或状态
oracle。稳定性或已确认正确性缺陷使用独立 suite、完整预期输出和显式隔离开关保存复现,避免默认 P0 破坏共享集群或固化错误结果。
+R01-R18 均映射到至少一个 P0 regression。十五个正向 suite 在最后一次成功写入后均由 Spark/Doris
交叉校验同表逻辑结果;transform、rollover、CTAS property 和失败原子性另有物理 metadata 或状态
oracle。W08、W10、W19 已作为常规回归运行;仍可能写出不可读 required-null 文件的 W07 保留隔离开关,避免破坏共享集群。
-本矩阵未覆盖项为 0。COW 行级 DML、branch 行级 DML、tag 写入和 Avro
写入属于当前明确能力边界,均以预期拒绝用例固化错误语义与失败原子性;已确认的产品缺陷均有隔离负向 regression。
+本矩阵未覆盖项为 0。COW 行级 DML、branch 行级 DML、tag 写入和 Avro
写入属于当前明确能力边界,均以预期拒绝用例固化错误语义与失败原子性;仅 required-null 已知缺陷继续使用隔离负向 regression。
diff --git
a/regression-test/suites/external_table_p0/iceberg/write/test_iceberg_write_merge_duplicate_source_negative.groovy
b/regression-test/suites/external_table_p0/iceberg/write/test_iceberg_write_merge_duplicate_source_negative.groovy
index fbb3be22c44..075bfccad96 100644
---
a/regression-test/suites/external_table_p0/iceberg/write/test_iceberg_write_merge_duplicate_source_negative.groovy
+++
b/regression-test/suites/external_table_p0/iceberg/write/test_iceberg_write_merge_duplicate_source_negative.groovy
@@ -16,23 +16,33 @@
// under the License.
suite("test_iceberg_write_merge_duplicate_source_negative",
- "p0,external,iceberg,external_docker,external_docker_iceberg") {
+
"p0,external,iceberg,external_docker,external_docker_iceberg,nonConcurrent") {
String enabled = context.config.otherConfigs.get("enableIcebergTest")
if (enabled == null || !enabled.equalsIgnoreCase("true")) {
logger.info("disable iceberg test")
return
}
- String knownBugEnabled =
context.config.otherConfigs.get("enableIcebergKnownBugTest")
- if (knownBugEnabled == null || !knownBugEnabled.equalsIgnoreCase("true")) {
- logger.info("skip isolated Iceberg known-bug test")
- return
- }
-
String restPort = context.config.otherConfigs.get("iceberg_rest_uri_port")
String minioPort = context.config.otherConfigs.get("iceberg_minio_port")
String externalEnvIp = context.config.otherConfigs.get("externalEnvIp")
String catalogName = "test_iceberg_write_merge_duplicate_source_negative"
String dbName = "iceberg_write_merge_duplicate_source_negative_db"
+ String dockerCommand =
context.config.otherConfigs.get("externalDockerCommand") ?: "docker"
+ String mcImage = "minio/mc:RELEASE.2025-01-17T23-25-50Z"
+
+ def countDataObjects = { String objectPath ->
+ def stdout = new StringBuilder()
+ def stderr = new StringBuilder()
+ def process = new ProcessBuilder("/bin/bash", "-c",
+ "${dockerCommand} run --rm --entrypoint /bin/sh ${mcImage} -c "
+ + "'/usr/bin/mc alias set minio
http://${externalEnvIp}:${minioPort} "
+ + "admin password >/dev/null "
+ + "&& /usr/bin/mc find ${objectPath} --type f | wc
-l'").start()
+ process.consumeProcessOutput(stdout, stderr)
+ process.waitForOrKill(30000)
+ assertEquals(0, process.exitValue(), "Failed to list Iceberg data
objects: ${stderr}")
+ return stdout.toString().trim() as long
+ }
sql """drop catalog if exists ${catalogName}"""
sql """
@@ -68,35 +78,94 @@ suite("test_iceberg_write_merge_duplicate_source_negative",
"""
sql """insert into duplicate_source_target values (1, 'A', 'committed')"""
+ String committedFile = (sql """
+ select file_path from duplicate_source_target\$files order by
file_path limit 1
+ """)[0][0].toString()
+ int dataDirectoryEnd = committedFile.indexOf('/data/') + '/data'.length()
+ assertTrue(dataDirectoryEnd >= '/data'.length(), "Unexpected Iceberg data
path: ${committedFile}")
+ String dataObjectPath = committedFile.substring(0, dataDirectoryEnd)
+ .replaceFirst('^s3a?://warehouse', 'minio/warehouse')
+ long objectsBefore = countDataObjects(dataObjectPath)
+
long snapshotsBefore =
(sql """select count(*) from
duplicate_source_target\$snapshots""")[0][0] as long
long filesBefore =
(sql """select count(*) from
duplicate_source_target\$files""")[0][0] as long
// Negative scenario: Iceberg MERGE cardinality permits only one source row
- // to update a target row. The entire statement must fail before
publishing.
+ // to update a target row. Tiny blocks and files force a valid row to roll
before
+ // the late duplicate, and the entire statement must still leave no data
object behind.
+ sql """set batch_size = 1"""
+ // A single pipeline instance preserves the ordered source sequence needed
to prove late cleanup.
+ sql """set parallel_pipeline_task_num = 1"""
+ sql """set iceberg_write_target_file_size_bytes = 1"""
test {
sql """
merge into duplicate_source_target t
using (
- select 1 as id, 'B' as region, 'first-update' as payload
- union all
- select 1, 'C', 'second-update'
+ select id, region, payload
+ from (
+ select 1 as seq, 2 as id, 'B' as region, 'valid-insert' as
payload
+ union all
+ select 2, 1, 'B', 'first-update'
+ union all
+ select 3, 1, 'C', 'second-update'
+ ) ordered_source
+ order by seq
) s
on t.id = s.id
when matched then update set
region = s.region,
payload = s.payload
+ when not matched then insert (id, region, payload)
+ values (s.id, s.region, s.payload)
"""
- exception "more than one"
+ exception "multiple source rows matched the same target row"
}
assertEquals(snapshotsBefore,
(sql """select count(*) from
duplicate_source_target\$snapshots""")[0][0] as long)
assertEquals(filesBefore,
(sql """select count(*) from
duplicate_source_target\$files""")[0][0] as long)
+ assertEquals(objectsBefore, countDataObjects(dataObjectPath))
order_qt_duplicate_source_atomic_state """
select id, region, payload
from duplicate_source_target
order by id
"""
+
+ // A sibling delete close can fail only after the data side has closed
successfully. The outer
+ // MERGE still owns and must remove those unpublished data objects.
+ long siblingFailureObjectsBefore = countDataObjects(dataObjectPath)
+ try {
+
GetDebugPoint().enableDebugPointForAllBEs("VIcebergDeleteSink.close.inject_failure")
+ test {
+ sql """
+ merge into duplicate_source_target t
+ using (
+ select 1 as id, 'B' as region, 'updated' as payload
+ union all
+ select 3, 'C', 'inserted'
+ ) s
+ on t.id = s.id
+ when matched then update set
+ region = s.region,
+ payload = s.payload
+ when not matched then insert (id, region, payload)
+ values (s.id, s.region, s.payload)
+ """
+ exception "injected Iceberg delete close failure"
+ }
+ } finally {
+
GetDebugPoint().disableDebugPointForAllBEs("VIcebergDeleteSink.close.inject_failure")
+ }
+ assertEquals(snapshotsBefore,
+ (sql """select count(*) from
duplicate_source_target\$snapshots""")[0][0] as long)
+ assertEquals(filesBefore,
+ (sql """select count(*) from
duplicate_source_target\$files""")[0][0] as long)
+ assertEquals(siblingFailureObjectsBefore, countDataObjects(dataObjectPath))
+ order_qt_sibling_close_atomic_state """
+ select id, region, payload
+ from duplicate_source_target
+ order by id
+ """
}
diff --git
a/regression-test/suites/external_table_p0/iceberg/write/test_iceberg_write_merge_truncate_negative.groovy
b/regression-test/suites/external_table_p0/iceberg/write/test_iceberg_write_merge_truncate_negative.groovy
index 31bfeb251ab..8a503d879ac 100644
---
a/regression-test/suites/external_table_p0/iceberg/write/test_iceberg_write_merge_truncate_negative.groovy
+++
b/regression-test/suites/external_table_p0/iceberg/write/test_iceberg_write_merge_truncate_negative.groovy
@@ -18,10 +18,8 @@
suite("test_iceberg_write_merge_truncate_negative",
"p0,external,iceberg,external_docker,external_docker_iceberg") {
String enabled = context.config.otherConfigs.get("enableIcebergTest")
- String crashTestEnabled =
context.config.otherConfigs.get("enableIcebergCrashTest")
- if (enabled == null || !enabled.equalsIgnoreCase("true")
- || crashTestEnabled == null ||
!crashTestEnabled.equalsIgnoreCase("true")) {
- logger.info("disable iceberg crash test")
+ if (enabled == null || !enabled.equalsIgnoreCase("true")) {
+ logger.info("disable iceberg test")
return
}
@@ -67,9 +65,9 @@ suite("test_iceberg_write_merge_truncate_negative",
"""
sql """insert into merge_truncate_negative values (1, 'alpha', 'before')"""
- // WM03-S01: A MERGE source projection is nullable even when every source
- // value and the Iceberg target column are NOT NULL. The writer must reject
- // an invalid input as a query error and must never terminate a BE.
+ // WM03-S01: A MERGE source projection is physically nullable even when
every source
+ // value and the Iceberg target column are NOT NULL. The writer must
accept those
+ // non-NULL values without terminating a BE or changing truncate partition
routing.
sql """
merge into merge_truncate_negative t
using (
@@ -92,8 +90,9 @@ suite("test_iceberg_write_merge_truncate_negative",
"""
// Logical rows cannot prove the MERGE writer used truncate(2) when routing
// its updated and inserted records.
+ // Iceberg names truncate transform fields with the `_trunc` suffix; the
width is not part of the name.
order_qt_merge_truncate_physical_partitions """
- select distinct hex(struct_element(`partition`,
'partition_value_trunc_2'))
+ select distinct hex(struct_element(`partition`,
'partition_value_trunc'))
from merge_truncate_negative\$partitions
order by 1
"""
diff --git
a/regression-test/suites/external_table_p0/iceberg/write/test_iceberg_write_nullable_truncate_negative.groovy
b/regression-test/suites/external_table_p0/iceberg/write/test_iceberg_write_nullable_truncate_negative.groovy
index 25074a0af78..a0358aef5b8 100644
---
a/regression-test/suites/external_table_p0/iceberg/write/test_iceberg_write_nullable_truncate_negative.groovy
+++
b/regression-test/suites/external_table_p0/iceberg/write/test_iceberg_write_nullable_truncate_negative.groovy
@@ -23,14 +23,6 @@ suite("test_iceberg_write_nullable_truncate_negative",
return
}
- // This opt-in switch isolates a BE-fatal negative scenario from the
shared P0 cluster.
- // Enable it only in a cluster whose BE processes can be restarted after
the suite.
- String crashTestEnabled =
context.config.otherConfigs.get("enableIcebergCrashTest")
- if (crashTestEnabled == null ||
!crashTestEnabled.equalsIgnoreCase("true")) {
- logger.info("skip isolated Iceberg crash regression")
- return
- }
-
String restPort = context.config.otherConfigs.get("iceberg_rest_uri_port")
String minioPort = context.config.otherConfigs.get("iceberg_minio_port")
String externalEnvIp = context.config.otherConfigs.get("externalEnvIp")
diff --git
a/regression-test/suites/external_table_p0/paimon/test_paimon_ctas_atomicity_negative.groovy
b/regression-test/suites/external_table_p0/paimon/test_paimon_ctas_atomicity_negative.groovy
index 6d449f4af1e..1fd4e27db7b 100644
---
a/regression-test/suites/external_table_p0/paimon/test_paimon_ctas_atomicity_negative.groovy
+++
b/regression-test/suites/external_table_p0/paimon/test_paimon_ctas_atomicity_negative.groovy
@@ -23,14 +23,6 @@ suite("test_paimon_ctas_atomicity_negative",
return
}
- // CTAS currently creates Paimon metadata before discovering that no
Paimon data sink exists.
- // Keep this opt-in until the product rejects or rolls back the statement
atomically.
- String knownBugEnabled =
context.config.otherConfigs.get("enablePaimonKnownBugTest")
- if (knownBugEnabled == null || !knownBugEnabled.equalsIgnoreCase("true")) {
- logger.info("skip isolated Paimon known-bug regression")
- return
- }
-
String minioPort = context.config.otherConfigs.get("iceberg_minio_port")
String externalEnvIp = context.config.otherConfigs.get("externalEnvIp")
String catalogName = "test_paimon_ctas_atomicity_negative"
@@ -67,6 +59,28 @@ suite("test_paimon_ctas_atomicity_negative",
exception "PaimonExternalCatalog"
}
assertEquals(0, (sql """show tables like 'ctas_target'""").size())
+
+ spark_paimon """
+ create table paimon.${dbName}.ctas_target (id int, payload string)
+ using paimon
+ """
+ // IF NOT EXISTS must remain a no-op even though Paimon does not
support the CTAS sink.
+ sql """
+ create table if not exists ctas_target engine=paimon
+ as select cast(1 as int) as id, cast('candidate' as string) as
payload
+ """
+ assertEquals(1, (sql """show tables like 'ctas_target'""").size())
+ assertEquals(0, (sql """select * from ctas_target""").size())
+
+ // An existing non-idempotent target must keep catalog error
precedence; no sink can own it.
+ test {
+ sql """
+ create table ctas_target engine=paimon
+ as select cast(2 as int) as id, cast('replacement' as string)
as payload
+ """
+ exception "already exists"
+ }
+ assertEquals(0, (sql """select * from ctas_target""").size())
} finally {
spark_paimon """drop table if exists paimon.${dbName}.ctas_target"""
sql """drop catalog if exists ${catalogName}"""
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]