github-actions[bot] commented on code in PR #66112:
URL: https://github.com/apache/doris/pull/66112#discussion_r3664610142
##########
be/src/exec/sink/viceberg_merge_sink.cpp:
##########
@@ -167,6 +180,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(
Review Comment:
[P1] Clean up rolled files when a later duplicate fails
This error can arrive after earlier valid blocks have already made the table
writer roll a data file. Rollover closes that `VIcebergPartitionWriter` with
`Status::OK()`, records commit data, and erases it from
`_partitions_to_writers`; the later error close can delete only writers that
are still open, while `IcebergTransaction.rollback()` does nothing for
insert/update mode. The statement therefore fails without publishing metadata
but leaves the earlier data object orphaned. The active regression's
`$snapshots`/`$files` checks cannot observe uncommitted storage objects. Track
all successfully closed files for error cleanup (or clean the accumulated
commit data on rollback), and cover a multi-block late duplicate with a tiny
target-file size plus an object-store listing.
##########
be/src/exec/sink/viceberg_merge_sink.cpp:
##########
@@ -120,17 +124,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;
}
}
+ // The physical sink hashes matched rows by row_id, so identical targets
reach the same sink.
+ // Exact state retained across blocks therefore enforces MERGE cardinality
for the whole query.
+ RETURN_IF_ERROR(_validate_matched_row_ids(output_block,
delete_filter.data()));
Review Comment:
[P1] Honor the UPDATE cardinality flag in BE
`IcebergUpdateCommand` now passes `requireMergeCardinalityCheck=false`, but
that flag is consumed only by FE property derivation and never reaches
`TIcebergMergeSink`. This is a functional regression for `UPDATE ... FROM`:
`LogicalPlanBuilder.visitUpdate()` joins the target with all `FROM` relations,
so a many-to-one join can emit the same target row ID twice and this new call
rejects the UPDATE with SQL MERGE's multiple-source error even though FE
explicitly marked validation unnecessary. Ordinary UPDATEs also retain a
redundant second file-to-position bitmap. Carry the discriminator through
thrift and honor false here, but preserve old-FE UPDATE behavior when the field
is absent and capability-gate new MERGE enforcement until every executing BE
understands it; add many-to-one UPDATE FROM, mixed-version, and
no-validator-state coverage.
##########
fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/commands/IcebergMergeCommand.java:
##########
@@ -465,6 +465,7 @@ private LogicalPlan buildMergePlan(ConnectContext ctx,
IcebergExternalTable iceb
icebergTable.getBaseSchema(true),
outputExprs,
deleteCtx,
+ true,
Review Comment:
[P2] Do not hash unmatched INSERTs by NULL row ID
This makes the sink property mandatory even when strict consistency is
disabled. With merge partitioning off, that property is `HASH(row_id)` for the
whole MERGE output, but `buildInsertProjection()` gives every unmatched INSERT
`row_id=NULL`; all of those inserts therefore collapse onto one exchange
channel. Insert-only MERGE sends its entire workload there, and mixed MERGE
hot-spots its unmatched branch as well. Cardinality needs row-ID colocation
only for matched delete/update images, so use operation-aware routing for that
requirement (or omit it when there are no matched actions) without hashing
INSERTs by NULL. Cover both strict-off not-matched-only and mixed MERGE.
##########
regression-test/suites/external_table_p0/paimon/test_paimon_ctas_atomicity_negative.groovy:
##########
@@ -67,6 +59,18 @@ 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())
Review Comment:
[P1] Refresh Paimon table names on the no-op path
The activated external regression already fails here in TeamCity build
1007534: expected one table, got zero. The earlier `SHOW TABLES` primes the
local name cache as empty; Spark then creates the remote target;
`performCreateTable()` observes it and returns `true`, so
`ExternalMetadataOps.createTable()` skips `afterCreateTable()` and Paimon's
only `resetMetaCacheNames()` call. CTAS `IF NOT EXISTS` reports success while
catalog enumeration still hides the target. This is distinct from the existing
no-op-error thread: the command now succeeds but leaves stale metadata.
Invalidate the table-name cache for both remote-existing `true` paths and
retain this assertion.
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: [email protected]
For queries about this service, please contact Infrastructure at:
[email protected]
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]