diff --git a/be/src/exec/operator/iceberg_sorter_reserve_memory.h b/be/src/exec/operator/iceberg_sorter_reserve_memory.h
new file mode 100644
index 00000000000000..1ad1454b4af5f3
--- /dev/null
+++ b/be/src/exec/operator/iceberg_sorter_reserve_memory.h
@@ -0,0 +1,45 @@
+// 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.
+
+#pragma once
+
+#include
+#include
+#include
+
+namespace doris {
+
+struct IcebergSorterReserveMemory {
+ size_t retained_growth = 0;
+ size_t transient_workspace = 0;
+};
+
+inline size_t bounded_iceberg_reserve_size(
+ const std::vector& per_partition_reservations) {
+ size_t retained_growth = 0;
+ size_t transient_workspace = 0;
+ for (const auto& reservation : per_partition_reservations) {
+ retained_growth = std::min(std::numeric_limits::max() - retained_growth,
+ reservation.retained_growth) +
+ retained_growth;
+ transient_workspace = std::max(transient_workspace, reservation.transient_workspace);
+ }
+ return std::min(std::numeric_limits::max() - retained_growth, transient_workspace) +
+ retained_growth;
+}
+
+} // namespace doris
diff --git a/be/src/exec/operator/spill_iceberg_table_sink_operator.cpp b/be/src/exec/operator/spill_iceberg_table_sink_operator.cpp
index cf7c8e6e1a1538..5d0d1cb00a7916 100644
--- a/be/src/exec/operator/spill_iceberg_table_sink_operator.cpp
+++ b/be/src/exec/operator/spill_iceberg_table_sink_operator.cpp
@@ -55,26 +55,34 @@ size_t SpillIcebergTableSinkLocalState::get_reserve_mem_size(RuntimeState* state
if (!_writer) {
return 0;
}
- auto current_writer = _writer->current_writer();
- auto* sort_writer = dynamic_cast(current_writer.get());
- if (!sort_writer) {
- return 0;
+ std::vector per_partition_reservations;
+ auto active_writers = _writer->active_writers();
+ per_partition_reservations.reserve(active_writers->size());
+ for (const auto& writer : *active_writers) {
+ if (auto* sort_writer = dynamic_cast(writer.get())) {
+ auto reservation = sort_writer->get_reserve_mem_size_components(state, eos);
+ per_partition_reservations.push_back(
+ {.retained_growth = reservation.retained_growth,
+ .transient_workspace = reservation.transient_workspace});
+ }
}
-
- return sort_writer->get_reserve_mem_size(state, eos);
+ // Column growth remains in every touched sorter, while sorting workspace is reused by serial dispatch.
+ return bounded_iceberg_reserve_size(per_partition_reservations);
}
size_t SpillIcebergTableSinkLocalState::get_revocable_mem_size(RuntimeState* state) const {
if (!_writer) {
return 0;
}
- auto current_writer = _writer->current_writer();
- auto* sort_writer = dynamic_cast(current_writer.get());
- if (!sort_writer) {
- return 0;
+ size_t revocable_size = 0;
+ // Retain the published container while the async writer may replace the current snapshot.
+ auto active_writers = _writer->active_writers();
+ for (const auto& writer : *active_writers) {
+ if (auto* sort_writer = dynamic_cast(writer.get())) {
+ revocable_size += sort_writer->data_size();
+ }
}
-
- return sort_writer->data_size();
+ return revocable_size;
}
Status SpillIcebergTableSinkLocalState::revoke_memory(RuntimeState* state) {
@@ -82,20 +90,25 @@ Status SpillIcebergTableSinkLocalState::revoke_memory(RuntimeState* state) {
if (!_writer) {
return Status::OK();
}
- auto current_writer = _writer->current_writer();
- auto* sort_writer = dynamic_cast(current_writer.get());
- if (!sort_writer) {
- return Status::OK();
+ std::shared_ptr largest_writer;
+ size_t largest_size = 0;
+ // Retain the published container while the async writer may replace the current snapshot.
+ auto active_writers = _writer->active_writers();
+ for (const auto& writer : *active_writers) {
+ if (auto* sort_writer = dynamic_cast(writer.get())) {
+ size_t size = sort_writer->data_size();
+ if (size > largest_size) {
+ largest_size = size;
+ largest_writer = writer;
+ }
+ }
}
-
- auto exception_catch_func = [current_writer, sort_writer]() {
- auto status = [&]() {
- RETURN_IF_CATCH_EXCEPTION({ return sort_writer->trigger_spill(); });
- }();
- return status;
- };
-
- return exception_catch_func();
+ if (largest_writer != nullptr) {
+ // Repeated revocation drains the largest partition first without launching O(P) spill jobs at once.
+ auto* sort_writer = dynamic_cast(largest_writer.get());
+ RETURN_IF_CATCH_EXCEPTION({ RETURN_IF_ERROR(sort_writer->trigger_spill()); });
+ }
+ return Status::OK();
}
SpillIcebergTableSinkOperatorX::SpillIcebergTableSinkOperatorX(
diff --git a/be/src/exec/operator/spill_iceberg_table_sink_operator.h b/be/src/exec/operator/spill_iceberg_table_sink_operator.h
index 6da926ae20fb91..5ffdd7505599ea 100644
--- a/be/src/exec/operator/spill_iceberg_table_sink_operator.h
+++ b/be/src/exec/operator/spill_iceberg_table_sink_operator.h
@@ -18,7 +18,9 @@
#pragma once
#include
+#include
+#include "exec/operator/iceberg_sorter_reserve_memory.h"
#include "exec/operator/operator.h"
#include "exec/sink/writer/iceberg/viceberg_table_writer.h"
@@ -87,4 +89,4 @@ class SpillIcebergTableSinkOperatorX final
ObjectPool* _pool = nullptr;
};
-} // namespace doris
\ No newline at end of file
+} // namespace doris
diff --git a/be/src/exec/pipeline/pipeline_fragment_context.cpp b/be/src/exec/pipeline/pipeline_fragment_context.cpp
index def2cf473aea77..5f3bb8d1070158 100644
--- a/be/src/exec/pipeline/pipeline_fragment_context.cpp
+++ b/be/src/exec/pipeline/pipeline_fragment_context.cpp
@@ -467,6 +467,8 @@ Status PipelineFragmentContext::_build_pipeline_tasks_for_instance(
_params.query_options, _query_ctx->query_globals, _exec_env, _query_ctx.get());
{
// Initialize runtime state for this task
+ task_runtime_state->set_iceberg_commit_data_budget(
+ _runtime_state->iceberg_commit_data_budget());
task_runtime_state->set_query_mem_tracker(_query_ctx->query_mem_tracker());
task_runtime_state->set_task_execution_context(shared_from_this());
@@ -2514,16 +2516,14 @@ void PipelineFragmentContext::_coordinator_callback(const ReportStatusRequest& r
}
}
}
- if (auto icd = req.runtime_state->iceberg_commit_datas(); !icd.empty()) {
+ req.runtime_state->append_iceberg_commit_datas(¶ms.iceberg_commit_datas);
+ if (!params.iceberg_commit_datas.empty()) {
params.__isset.iceberg_commit_datas = true;
- params.iceberg_commit_datas.insert(params.iceberg_commit_datas.end(), icd.begin(),
- icd.end());
} else if (!req.runtime_states.empty()) {
for (auto* rs : req.runtime_states) {
- if (auto rs_icd = rs->iceberg_commit_datas(); !rs_icd.empty()) {
+ rs->append_iceberg_commit_datas(¶ms.iceberg_commit_datas);
+ if (!params.iceberg_commit_datas.empty()) {
params.__isset.iceberg_commit_datas = true;
- params.iceberg_commit_datas.insert(params.iceberg_commit_datas.end(),
- rs_icd.begin(), rs_icd.end());
}
}
}
diff --git a/be/src/exec/sink/viceberg_delete_sink.cpp b/be/src/exec/sink/viceberg_delete_sink.cpp
index 172fdd28177c62..a3dcb1df22f657 100644
--- a/be/src/exec/sink/viceberg_delete_sink.cpp
+++ b/be/src/exec/sink/viceberg_delete_sink.cpp
@@ -283,8 +283,12 @@ Status VIcebergDeleteSink::close(Status close_status) {
_delete_file_count);
if (_state != nullptr) {
- for (const auto& commit_data : _commit_data_list) {
- _state->add_iceberg_commit_datas(commit_data);
+ for (auto& commit_data : _commit_data_list) {
+ Status report_status = _state->add_iceberg_commit_datas(std::move(commit_data));
+ if (!report_status.ok()) {
+ _cleanup_created_files();
+ return report_status;
+ }
}
}
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 ba7644daec751f..7faaebe9525e88 100644
--- a/be/src/exec/sink/writer/iceberg/viceberg_partition_writer.cpp
+++ b/be/src/exec/sink/writer/iceberg/viceberg_partition_writer.cpp
@@ -69,6 +69,7 @@ Status VIcebergPartitionWriter::open(RuntimeState* state, RuntimeProfile* profil
io::FileWriterOptions file_writer_options = {.used_by_s3_committer = false};
RETURN_IF_ERROR(_fs->create_file(file_description.path, &_file_writer, &file_writer_options));
+ Status open_status;
switch (_file_format_type) {
case TFileFormatType::FORMAT_PARQUET: {
TParquetCompressionType::type parquet_compression_type;
@@ -92,9 +93,13 @@ Status VIcebergPartitionWriter::open(RuntimeState* state, RuntimeProfile* profil
break;
}
default: {
- return Status::InternalError("Unsupported compress type {} with parquet",
- to_string(_compress_type));
+ open_status = Status::InternalError("Unsupported compress type {} with parquet",
+ to_string(_compress_type));
+ break;
+ }
}
+ if (!open_status.ok()) {
+ break;
}
ParquetFileOptions parquet_options = {.compression_type = parquet_compression_type,
.parquet_version = TParquetVersion::PARQUET_1_0,
@@ -103,19 +108,28 @@ Status VIcebergPartitionWriter::open(RuntimeState* state, RuntimeProfile* profil
_file_format_transformer = std::make_unique(
state, _file_writer.get(), _write_output_expr_ctxs, _write_column_names, false,
parquet_options, _iceberg_schema_json, &_schema);
- return _file_format_transformer->open();
+ open_status = _file_format_transformer->open();
+ break;
}
case TFileFormatType::FORMAT_ORC: {
_file_format_transformer = std::make_unique(
state, _file_writer.get(), _write_output_expr_ctxs, "", _write_column_names, false,
_compress_type, &_schema, _fs);
- return _file_format_transformer->open();
+ open_status = _file_format_transformer->open();
+ break;
}
default: {
- return Status::InternalError("Unsupported file format type {}",
- to_string(_file_format_type));
+ open_status = Status::InternalError("Unsupported file format type {}",
+ to_string(_file_format_type));
+ break;
}
}
+ if (!open_status.ok()) {
+ // A transformer failure happens after object creation, so abort multipart state before deleting the path.
+ WARN_IF_ERROR(_file_writer->abort(), "failed to abort Iceberg file after open error");
+ WARN_IF_ERROR(_fs->delete_file(_path), "failed to delete Iceberg file after open error");
+ }
+ return open_status;
}
Status VIcebergPartitionWriter::close(const Status& status) {
@@ -147,7 +161,12 @@ Status VIcebergPartitionWriter::close(const Status& status) {
}
return commit_status;
}
- _state->add_iceberg_commit_datas(commit_data);
+ Status report_status = _state->add_iceberg_commit_datas(std::move(commit_data));
+ if (!report_status.ok()) {
+ // A closed object that cannot be reported can never be committed, so remove it immediately.
+ WARN_IF_ERROR(_fs->delete_file(_path), "failed to delete unreportable Iceberg file");
+ return report_status;
+ }
if (_closed_file_callback) {
_closed_file_callback(_fs, _path);
}
diff --git a/be/src/exec/sink/writer/iceberg/viceberg_sort_writer.cpp b/be/src/exec/sink/writer/iceberg/viceberg_sort_writer.cpp
index 6081166777fc28..444aec8933ae6a 100644
--- a/be/src/exec/sink/writer/iceberg/viceberg_sort_writer.cpp
+++ b/be/src/exec/sink/writer/iceberg/viceberg_sort_writer.cpp
@@ -84,6 +84,13 @@ size_t VIcebergSortWriter::get_reserve_mem_size(RuntimeState* state, bool eos) c
return _sorter == nullptr ? 0 : _sorter->get_reserve_mem_size(state, eos);
}
+SorterReserveMemory VIcebergSortWriter::get_reserve_mem_size_components(RuntimeState* state,
+ bool eos) const {
+ std::lock_guard lock(_sorter_mutex);
+ return _sorter == nullptr ? SorterReserveMemory {}
+ : _sorter->get_reserve_mem_size_components(state, eos);
+}
+
Status VIcebergSortWriter::trigger_spill() {
std::lock_guard lock(_sorter_mutex);
if (_closed || _sorter == nullptr) {
@@ -102,80 +109,35 @@ Status VIcebergSortWriter::close(const Status& status) {
}
Status VIcebergSortWriter::_close_locked(const Status& status) {
- // Track the actual internal status of operations performed during close.
- // This is important because if intermediate operations (like do_sort()) fail,
- // we need to propagate the actual error status to the underlying partition writer's
- // close() call, rather than the original status parameter which could be OK.
- Status internal_status = Status::OK();
- // Track the close status of the underlying partition writer.
- // If _iceberg_partition_writer->close() fails (e.g., Parquet file flush error),
- // we must propagate this error to the caller to avoid silent data loss.
- Status close_status = Status::OK();
-
- // Defer ensures the underlying partition writer is always closed and
- // spill streams are cleaned up, regardless of whether intermediate operations succeed.
- // Uses internal_status to propagate any errors that occurred during close operations.
- Defer defer {[&]() {
- // If any intermediate operation failed, pass that error to the partition writer;
- // otherwise, pass the original status from the caller.
- close_status =
- _iceberg_partition_writer->close(internal_status.ok() ? status : internal_status);
- if (!close_status.ok()) {
- LOG(WARNING) << fmt::format("_iceberg_partition_writer close failed, reason: {}",
- close_status.to_string());
- }
- _cleanup_spill_streams();
- }};
-
- // If the original status is already an error or the query is cancelled,
- // skip all close operations and propagate the original error
- if (!status.ok() || _runtime_state->is_cancelled()) {
- return status;
- }
-
- // If sorter was never initialized (e.g., no data was written), nothing to do
- if (_sorter == nullptr) {
- return Status::OK();
- }
-
- // Check if there is any remaining data in the sorter (either unsorted or already sorted blocks)
- if (!_sorter->merge_sort_state()->unsorted_block()->empty() ||
- !_sorter->merge_sort_state()->get_sorted_block().empty()) {
- if (_sorted_spill_files.empty()) {
- // No spill has occurred, all data is in memory.
- // Sort the remaining data, prepare for reading, and write to file.
- internal_status = _sorter->do_sort();
- if (!internal_status.ok()) {
- return internal_status;
- }
- internal_status = _sorter->prepare_for_read(false);
- if (!internal_status.ok()) {
- return internal_status;
+ Status internal_status = status;
+ if (status.ok() && !_runtime_state->is_cancelled()) {
+ internal_status = Status::OK();
+ if (_sorter != nullptr && (!_sorter->merge_sort_state()->unsorted_block()->empty() ||
+ !_sorter->merge_sort_state()->get_sorted_block().empty())) {
+ if (_sorted_spill_files.empty()) {
+ internal_status = _sorter->do_sort();
+ if (internal_status.ok()) {
+ internal_status = _sorter->prepare_for_read(false);
+ }
+ if (internal_status.ok()) {
+ internal_status = _write_sorted_data();
+ }
+ } else {
+ internal_status = _do_spill();
}
- internal_status = _write_sorted_data();
- return internal_status;
}
-
- // Some data has already been spilled to disk.
- // Spill the remaining in-memory data to a new spill stream.
- internal_status = _do_spill();
- if (!internal_status.ok()) {
- return internal_status;
+ if (internal_status.ok() && !_sorted_spill_files.empty()) {
+ internal_status = _combine_files_output();
}
}
- // Merge all spilled streams using multi-way merge sort and output final sorted data to files
- if (!_sorted_spill_files.empty()) {
- internal_status = _combine_files_output();
- if (!internal_status.ok()) {
- return internal_status;
- }
+ // Form the return value only after the underlying close runs; a deferred assignment is too late.
+ Status close_status =
+ _iceberg_partition_writer->close(internal_status.ok() ? status : internal_status);
+ _cleanup_spill_streams();
+ if (!internal_status.ok()) {
+ return internal_status;
}
-
- // Return close_status if internal operations succeeded but the underlying
- // partition writer's close() failed (e.g., file flush error).
- // This prevents silent data loss where the caller thinks the write succeeded
- // but the file was not properly closed.
return close_status;
}
diff --git a/be/src/exec/sink/writer/iceberg/viceberg_sort_writer.h b/be/src/exec/sink/writer/iceberg/viceberg_sort_writer.h
index e1e512f0a0cf79..37659eeca4bc89 100644
--- a/be/src/exec/sink/writer/iceberg/viceberg_sort_writer.h
+++ b/be/src/exec/sink/writer/iceberg/viceberg_sort_writer.h
@@ -105,6 +105,8 @@ class VIcebergSortWriter : public IPartitionWriterBase {
size_t get_reserve_mem_size(RuntimeState* state, bool eos) const;
+ SorterReserveMemory get_reserve_mem_size_components(RuntimeState* state, bool eos) const;
+
// Called by the memory management system to trigger spilling data to disk
Status trigger_spill();
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 80db0876a9ba40..24c0269cf18901 100644
--- a/be/src/exec/sink/writer/iceberg/viceberg_table_writer.cpp
+++ b/be/src/exec/sink/writer/iceberg/viceberg_table_writer.cpp
@@ -44,6 +44,7 @@ VIcebergTableWriter::VIcebergTableWriter(const TDataSink& t_sink,
std::shared_ptr fin_dep)
: AsyncResultWriter(output_expr_ctxs, dep, fin_dep), _t_sink(t_sink) {
DCHECK(_t_sink.__isset.iceberg_table_sink);
+ _active_writers.store(std::make_shared());
}
Status VIcebergTableWriter::open(RuntimeState* state, RuntimeProfile* profile) {
@@ -242,7 +243,8 @@ Status VIcebergTableWriter::_process_row_lineage_columns(Block& block) {
Status VIcebergTableWriter::_write_prepared_block(Block& output_block) {
RETURN_IF_ERROR(_process_row_lineage_columns(output_block));
- std::unordered_map, IColumn::Filter> writer_positions;
+ std::unordered_map, IColumn::Permutation>
+ writer_positions;
_row_count += output_block.rows();
// Case 1: Full static partition - all data goes to a single partition
@@ -259,6 +261,7 @@ Status VIcebergTableWriter::_write_prepared_block(Block& output_block) {
}
_partitions_to_writers.insert({_static_partition_path, writer});
RETURN_IF_ERROR(writer->open(_state, _operator_profile, _row_desc));
+ _publish_active_writers();
} else {
if (writer_iter->second->written_len() > _target_file_size_bytes) {
std::string file_name(writer_iter->second->file_name());
@@ -268,6 +271,7 @@ Status VIcebergTableWriter::_write_prepared_block(Block& output_block) {
RETURN_IF_ERROR(writer_iter->second->close(Status::OK()));
}
_partitions_to_writers.erase(writer_iter);
+ _publish_active_writers();
try {
writer = _create_partition_writer(nullptr, -1, &file_name,
file_name_index + 1);
@@ -276,6 +280,7 @@ Status VIcebergTableWriter::_write_prepared_block(Block& output_block) {
}
_partitions_to_writers.insert({_static_partition_path, writer});
RETURN_IF_ERROR(writer->open(_state, _operator_profile, _row_desc));
+ _publish_active_writers();
} else {
writer = writer_iter->second;
}
@@ -284,7 +289,6 @@ Status VIcebergTableWriter::_write_prepared_block(Block& output_block) {
SCOPED_RAW_TIMER(&_partition_writers_write_ns);
output_block.erase(_non_write_columns_indices);
RETURN_IF_ERROR(writer->write(output_block));
- _current_writer.store(writer);
return Status::OK();
}
@@ -302,6 +306,7 @@ Status VIcebergTableWriter::_write_prepared_block(Block& output_block) {
}
_partitions_to_writers.insert({"", writer});
RETURN_IF_ERROR(writer->open(_state, _operator_profile, _row_desc));
+ _publish_active_writers();
} else {
if (writer_iter->second->written_len() > _target_file_size_bytes) {
std::string file_name(writer_iter->second->file_name());
@@ -311,6 +316,7 @@ Status VIcebergTableWriter::_write_prepared_block(Block& output_block) {
RETURN_IF_ERROR(writer_iter->second->close(Status::OK()));
}
_partitions_to_writers.erase(writer_iter);
+ _publish_active_writers();
try {
writer = _create_partition_writer(nullptr, -1, &file_name,
file_name_index + 1);
@@ -319,6 +325,7 @@ Status VIcebergTableWriter::_write_prepared_block(Block& output_block) {
}
_partitions_to_writers.insert({"", writer});
RETURN_IF_ERROR(writer->open(_state, _operator_profile, _row_desc));
+ _publish_active_writers();
} else {
writer = writer_iter->second;
}
@@ -327,7 +334,6 @@ Status VIcebergTableWriter::_write_prepared_block(Block& output_block) {
SCOPED_RAW_TIMER(&_partition_writers_write_ns);
output_block.erase(_non_write_columns_indices);
RETURN_IF_ERROR(writer->write(output_block));
- _current_writer.store(writer);
return Status::OK();
}
@@ -376,10 +382,8 @@ Status VIcebergTableWriter::_write_prepared_block(Block& output_block) {
auto writer = _create_partition_writer(&transformed_block, position, file_name,
file_name_index);
RETURN_IF_ERROR(writer->open(_state, _operator_profile, _row_desc));
- IColumn::Filter filter(output_block.rows(), 0);
- filter[position] = 1;
- writer_positions.insert({writer, std::move(filter)});
_partitions_to_writers.insert({partition_name, writer});
+ _publish_active_writers();
writer_ptr = writer;
} catch (doris::Exception& e) {
return e.to_status();
@@ -388,8 +392,8 @@ Status VIcebergTableWriter::_write_prepared_block(Block& output_block) {
};
auto writer_iter = _partitions_to_writers.find(partition_name);
+ std::shared_ptr writer;
if (writer_iter == _partitions_to_writers.end()) {
- std::shared_ptr writer;
if (_partitions_to_writers.size() + 1 >
config::table_sink_partition_write_max_partition_nums_per_writer) {
return Status::InternalError(
@@ -398,7 +402,6 @@ Status VIcebergTableWriter::_write_prepared_block(Block& output_block) {
}
RETURN_IF_ERROR(create_and_open_writer(partition_name, i, nullptr, 0, writer));
} else {
- std::shared_ptr writer;
if (writer_iter->second->written_len() > _target_file_size_bytes) {
std::string file_name(writer_iter->second->file_name());
int file_name_index = writer_iter->second->file_name_index();
@@ -408,53 +411,53 @@ Status VIcebergTableWriter::_write_prepared_block(Block& output_block) {
}
writer_positions.erase(writer_iter->second);
_partitions_to_writers.erase(writer_iter);
+ _publish_active_writers();
RETURN_IF_ERROR(create_and_open_writer(partition_name, i, &file_name,
file_name_index + 1, writer));
} else {
writer = writer_iter->second;
}
- auto writer_pos_iter = writer_positions.find(writer);
- if (writer_pos_iter == writer_positions.end()) {
- IColumn::Filter filter(output_block.rows(), 0);
- filter[i] = 1;
- writer_positions.insert({writer, std::move(filter)});
- } else {
- writer_pos_iter->second[i] = 1;
- }
+ }
+ auto writer_pos_iter = writer_positions.find(writer);
+ if (writer_pos_iter == writer_positions.end()) {
+ IColumn::Permutation rows {static_cast(i)};
+ writer_positions.insert({writer, std::move(rows)});
+ } else {
+ writer_pos_iter->second.push_back(static_cast(i));
}
}
}
SCOPED_RAW_TIMER(&_partition_writers_write_ns);
output_block.erase(_non_write_columns_indices);
for (auto it = writer_positions.begin(); it != writer_positions.end(); ++it) {
- Block filtered_block;
- RETURN_IF_ERROR(_filter_block(output_block, &it->second, &filtered_block));
- RETURN_IF_ERROR(it->first->write(filtered_block));
- _current_writer.store(it->first);
+ Block selected_block;
+ RETURN_IF_ERROR(_select_block(output_block, it->second, &selected_block));
+ RETURN_IF_ERROR(it->first->write(selected_block));
}
return Status::OK();
}
-Status VIcebergTableWriter::_filter_block(doris::Block& block, const IColumn::Filter* filter,
+Status VIcebergTableWriter::_select_block(doris::Block& block, const IColumn::Permutation& rows,
doris::Block* output_block) {
const ColumnsWithTypeAndName& columns_with_type_and_name =
block.get_columns_with_type_and_name();
ColumnsWithTypeAndName result_columns;
+ result_columns.reserve(columns_with_type_and_name.size());
for (const auto& col : columns_with_type_and_name) {
- result_columns.emplace_back(col.column->clone_resized(col.column->size()), col.type,
- col.name);
+ // Across all partitions the permutations contain exactly one entry per input row, avoiding O(P*C*R).
+ result_columns.emplace_back(col.column->permute(rows, rows.size()), col.type, col.name);
}
*output_block = {std::move(result_columns)};
+ return Status::OK();
+}
- std::vector columns_to_filter;
- int column_to_keep = output_block->columns();
- columns_to_filter.resize(column_to_keep);
- for (uint32_t i = 0; i < column_to_keep; ++i) {
- columns_to_filter[i] = i;
+void VIcebergTableWriter::_publish_active_writers() {
+ auto snapshot = std::make_shared();
+ snapshot->reserve(_partitions_to_writers.size());
+ for (const auto& entry : _partitions_to_writers) {
+ snapshot->push_back(entry.second);
}
-
- Block::filter_block_internal(output_block, columns_to_filter, *filter);
- return Status::OK();
+ _active_writers.store(std::move(snapshot));
}
Status VIcebergTableWriter::close(Status status) {
@@ -474,6 +477,7 @@ Status VIcebergTableWriter::close(Status status) {
}
}
_partitions_to_writers.clear();
+ _publish_active_writers();
}
if (status.ok()) {
SCOPED_TIMER(_operator_profile->total_time_counter());
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 070019d85db9be..4929f976b71e41 100644
--- a/be/src/exec/sink/writer/iceberg/viceberg_table_writer.h
+++ b/be/src/exec/sink/writer/iceberg/viceberg_table_writer.h
@@ -72,20 +72,14 @@ class VIcebergTableWriter final : public AsyncResultWriter {
TIcebergWriteType::type write_type() const { return _write_type; }
- // Getter for the current partition writer.
- // Used by SpillIcebergTableSinkLocalState to access the current writer for
- // memory management operations (get_reserve_mem_size, revocable_mem_size, etc.).
- // Returns a snapshot by value: the async writer thread updates _current_writer
- // concurrently with the spill/revoke path, so callers must hold their own copy
- // while operating on it instead of dereferencing the underlying member directly.
- std::shared_ptr current_writer() const { return _current_writer.load(); }
+ using ActiveWriterSnapshot = std::vector>;
+ std::shared_ptr active_writers() const { return _active_writers.load(); }
private:
- // The currently active partition writer (may be VIcebergPartitionWriter or VIcebergSortWriter).
- // Updated during write() to track which writer received the most recent data.
- // Wrapped in atomic_shared_ptr because revoke_memory / get_revocable_mem_size run on
- // a different thread than the async writer that assigns to it.
- doris::atomic_shared_ptr _current_writer;
+ friend class VIcebergTableWriterTest;
+
+ // The spill thread needs a stable view of every partition sorter, while the async writer owns the map.
+ doris::atomic_shared_ptr _active_writers;
class IcebergPartitionColumn {
public:
IcebergPartitionColumn(const iceberg::PartitionField& field,
@@ -140,8 +134,9 @@ class VIcebergTableWriter final : public AsyncResultWriter {
std::string _compute_file_name();
- Status _filter_block(doris::Block& block, const IColumn::Filter* filter,
+ Status _select_block(doris::Block& block, const IColumn::Permutation& rows,
doris::Block* output_block);
+ void _publish_active_writers();
Status _write_prepared_block(Block& output_block);
Status _process_row_lineage_columns(Block& block);
diff --git a/be/src/exec/sort/sorter.cpp b/be/src/exec/sort/sorter.cpp
index 2d9304adfa2f8e..c64d07b219a6cf 100644
--- a/be/src/exec/sort/sorter.cpp
+++ b/be/src/exec/sort/sorter.cpp
@@ -202,7 +202,12 @@ bool FullSorter::has_enough_capacity(Block* input_block, Block* unsorted_block)
}
size_t FullSorter::get_reserve_mem_size(RuntimeState* state, bool eos) const {
- size_t size_to_reserve = 0;
+ return get_reserve_mem_size_components(state, eos).total();
+}
+
+SorterReserveMemory FullSorter::get_reserve_mem_size_components(RuntimeState* state,
+ bool eos) const {
+ SorterReserveMemory reserve;
const auto rows = _state->unsorted_block()->rows();
if (rows != 0) {
const auto bytes = _state->unsorted_block()->bytes();
@@ -213,24 +218,24 @@ size_t FullSorter::get_reserve_mem_size(RuntimeState* state, bool eos) const {
auto new_rows = rows + state->batch_size();
// If the new size is greater than 85% of allocalted bytes, it maybe need to realloc.
if ((new_block_bytes * 100 / allocated_bytes) >= 85) {
- size_to_reserve += (size_t)(allocated_bytes * 1.15);
+ reserve.retained_growth += (size_t)(allocated_bytes * 1.15);
}
auto sort = new_rows > _buffered_block_size || new_block_bytes > _buffered_block_bytes;
if (sort) {
// new column is created when doing sort, reserve average size of one column
// for estimation
- size_to_reserve += new_block_bytes / _state->unsorted_block()->columns();
+ reserve.transient_workspace += new_block_bytes / _state->unsorted_block()->columns();
// helping data structures used during sorting
- size_to_reserve += new_rows * sizeof(IColumn::Permutation::value_type);
+ reserve.transient_workspace += new_rows * sizeof(IColumn::Permutation::value_type);
auto sort_columns_count = _ordering_expr_ctxs.size();
if (1 != sort_columns_count) {
- size_to_reserve += new_rows * sizeof(EqualRangeIterator);
+ reserve.transient_workspace += new_rows * sizeof(EqualRangeIterator);
}
}
}
- return size_to_reserve;
+ return reserve;
}
Status FullSorter::append_block(Block* block) {
diff --git a/be/src/exec/sort/sorter.h b/be/src/exec/sort/sorter.h
index 1651247eecc1ab..5c748f86a7f858 100644
--- a/be/src/exec/sort/sorter.h
+++ b/be/src/exec/sort/sorter.h
@@ -39,6 +39,13 @@
#include "runtime/runtime_state.h"
namespace doris {
+
+struct SorterReserveMemory {
+ size_t retained_growth = 0;
+ size_t transient_workspace = 0;
+
+ size_t total() const { return retained_growth + transient_workspace; }
+};
class ObjectPool;
class RowDescriptor;
} // namespace doris
@@ -194,6 +201,8 @@ class FullSorter final : public Sorter {
size_t get_reserve_mem_size(RuntimeState* state, bool eos) const override;
+ SorterReserveMemory get_reserve_mem_size_components(RuntimeState* state, bool eos) const;
+
Status merge_sort_read_for_spill(RuntimeState* state, doris::Block* block, int batch_size,
bool* eos) override;
void reset() override;
diff --git a/be/src/io/fs/azure_obj_storage_client.cpp b/be/src/io/fs/azure_obj_storage_client.cpp
index 9702c87b3b304b..daa215e3c58d9e 100644
--- a/be/src/io/fs/azure_obj_storage_client.cpp
+++ b/be/src/io/fs/azure_obj_storage_client.cpp
@@ -38,6 +38,7 @@
#include
#include
#include
+#include
#include
#include "common/exception.h"
@@ -46,8 +47,8 @@
#include "cpp/obj_retry_strategy.h"
#include "io/fs/obj_storage_client.h"
#include "util/bvar_helper.h"
-#include "util/coding.h"
#include "util/s3_util.h"
+#include "util/uuid_generator.h"
using namespace Azure::Storage::Blobs;
@@ -64,10 +65,13 @@ std::string to_lower_ascii(std::string_view input) {
return lowered;
}
-auto base64_encode_part_num(int part_num) {
- uint8_t buf[4];
- doris::encode_fixed32_le(buf, static_cast(part_num));
- return Aws::Utils::HashingUtils::Base64Encode({buf, sizeof(buf)});
+std::string azure_block_id(const doris::io::ObjectStoragePathOptions& opts, int part_num) {
+ DCHECK(opts.upload_id.has_value());
+ // Azure requires every block ID for one blob to have the same decoded length.
+ std::string raw_id = fmt::format("{}:{:010}", *opts.upload_id, part_num);
+ Aws::Utils::ByteBuffer bytes(reinterpret_cast(raw_id.data()),
+ raw_id.size());
+ return Aws::Utils::HashingUtils::Base64Encode(bytes);
}
// Rate limiting is applied by RateLimitedObjStorageClient, the decorator that
@@ -194,11 +198,13 @@ struct AzureBatchDeleter {
std::vector> deferred_resps;
};
-// Azure would do nothing
ObjectStorageUploadResponse AzureObjStorageClient::create_multipart_upload(
const ObjectStoragePathOptions& opts) {
+ std::stringstream upload_id;
+ upload_id << UUIDGenerator::instance()->next_uuid();
return ObjectStorageUploadResponse {
.resp = ObjectStorageResponse::OK(),
+ .upload_id = upload_id.str(),
};
}
@@ -217,17 +223,20 @@ ObjectStorageUploadResponse AzureObjStorageClient::upload_part(const ObjectStora
std::string_view stream,
int part_num) {
auto client = _client->GetBlockBlobClient(opts.key);
+ std::string block_id = azure_block_id(opts, part_num);
auto resp = do_azure_client_call(
[&]() {
Azure::Core::IO::MemoryBodyStream memory_body(
reinterpret_cast(stream.data()), stream.size());
// The blockId must be base64 encoded
SCOPED_BVAR_LATENCY(s3_bvar::s3_multi_part_upload_latency);
- client.StageBlock(base64_encode_part_num(part_num), memory_body);
+ client.StageBlock(block_id, memory_body);
},
opts, _tls_debug_context);
return ObjectStorageUploadResponse {
.resp = resp,
+ // Hive defers completion to FE, so the exact staged ID must cross that boundary.
+ .etag = block_id,
};
}
@@ -238,7 +247,7 @@ ObjectStorageResponse AzureObjStorageClient::complete_multipart_upload(
std::vector string_block_ids;
std::ranges::transform(
completed_parts, std::back_inserter(string_block_ids),
- [](const ObjectCompleteMultiPart& i) { return base64_encode_part_num(i.part_num); });
+ [&opts](const ObjectCompleteMultiPart& i) { return azure_block_id(opts, i.part_num); });
return do_azure_client_call(
[&]() {
SCOPED_BVAR_LATENCY(s3_bvar::s3_multi_part_upload_latency);
@@ -247,6 +256,41 @@ ObjectStorageResponse AzureObjStorageClient::complete_multipart_upload(
opts, _tls_debug_context);
}
+ObjectStorageResponse AzureObjStorageClient::abort_multipart_upload(
+ const ObjectStoragePathOptions& opts) {
+ auto client = _client->GetBlockBlobClient(opts.key);
+ auto response = do_azure_client_call(
+ [&]() {
+ GetBlockListOptions get_options;
+ get_options.ListType = Models::BlockListType::All;
+ auto block_list = client.GetBlockList(get_options);
+ const bool has_committed_blob = azure_block_list_has_committed_blob(
+ block_list.Value.CommittedBlocks.size(), block_list.Value.ETag.HasValue());
+ if (!has_committed_blob) {
+ // Uncommitted blocks are invisible and expire without deleting a racing commit.
+ return;
+ }
+ if (block_list.Value.CommittedBlocks.empty()) {
+ // Azure cannot selectively discard staged blocks without replacing Put Blob content.
+ return;
+ }
+ std::vector committed_ids;
+ committed_ids.reserve(block_list.Value.CommittedBlocks.size());
+ std::ranges::transform(block_list.Value.CommittedBlocks,
+ std::back_inserter(committed_ids),
+ [](const Models::BlobBlock& block) { return block.Name; });
+ CommitBlockListOptions commit_options;
+ commit_options.AccessConditions.IfMatch = block_list.Value.ETag;
+ // Recommitting only the old IDs discards this writer's unique staged blocks.
+ client.CommitBlockList(committed_ids, commit_options);
+ },
+ opts, _tls_debug_context);
+ // Azure creates no server-side object until the first block is staged, so absence is clean.
+ return response.http_code == static_cast(Azure::Core::Http::HttpStatusCode::NotFound)
+ ? ObjectStorageResponse::OK()
+ : response;
+}
+
ObjectStorageHeadResponse AzureObjStorageClient::head_object(const ObjectStoragePathOptions& opts) {
Models::BlobProperties properties {};
auto resp = do_azure_client_call(
diff --git a/be/src/io/fs/azure_obj_storage_client.h b/be/src/io/fs/azure_obj_storage_client.h
index 7d1cecc502e44d..fe3cf01417aa3b 100644
--- a/be/src/io/fs/azure_obj_storage_client.h
+++ b/be/src/io/fs/azure_obj_storage_client.h
@@ -33,6 +33,10 @@ class ObjClientHolder;
bool is_azure_tls_ca_error_message(std::string_view message);
std::string build_azure_tls_debug_suffix(std::string_view error_message,
std::string_view tls_debug_context);
+inline bool azure_block_list_has_committed_blob(size_t committed_block_count, bool has_etag) {
+ // Put Blob creates committed content without block IDs; its ETag is the only safe discriminator.
+ return committed_block_count > 0 || has_etag;
+}
class AzureObjStorageClient final : public ObjStorageClient {
public:
@@ -49,6 +53,7 @@ class AzureObjStorageClient final : public ObjStorageClient {
ObjectStorageResponse complete_multipart_upload(
const ObjectStoragePathOptions& opts,
const std::vector& completed_parts) override;
+ ObjectStorageResponse abort_multipart_upload(const ObjectStoragePathOptions& opts) override;
ObjectStorageHeadResponse head_object(const ObjectStoragePathOptions& opts) override;
ObjectStorageResponse get_object(const ObjectStoragePathOptions& opts, void* buffer,
size_t offset, size_t bytes_read,
diff --git a/be/src/io/fs/file_writer.h b/be/src/io/fs/file_writer.h
index 9402fdef18303c..08754ec3689a0f 100644
--- a/be/src/io/fs/file_writer.h
+++ b/be/src/io/fs/file_writer.h
@@ -73,6 +73,9 @@ class FileWriter {
// If there is no data appended, an empty file will be persisted.
virtual Status close(bool non_block = false) = 0;
+ // Abandon an unpublished file. Remote writers should cancel multipart state instead of completing it.
+ virtual Status abort() { return close(); }
+
// Non-blocking probe for a previous close(true).
// OK means close finished successfully. NeedSendAgain means close is still running.
// Other errors mean close finished with error or the writer does not support this API.
diff --git a/be/src/io/fs/obj_storage_client.h b/be/src/io/fs/obj_storage_client.h
index fa239ca3282e2a..db326a931719f9 100644
--- a/be/src/io/fs/obj_storage_client.h
+++ b/be/src/io/fs/obj_storage_client.h
@@ -44,7 +44,7 @@ struct ObjectStoragePathOptions {
std::string bucket = std::string(); // blob container in azure
std::string key = std::string(); // blob name in azure
std::string prefix = std::string(); // for batch delete and recursive delete
- std::optional upload_id = std::nullopt; // only used for S3 upload
+ std::optional upload_id = std::nullopt; // provider-specific upload token
};
struct ObjectCompleteMultiPart {
@@ -86,7 +86,7 @@ struct ObjectStorageHeadResponse : ObjectStorageResponse {
class ObjStorageClient {
public:
virtual ~ObjStorageClient() = default;
- // Create a multi-part upload request. On AWS-compatible systems, it will return an upload ID, but not on Azure.
+ // Create a multi-part upload request. The returned provider token identifies this upload's parts.
// The input parameters should include the bucket and key for the object storage.
virtual ObjectStorageUploadResponse create_multipart_upload(
const ObjectStoragePathOptions& opts) = 0;
@@ -106,6 +106,10 @@ class ObjStorageClient {
virtual ObjectStorageResponse complete_multipart_upload(
const ObjectStoragePathOptions& opts,
const std::vector& completed_parts) = 0;
+ virtual ObjectStorageResponse abort_multipart_upload(const ObjectStoragePathOptions&) {
+ return {.status = {.code = ErrorCode::NOT_IMPLEMENTED_ERROR,
+ .msg = "abort multipart upload is not supported"}};
+ }
// According to the passed bucket and key, it will access whether the corresponding file exists in the object storage.
// If it exists, it will return the corresponding file size
virtual ObjectStorageHeadResponse head_object(const ObjectStoragePathOptions& opts) = 0;
diff --git a/be/src/io/fs/rate_limited_obj_storage_client.cpp b/be/src/io/fs/rate_limited_obj_storage_client.cpp
index 1b8730847162df..218c39cb4b19ec 100644
--- a/be/src/io/fs/rate_limited_obj_storage_client.cpp
+++ b/be/src/io/fs/rate_limited_obj_storage_client.cpp
@@ -73,6 +73,12 @@ ObjectStorageResponse RateLimitedObjStorageClient::complete_multipart_upload(
return _inner->complete_multipart_upload(opts, completed_parts);
}
+ObjectStorageResponse RateLimitedObjStorageClient::abort_multipart_upload(
+ const ObjectStoragePathOptions& opts) {
+ // Cleanup must reach the provider even when a hard PUT limit caused the upload failure.
+ return _inner->abort_multipart_upload(opts);
+}
+
ObjectStorageHeadResponse RateLimitedObjStorageClient::head_object(
const ObjectStoragePathOptions& opts) {
S3RateLimitGuard guard(S3RateLimitType::GET, 0);
diff --git a/be/src/io/fs/rate_limited_obj_storage_client.h b/be/src/io/fs/rate_limited_obj_storage_client.h
index 00725d7edcb299..dc6fb1503c375d 100644
--- a/be/src/io/fs/rate_limited_obj_storage_client.h
+++ b/be/src/io/fs/rate_limited_obj_storage_client.h
@@ -50,6 +50,7 @@ class RateLimitedObjStorageClient final : public ObjStorageClient {
ObjectStorageResponse complete_multipart_upload(
const ObjectStoragePathOptions& opts,
const std::vector& completed_parts) override;
+ ObjectStorageResponse abort_multipart_upload(const ObjectStoragePathOptions& opts) override;
ObjectStorageHeadResponse head_object(const ObjectStoragePathOptions& opts) override;
ObjectStorageResponse get_object(const ObjectStoragePathOptions& opts, void* buffer,
size_t offset, size_t bytes_read,
diff --git a/be/src/io/fs/s3_file_writer.cpp b/be/src/io/fs/s3_file_writer.cpp
index f8b836607a14a6..43663afa6dfdf8 100644
--- a/be/src/io/fs/s3_file_writer.cpp
+++ b/be/src/io/fs/s3_file_writer.cpp
@@ -78,6 +78,8 @@ S3FileWriter::~S3FileWriter() {
// For thread safety
std::ignore = _async_close_pack->future.get();
_async_close_pack = nullptr;
+ } else if (state() == State::OPENED) {
+ WARN_IF_ERROR(abort(), "failed to abort unfinished S3 writer");
} else {
// Consider one situation where the file writer is destructed after it submit at least one async task
// without calling close(), then there exists one occasion where the async task is executed right after
@@ -85,13 +87,42 @@ S3FileWriter::~S3FileWriter() {
_wait_until_finish(fmt::format("wait s3 file {} upload to be finished",
_obj_storage_path_opts.path.native()));
}
- // We won't do S3 abort operation in BE, we let s3 service do it own.
if (state() == State::OPENED && !_failed) {
s3_bytes_written_total << _bytes_appended;
}
s3_file_being_written << -1;
}
+Status S3FileWriter::abort() {
+ if (state() == State::CLOSED) {
+ return Status::OK();
+ }
+ if (state() == State::ASYNC_CLOSING) {
+ return Status::InternalError("cannot abort an asynchronously closing S3 writer");
+ }
+ RETURN_IF_ERROR(_abort_impl());
+ _state = State::CLOSED;
+ return Status::OK();
+}
+
+Status S3FileWriter::_abort_impl() {
+ _wait_until_finish(
+ fmt::format("wait s3 file {} before abort", _obj_storage_path_opts.path.native()));
+ _pending_buf.reset();
+ if (_multipart_upload_started) {
+ const auto& client = _obj_client->get();
+ if (client == nullptr) {
+ return Status::InternalError("invalid obj storage client");
+ }
+ auto response = client->abort_multipart_upload(_obj_storage_path_opts);
+ if (response.status.code != ErrorCode::OK) {
+ return {response.status.code, std::move(response.status.msg)};
+ }
+ }
+ // Once abort returns, no destructor or retry may complete the abandoned upload.
+ return Status::OK();
+}
+
Status S3FileWriter::_create_multi_upload_request() {
LOG(INFO) << "create_multi_upload_request " << _obj_storage_path_opts.path.native();
const auto& client = _obj_client->get();
@@ -100,6 +131,8 @@ Status S3FileWriter::_create_multi_upload_request() {
}
auto resp = client->create_multipart_upload(_obj_storage_path_opts);
if (resp.resp.status.code == ErrorCode::OK) {
+ // Some providers identify staged uploads by block IDs instead of a server-issued upload ID.
+ _multipart_upload_started = true;
_obj_storage_path_opts.upload_id = resp.upload_id;
}
return {resp.resp.status.code, std::move(resp.resp.status.msg)};
@@ -162,6 +195,10 @@ Status S3FileWriter::close(bool non_block) {
s3_file_writer_async_close_queuing << -1;
s3_file_writer_async_close_processing << 1;
_st = _close_impl();
+ if (!_st.ok()) {
+ // A failed completion must not leave server-side multipart state behind.
+ WARN_IF_ERROR(_abort_impl(), "failed to abort incomplete S3 upload");
+ }
_async_close_pack->promise.set_value(_st);
s3_file_writer_async_close_processing << -1;
});
@@ -172,12 +209,18 @@ Status S3FileWriter::close(bool non_block) {
<< _obj_storage_path_opts.path.native()
<< ", fallback to sync close, status=" << submit_status;
_st = _close_impl();
+ if (!_st.ok()) {
+ WARN_IF_ERROR(_abort_impl(), "failed to abort incomplete S3 upload");
+ }
_async_close_pack->promise.set_value(_st);
return _st;
}
return Status::OK();
}
_st = _close_impl();
+ if (!_st.ok()) {
+ WARN_IF_ERROR(_abort_impl(), "failed to abort incomplete S3 upload");
+ }
_state = State::CLOSED;
if (!non_block && _st.ok()) {
_record_close_latency();
diff --git a/be/src/io/fs/s3_file_writer.h b/be/src/io/fs/s3_file_writer.h
index 5a8075e03cf404..eb40772eea095e 100644
--- a/be/src/io/fs/s3_file_writer.h
+++ b/be/src/io/fs/s3_file_writer.h
@@ -71,11 +71,12 @@ class S3FileWriter final : public FileWriter {
}
Status close(bool non_block = false) override;
+ Status abort() override;
Status try_finish_close() override;
private:
+ Status _abort_impl();
Status _close_impl();
- Status _abort();
[[nodiscard]] std::string _dump_completed_part() const;
void _wait_until_finish(std::string_view task_name);
Status _complete();
@@ -118,6 +119,7 @@ class S3FileWriter final : public FileWriter {
std::shared_ptr _obj_client;
std::optional _first_append_timestamp;
bool _close_latency_recorded = false;
+ bool _multipart_upload_started = false;
};
} // namespace io
diff --git a/be/src/io/fs/s3_obj_storage_client.cpp b/be/src/io/fs/s3_obj_storage_client.cpp
index 0c0b0370f8097f..54ba6b687e9790 100644
--- a/be/src/io/fs/s3_obj_storage_client.cpp
+++ b/be/src/io/fs/s3_obj_storage_client.cpp
@@ -275,6 +275,25 @@ ObjectStorageResponse S3ObjStorageClient::complete_multipart_upload(
return ObjectStorageResponse::OK();
}
+ObjectStorageResponse S3ObjStorageClient::abort_multipart_upload(
+ const ObjectStoragePathOptions& opts) {
+ AbortMultipartUploadRequest request;
+ request.WithBucket(opts.bucket).WithKey(opts.key).WithUploadId(*opts.upload_id);
+ auto outcome = SYNC_POINT_HOOK_RETURN_VALUE(_client->AbortMultipartUpload(request),
+ "s3_file_writer::abort_multi_part",
+ std::cref(request).get());
+ if (!outcome.IsSuccess()) {
+ record_s3_request_failed(outcome.GetError());
+ auto status = s3fs_error(outcome.GetError(),
+ fmt::format("failed to AbortMultipartUpload: {}, upload_id={}",
+ opts.path.native(), *opts.upload_id));
+ return {convert_to_obj_response(std::move(status)),
+ static_cast(outcome.GetError().GetResponseCode()),
+ outcome.GetError().GetRequestId()};
+ }
+ return ObjectStorageResponse::OK();
+}
+
ObjectStorageHeadResponse S3ObjStorageClient::head_object(const ObjectStoragePathOptions& opts) {
Aws::S3::Model::HeadObjectRequest request;
request.WithBucket(opts.bucket).WithKey(opts.key);
diff --git a/be/src/io/fs/s3_obj_storage_client.h b/be/src/io/fs/s3_obj_storage_client.h
index 45294226594d81..10bcf6b2e9495b 100644
--- a/be/src/io/fs/s3_obj_storage_client.h
+++ b/be/src/io/fs/s3_obj_storage_client.h
@@ -43,6 +43,7 @@ class S3ObjStorageClient final : public ObjStorageClient {
ObjectStorageResponse complete_multipart_upload(
const ObjectStoragePathOptions& opts,
const std::vector& completed_parts) override;
+ ObjectStorageResponse abort_multipart_upload(const ObjectStoragePathOptions& opts) override;
ObjectStorageHeadResponse head_object(const ObjectStoragePathOptions& opts) override;
ObjectStorageResponse get_object(const ObjectStoragePathOptions& opts, void* buffer,
size_t offset, size_t bytes_read,
diff --git a/be/src/runtime/runtime_state.cpp b/be/src/runtime/runtime_state.cpp
index 5dfd027d42d4ce..fde7ca3bc2d0ad 100644
--- a/be/src/runtime/runtime_state.cpp
+++ b/be/src/runtime/runtime_state.cpp
@@ -52,12 +52,37 @@
#include "runtime/thread_context.h"
#include "storage/id_manager.h"
#include "storage/storage_engine.h"
+#include "util/thrift_util.h"
#include "util/timezone_utils.h"
#include "util/uid_util.h"
namespace doris {
using namespace ErrorCode;
+Status RuntimeState::add_iceberg_commit_datas(TIcebergCommitData iceberg_commit_data) {
+ ThriftSerializer serializer(false, 256);
+ uint32_t serialized_size = 0;
+ uint8_t* buffer = nullptr;
+ RETURN_IF_ERROR(serializer.serialize(&iceberg_commit_data, &serialized_size, &buffer));
+
+ constexpr size_t report_envelope_headroom = 1024 * 1024;
+ const size_t thrift_limit = static_cast(std::max(config::thrift_max_message_size, 0));
+ const size_t commit_data_limit =
+ thrift_limit > report_envelope_headroom ? thrift_limit - report_envelope_headroom : 0;
+ std::lock_guard budget_lock(_iceberg_commit_data_budget->mutex);
+ // Parallel task states share this budget because FE receives their vectors in one fragment report.
+ if (_iceberg_commit_data_budget->serialized_bytes + serialized_size + sizeof(uint32_t) >
+ commit_data_limit) {
+ return Status::InternalError(
+ "Iceberg commit metadata exceeds the Thrift report limit; reduce output file "
+ "count");
+ }
+ std::lock_guard data_lock(_iceberg_commit_datas_mutex);
+ _iceberg_commit_data_budget->serialized_bytes += serialized_size + sizeof(uint32_t);
+ _iceberg_commit_datas.emplace_back(std::move(iceberg_commit_data));
+ return Status::OK();
+}
+
RuntimeState::RuntimeState(const TPlanFragmentExecParams& fragment_exec_params,
const TQueryOptions& query_options, const TQueryGlobals& query_globals,
ExecEnv* exec_env, QueryContext* ctx,
diff --git a/be/src/runtime/runtime_state.h b/be/src/runtime/runtime_state.h
index 0551c2e533689c..faa3ce1a7ef040 100644
--- a/be/src/runtime/runtime_state.h
+++ b/be/src/runtime/runtime_state.h
@@ -74,6 +74,14 @@ class RuntimeFilterConsumer;
class RuntimeFilterProducer;
class TaskExecutionContext;
+class IcebergCommitDataBudget {
+ friend class RuntimeState;
+
+private:
+ std::mutex mutex;
+ size_t serialized_bytes = 0;
+};
+
// A collection of items that are part of the global state of a
// query and shared across all execution nodes of that query.
class RuntimeState {
@@ -523,14 +531,19 @@ class RuntimeState {
_hive_partition_updates.emplace_back(hive_partition_update);
}
- std::vector iceberg_commit_datas() const {
+ void append_iceberg_commit_datas(std::vector* output) const {
std::lock_guard lock(_iceberg_commit_datas_mutex);
- return _iceberg_commit_datas;
+ output->insert(output->end(), _iceberg_commit_datas.begin(), _iceberg_commit_datas.end());
}
- void add_iceberg_commit_datas(const TIcebergCommitData& iceberg_commit_data) {
- std::lock_guard lock(_iceberg_commit_datas_mutex);
- _iceberg_commit_datas.emplace_back(iceberg_commit_data);
+ Status add_iceberg_commit_datas(TIcebergCommitData iceberg_commit_data);
+
+ void set_iceberg_commit_data_budget(std::shared_ptr budget) {
+ _iceberg_commit_data_budget = std::move(budget);
+ }
+
+ const std::shared_ptr& iceberg_commit_data_budget() const {
+ return _iceberg_commit_data_budget;
}
std::vector mc_commit_datas() const {
@@ -976,6 +989,8 @@ class RuntimeState {
mutable std::mutex _iceberg_commit_datas_mutex;
std::vector _iceberg_commit_datas;
+ std::shared_ptr _iceberg_commit_data_budget =
+ std::make_shared();
mutable std::mutex _mc_commit_datas_mutex;
std::vector _mc_commit_datas;
diff --git a/be/test/exec/operator/spill_iceberg_table_sink_operator_test.cpp b/be/test/exec/operator/spill_iceberg_table_sink_operator_test.cpp
new file mode 100644
index 00000000000000..5d1fa473205f0b
--- /dev/null
+++ b/be/test/exec/operator/spill_iceberg_table_sink_operator_test.cpp
@@ -0,0 +1,39 @@
+// 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.
+
+#include
+
+#include "exec/operator/iceberg_sorter_reserve_memory.h"
+
+namespace doris {
+
+TEST(SpillIcebergTableSinkOperatorTest, BoundsManyPartitionReservationToOneInputBlock) {
+ std::vector per_partition_reservations(
+ 128, {.retained_growth = 0, .transient_workspace = 8 * 1024 * 1024});
+
+ EXPECT_EQ(8 * 1024 * 1024, bounded_iceberg_reserve_size(per_partition_reservations));
+}
+
+TEST(SpillIcebergTableSinkOperatorTest, AccumulatesRetainedGrowthAcrossTouchedPartitions) {
+ std::vector per_partition_reservations {
+ {.retained_growth = 3 * 1024 * 1024, .transient_workspace = 7 * 1024 * 1024},
+ {.retained_growth = 4 * 1024 * 1024, .transient_workspace = 5 * 1024 * 1024}};
+
+ EXPECT_EQ(14 * 1024 * 1024, bounded_iceberg_reserve_size(per_partition_reservations));
+}
+
+} // namespace doris
diff --git a/be/test/exec/sink/writer/iceberg/iceberg_partition_writer_test.cpp b/be/test/exec/sink/writer/iceberg/iceberg_partition_writer_test.cpp
index d453177cf25044..18af0af7cb23bd 100644
--- a/be/test/exec/sink/writer/iceberg/iceberg_partition_writer_test.cpp
+++ b/be/test/exec/sink/writer/iceberg/iceberg_partition_writer_test.cpp
@@ -20,6 +20,8 @@
#include
#include "exec/sink/writer/iceberg/viceberg_partition_writer.h"
+#include "exec/sink/writer/iceberg/viceberg_sort_writer.h"
+#include "testutil/mock/mock_runtime_state.h"
namespace doris {
@@ -27,13 +29,18 @@ namespace {
class FakeFileFormatTransformer final : public VFileFormatTransformer {
public:
- explicit FakeFileFormatTransformer(const VExprContextSPtrs& output_exprs)
- : VFileFormatTransformer(nullptr, output_exprs, false) {}
+ explicit FakeFileFormatTransformer(const VExprContextSPtrs& output_exprs,
+ Status close_status = Status::OK())
+ : VFileFormatTransformer(nullptr, output_exprs, false),
+ _close_status(std::move(close_status)) {}
Status open() override { return Status::OK(); }
Status write(const Block&) override { return Status::OK(); }
- Status close() override { return Status::OK(); }
+ Status close() override { return _close_status; }
int64_t written_len() override { return 64; }
+
+private:
+ Status _close_status;
};
TDataSink make_table_sink(std::optional collect_column_stats) {
@@ -64,9 +71,10 @@ class VIcebergPartitionWriterTest : public testing::Test {
}
static void install_fake_transformer(VIcebergPartitionWriter* writer,
- const VExprContextSPtrs& output_exprs) {
+ const VExprContextSPtrs& output_exprs,
+ Status close_status = Status::OK()) {
writer->_file_format_transformer =
- std::make_unique(output_exprs);
+ std::make_unique(output_exprs, std::move(close_status));
}
static Status build_commit_data(VIcebergPartitionWriter* writer,
@@ -104,4 +112,23 @@ TEST_F(VIcebergPartitionWriterTest, MissingPolicyKeepsCollectionEnabledForRollin
EXPECT_TRUE(collect_column_stats(*writer));
}
+TEST_F(VIcebergPartitionWriterTest, SortWriterPropagatesUnderlyingCloseFailure) {
+ VExprContextSPtrs output_exprs;
+ iceberg::Schema schema(std::vector {});
+ std::string schema_json;
+ std::map hadoop_conf;
+ auto partition_writer = std::shared_ptr(
+ make_writer(make_table_sink(false), output_exprs, schema, &schema_json, hadoop_conf));
+ install_fake_transformer(partition_writer.get(), output_exprs,
+ Status::IOError("injected close failure"));
+ VIcebergSortWriter sort_writer(partition_writer, TSortInfo(), 1024);
+ MockRuntimeState state;
+ sort_writer._runtime_state = &state;
+
+ Status status = sort_writer.close(Status::OK());
+
+ EXPECT_FALSE(status.ok());
+ EXPECT_NE(status.to_string().find("injected close failure"), std::string::npos);
+}
+
} // namespace doris
diff --git a/be/test/exec/sink/writer/iceberg/iceberg_table_writer_test.cpp b/be/test/exec/sink/writer/iceberg/iceberg_table_writer_test.cpp
new file mode 100644
index 00000000000000..e38da59cfbc176
--- /dev/null
+++ b/be/test/exec/sink/writer/iceberg/iceberg_table_writer_test.cpp
@@ -0,0 +1,144 @@
+// 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.
+
+#include
+
+#include
+#include
+
+#include "core/column/column_vector.h"
+#include "core/data_type/data_type_number.h"
+#include "exec/sink/writer/iceberg/viceberg_table_writer.h"
+#include "exec/sink/writer/iceberg/vpartition_writer_base.h"
+
+namespace doris {
+
+namespace {
+
+class FakePartitionWriter final : public IPartitionWriterBase {
+public:
+ explicit FakePartitionWriter(std::atomic* destroyed = nullptr) : _destroyed(destroyed) {}
+ ~FakePartitionWriter() override {
+ if (_destroyed != nullptr) {
+ ++(*_destroyed);
+ }
+ }
+ Status open(RuntimeState*, RuntimeProfile*, const RowDescriptor*) override {
+ return Status::OK();
+ }
+ Status write(Block&) override { return Status::OK(); }
+ Status close(const Status&) override { return Status::OK(); }
+ const std::string& file_name() const override { return _name; }
+ int file_name_index() const override { return 0; }
+ size_t written_len() const override { return 0; }
+
+private:
+ std::string _name = "fake";
+ std::atomic* _destroyed;
+};
+
+TDataSink make_sink() {
+ TDataSink sink;
+ sink.__set_type(TDataSinkType::ICEBERG_TABLE_SINK);
+ sink.__set_iceberg_table_sink(TIcebergTableSink());
+ return sink;
+}
+
+} // namespace
+
+class VIcebergTableWriterTest : public testing::Test {
+protected:
+ static Status select_block(VIcebergTableWriter* writer, Block& input,
+ const IColumn::Permutation& rows, Block* selected) {
+ return writer->_select_block(input, rows, selected);
+ }
+
+ static void add_writer(VIcebergTableWriter* writer, std::string partition) {
+ writer->_partitions_to_writers.emplace(std::move(partition),
+ std::make_shared());
+ }
+
+ static void add_writer(VIcebergTableWriter* writer, std::string partition,
+ std::shared_ptr partition_writer) {
+ writer->_partitions_to_writers.emplace(std::move(partition), std::move(partition_writer));
+ }
+
+ static void clear_writers(VIcebergTableWriter* writer) {
+ writer->_partitions_to_writers.clear();
+ }
+
+ static void publish_active_writers(VIcebergTableWriter* writer) {
+ writer->_publish_active_writers();
+ }
+};
+
+TEST_F(VIcebergTableWriterTest, SelectBlockUsesRowPermutation) {
+ VIcebergTableWriter writer(make_sink(), {}, nullptr, nullptr);
+ auto values = ColumnInt32::create();
+ values->insert_value(10);
+ values->insert_value(20);
+ values->insert_value(30);
+ Block input;
+ input.insert({std::move(values), std::make_shared(), "value"});
+ IColumn::Permutation rows {2, 0};
+ Block selected;
+
+ ASSERT_TRUE(select_block(&writer, input, rows, &selected).ok());
+
+ const auto& result = assert_cast(*selected.get_by_position(0).column);
+ ASSERT_EQ(result.size(), 2);
+ EXPECT_EQ(result.get_element(0), 30);
+ EXPECT_EQ(result.get_element(1), 10);
+}
+
+TEST_F(VIcebergTableWriterTest, ActiveWriterSnapshotContainsEveryOpenPartition) {
+ VIcebergTableWriter writer(make_sink(), {}, nullptr, nullptr);
+ add_writer(&writer, "p=1");
+ add_writer(&writer, "p=2");
+
+ publish_active_writers(&writer);
+
+ ASSERT_NE(writer.active_writers(), nullptr);
+ EXPECT_EQ(writer.active_writers()->size(), 2);
+}
+
+TEST_F(VIcebergTableWriterTest, LoadedSnapshotRetainsWritersDuringConcurrentPublication) {
+ VIcebergTableWriter writer(make_sink(), {}, nullptr, nullptr);
+ std::atomic destroyed = 0;
+ add_writer(&writer, "p=1", std::make_shared(&destroyed));
+ publish_active_writers(&writer);
+ std::promise snapshot_loaded;
+ std::promise replacement_published;
+
+ auto reader = std::async(std::launch::async, [&]() {
+ auto snapshot = writer.active_writers();
+ snapshot_loaded.set_value();
+ replacement_published.get_future().wait();
+ EXPECT_EQ(1, snapshot->size());
+ EXPECT_EQ("fake", snapshot->front()->file_name());
+ });
+
+ snapshot_loaded.get_future().wait();
+ clear_writers(&writer);
+ publish_active_writers(&writer);
+ EXPECT_EQ(0, destroyed.load());
+ replacement_published.set_value();
+ reader.get();
+ EXPECT_EQ(1, destroyed.load());
+}
+
+} // namespace doris
diff --git a/be/test/io/fs/azure_obj_storage_client_test.cpp b/be/test/io/fs/azure_obj_storage_client_test.cpp
index 7591b4bf2ea997..88e81ea7fe6ed0 100644
--- a/be/test/io/fs/azure_obj_storage_client_test.cpp
+++ b/be/test/io/fs/azure_obj_storage_client_test.cpp
@@ -19,6 +19,8 @@
#include
+#include
+
#include "io/fs/file_system.h"
#include "io/fs/obj_storage_client.h"
#include "util/s3_util.h"
@@ -32,6 +34,12 @@
namespace doris {
+TEST(AzureObjStorageClientAbortHelperTest, preserves_committed_put_blob_without_block_list) {
+ EXPECT_FALSE(io::azure_block_list_has_committed_blob(0, false));
+ EXPECT_TRUE(io::azure_block_list_has_committed_blob(0, true));
+ EXPECT_TRUE(io::azure_block_list_has_committed_blob(1, false));
+}
+
#ifdef USE_AZURE
using namespace Azure::Storage::Blobs;
@@ -156,6 +164,58 @@ TEST_F(AzureObjStorageClientTest, delete_objects_recursively) {
EXPECT_EQ(response.status.code, ErrorCode::OK);
EXPECT_EQ(files.size(), 0);
}
+
+TEST_F(AzureObjStorageClientTest, abort_multipart_upload_discards_staged_blocks) {
+ io::ObjectStoragePathOptions opts;
+ auto create_response =
+ AzureObjStorageClientTest::obj_storage_client->create_multipart_upload(opts);
+ ASSERT_EQ(create_response.resp.status.code, ErrorCode::OK);
+ ASSERT_TRUE(create_response.upload_id.has_value());
+ opts.key = "AzureObjStorageClientTest/abort_multipart_upload_" + *create_response.upload_id;
+ opts.upload_id = create_response.upload_id;
+
+ auto upload_response =
+ AzureObjStorageClientTest::obj_storage_client->upload_part(opts, "staged", 1);
+ ASSERT_EQ(upload_response.resp.status.code, ErrorCode::OK);
+ ASSERT_TRUE(upload_response.etag.has_value());
+ EXPECT_FALSE(upload_response.etag->empty());
+ auto abort_response =
+ AzureObjStorageClientTest::obj_storage_client->abort_multipart_upload(opts);
+ ASSERT_EQ(abort_response.status.code, ErrorCode::OK);
+
+ auto head_response = AzureObjStorageClientTest::obj_storage_client->head_object(opts);
+ EXPECT_EQ(head_response.resp.status.code, ErrorCode::NOT_FOUND);
+}
+
+TEST_F(AzureObjStorageClientTest, abort_multipart_upload_preserves_existing_put_blob) {
+ io::ObjectStoragePathOptions opts;
+ auto create_response =
+ AzureObjStorageClientTest::obj_storage_client->create_multipart_upload(opts);
+ ASSERT_EQ(create_response.resp.status.code, ErrorCode::OK);
+ ASSERT_TRUE(create_response.upload_id.has_value());
+ opts.key = "AzureObjStorageClientTest/abort_preserves_put_blob_" + *create_response.upload_id;
+ opts.upload_id = create_response.upload_id;
+
+ auto put_response = AzureObjStorageClientTest::obj_storage_client->put_object(opts, "original");
+ ASSERT_EQ(put_response.status.code, ErrorCode::OK);
+ auto upload_response =
+ AzureObjStorageClientTest::obj_storage_client->upload_part(opts, "replacement", 1);
+ ASSERT_EQ(upload_response.resp.status.code, ErrorCode::OK);
+
+ auto abort_response =
+ AzureObjStorageClientTest::obj_storage_client->abort_multipart_upload(opts);
+ ASSERT_EQ(abort_response.status.code, ErrorCode::OK);
+ std::array contents {};
+ size_t size_return = 0;
+ auto get_response = AzureObjStorageClientTest::obj_storage_client->get_object(
+ opts, contents.data(), 0, contents.size(), &size_return);
+ ASSERT_EQ(get_response.status.code, ErrorCode::OK);
+ EXPECT_EQ(size_return, contents.size());
+ EXPECT_EQ(std::string_view(contents.data(), contents.size()), "original");
+
+ EXPECT_EQ(AzureObjStorageClientTest::obj_storage_client->delete_object(opts).status.code,
+ ErrorCode::OK);
+}
#else
class AzureObjStorageClientTest : public testing::Test {
diff --git a/be/test/io/fs/rate_limited_obj_storage_client_test.cpp b/be/test/io/fs/rate_limited_obj_storage_client_test.cpp
index 657b139c0a4fcd..6120347beb0f7c 100644
--- a/be/test/io/fs/rate_limited_obj_storage_client_test.cpp
+++ b/be/test/io/fs/rate_limited_obj_storage_client_test.cpp
@@ -62,6 +62,11 @@ class FakeObjStorageClient : public ObjStorageClient {
++calls;
return ObjectStorageResponse::OK();
}
+ ObjectStorageResponse abort_multipart_upload(const ObjectStoragePathOptions& opts) override {
+ ++calls;
+ ++abort_multipart_upload_calls;
+ return ObjectStorageResponse::OK();
+ }
ObjectStorageHeadResponse head_object(const ObjectStoragePathOptions& opts) override {
++calls;
return {};
@@ -106,6 +111,7 @@ class FakeObjStorageClient : public ObjStorageClient {
int create_multipart_upload_calls = 0;
int create_multipart_upload_provider_calls = 0;
int create_multipart_upload_provider_calls_per_logical_call = 1;
+ int abort_multipart_upload_calls = 0;
int delete_objects_recursively_calls = 0;
int delete_objects_recursively_provider_calls = 0;
int delete_objects_recursively_provider_calls_per_logical_call = 1;
@@ -368,6 +374,23 @@ TEST(RateLimitedObjStorageClientTest, multipart_control_apis_map_to_put_qps_with
EXPECT_EQ(-1, put_bytes->add(1));
}
+TEST(RateLimitedObjStorageClientTest, abortBypassesAnExhaustedPutLimit) {
+ RateLimiterConfigGuard guard;
+ config::enable_s3_rate_limiter = true;
+ auto& manager = S3RateLimiterManager::instance();
+ manager.qps_limiter(S3RateLimitType::PUT)
+ ->reset(kNoThrottleBytesPerSecond, kNoThrottleBytesPerSecond, 1);
+ manager.bytes_limiter(S3RateLimitType::PUT)->reset(0, 0, 0);
+
+ auto fake = std::make_shared();
+ RateLimitedObjStorageClient client(fake);
+ ObjectStoragePathOptions opts {.bucket = "b", .key = "k", .upload_id = "upload"};
+
+ EXPECT_EQ(0, client.create_multipart_upload(opts).resp.status.code);
+ EXPECT_EQ(0, client.abort_multipart_upload(opts).status.code);
+ EXPECT_EQ(1, fake->abort_multipart_upload_calls);
+}
+
TEST(RateLimitedObjStorageClientTest, delete_apis_map_to_put_qps_without_bytes) {
RateLimiterConfigGuard guard;
config::enable_s3_rate_limiter = true;
diff --git a/be/test/io/fs/s3_file_writer_test.cpp b/be/test/io/fs/s3_file_writer_test.cpp
index 3937d6e38561fe..b00f6c491c72d4 100644
--- a/be/test/io/fs/s3_file_writer_test.cpp
+++ b/be/test/io/fs/s3_file_writer_test.cpp
@@ -316,6 +316,21 @@ class S3FileWriterTest : public testing::Test {
}
};
+TEST_F(S3FileWriterTest, abort_cleans_up_multipart_upload) {
+ mock_client = std::make_shared();
+ doris::io::FileWriterOptions options;
+
+ io::FileWriterPtr writer;
+ ASSERT_TRUE(s3_fs->create_file("abort_multipart", &writer, &options).ok());
+ std::string data(config::s3_write_buffer_size, 'a');
+ ASSERT_TRUE(writer->append(Slice(data)).ok());
+ ASSERT_FALSE(static_cast(writer.get())->upload_id().empty());
+
+ ASSERT_TRUE(writer->abort().ok());
+ EXPECT_EQ(writer->state(), io::FileWriter::State::CLOSED);
+ EXPECT_TRUE(mock_client->contents().empty());
+}
+
TEST_F(S3FileWriterTest, multi_part_io_error) {
mock_client = std::make_shared();
doris::io::FileWriterOptions state;
@@ -1154,6 +1169,14 @@ class SimpleMockObjStorageClient : public io::ObjStorageClient {
return default_response;
}
+ ObjectStorageResponse abort_multipart_upload(const ObjectStoragePathOptions& opts) override {
+ std::lock_guard lock(_mutex);
+ abort_multipart_count++;
+ last_opts = opts;
+ parts.clear();
+ return default_response;
+ }
+
ObjectStorageHeadResponse head_object(const ObjectStoragePathOptions& opts) override {
std::lock_guard lock(_mutex);
return {.resp = ObjectStorageResponse::OK(),
@@ -1228,6 +1251,7 @@ class SimpleMockObjStorageClient : public io::ObjStorageClient {
int put_object_count = 0;
int upload_part_count = 0;
int complete_multipart_count = 0;
+ int abort_multipart_count = 0;
// Structures to store input parameters for each call
struct UploadPartParams {
@@ -1266,6 +1290,7 @@ class SimpleMockObjStorageClient : public io::ObjStorageClient {
put_object_count = 0;
upload_part_count = 0;
complete_multipart_count = 0;
+ abort_multipart_count = 0;
create_multipart_params.clear();
put_object_params.clear();
@@ -1307,6 +1332,19 @@ create_s3_client(const std::string& path) {
return {mock_client, s3_file_writer};
}
+TEST_F(S3FileWriterTest, abortsProviderMultipartWithoutAnUploadId) {
+ auto [client, writer] = create_s3_client("provider_without_upload_id");
+ client->default_upload_response.upload_id.reset();
+ std::string data(config::s3_write_buffer_size, 'a');
+
+ ASSERT_TRUE(writer->append(Slice(data)).ok());
+ ASSERT_TRUE(writer->abort().ok());
+
+ EXPECT_EQ(1, client->create_multipart_count);
+ EXPECT_EQ(1, client->abort_multipart_count);
+ EXPECT_EQ(FileWriter::State::CLOSED, writer->state());
+}
+
/**
* Generate test data for S3FileWriter boundary tests.
* Returns a vector of sizes that we'll use to generate data on demand.
diff --git a/be/test/runtime/runtime_state_block_budget_test.cpp b/be/test/runtime/runtime_state_block_budget_test.cpp
index 22ebc5ebf8a0ee..24afe920d197c5 100644
--- a/be/test/runtime/runtime_state_block_budget_test.cpp
+++ b/be/test/runtime/runtime_state_block_budget_test.cpp
@@ -24,6 +24,41 @@
namespace doris {
+TEST(RuntimeStateIcebergCommitDataTest, RejectsMetadataBeforeItCanExceedTheThriftLimit) {
+ RuntimeState state;
+ const int32_t saved_limit = config::thrift_max_message_size;
+ config::thrift_max_message_size = 128;
+ TIcebergCommitData commit_data;
+ commit_data.__set_file_path(std::string(256, 'x'));
+
+ Status status = state.add_iceberg_commit_datas(commit_data);
+
+ config::thrift_max_message_size = saved_limit;
+ EXPECT_FALSE(status.ok());
+ std::vector collected;
+ state.append_iceberg_commit_datas(&collected);
+ EXPECT_TRUE(collected.empty());
+}
+
+TEST(RuntimeStateIcebergCommitDataTest, SharesTheReportBudgetAcrossParallelTasks) {
+ RuntimeState first;
+ RuntimeState second;
+ auto budget = std::make_shared();
+ first.set_iceberg_commit_data_budget(budget);
+ second.set_iceberg_commit_data_budget(budget);
+ const int32_t saved_limit = config::thrift_max_message_size;
+ config::thrift_max_message_size = 1024 * 1024 + 512;
+ TIcebergCommitData commit_data;
+ commit_data.__set_file_path(std::string(300, 'x'));
+
+ Status first_status = first.add_iceberg_commit_datas(commit_data);
+ Status second_status = second.add_iceberg_commit_datas(commit_data);
+
+ config::thrift_max_message_size = saved_limit;
+ EXPECT_TRUE(first_status.ok()) << first_status;
+ EXPECT_FALSE(second_status.ok());
+}
+
// ---------------------------------------------------------------------------
// RuntimeState::batch_size()
// ---------------------------------------------------------------------------
diff --git a/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/IcebergConnectorMetadata.java b/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/IcebergConnectorMetadata.java
index 62e98dc2a0a048..ab87d325bf937e 100644
--- a/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/IcebergConnectorMetadata.java
+++ b/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/IcebergConnectorMetadata.java
@@ -2128,8 +2128,8 @@ private long parseTimestampMillis(ConnectorSession session, ConnectorTimeTravelS
* Threads a resolved MVCC / time-travel pin onto the handle BEFORE the scan reads it (the generic
* {@code PluginDrivenScanNode} calls this via {@code applyMvccSnapshotPin}). Reads the typed
* {@code snapshotId}/{@code schemaId} and the {@code iceberg.scan.ref} property; an empty-table / query-begin
- * latest pin ({@code snapshotId<0} and no ref) returns the handle UNCHANGED (read latest — a
- * {@code useSnapshot(-1)} would be a non-existent snapshot; mirrors paimon's {@code -1} guard).
+ * latest pin ({@code snapshotId<0} and no ref) remains distinguishable from no pin while scans still
+ * read latest (a {@code useSnapshot(-1)} would be a non-existent snapshot).
*/
@Override
public ConnectorTableHandle applySnapshot(ConnectorSession session,
@@ -2140,9 +2140,6 @@ public ConnectorTableHandle applySnapshot(ConnectorSession session,
}
String ref = snapshot.getProperties().get(REF_PROPERTY);
long snapshotId = snapshot.getSnapshotId();
- if (snapshotId < 0 && ref == null) {
- return iceHandle;
- }
return iceHandle.withSnapshot(snapshotId, ref, snapshot.getSchemaId());
}
diff --git a/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/IcebergConnectorTransaction.java b/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/IcebergConnectorTransaction.java
index 666e59a8741f8e..bd3b67d8052450 100644
--- a/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/IcebergConnectorTransaction.java
+++ b/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/IcebergConnectorTransaction.java
@@ -148,8 +148,8 @@ public class IcebergConnectorTransaction implements ConnectorTransaction, Rewrit
private boolean staticPartitionOverwrite;
private Map staticPartitionValues = Collections.emptyMap();
private String branchName;
- // The current snapshot pinned at begin time for a DELETE/MERGE (null for INSERT/OVERWRITE). Consumed by
- // the commit validation suite (validateFromSnapshot).
+ // The snapshot pinned at begin time for DELETE/MERGE and OVERWRITE (null for INSERT). Consumed by the
+ // commit validation suite (validateFromSnapshot).
private Long baseSnapshotId;
// Session zone for human-readable TIMESTAMP partition value parsing (DV-T04-f).
private ZoneId zone = ZoneOffset.UTC;
@@ -294,7 +294,6 @@ private void applyBeginGuards(IcebergWriteContext ctx, String tableName) {
}
} else {
// INSERT / OVERWRITE (append path).
- this.baseSnapshotId = null;
if (ctx.getBranchName().isPresent()) {
this.branchName = ctx.getBranchName().get();
SnapshotRef branchRef = table.refs().get(branchName);
@@ -304,10 +303,49 @@ private void applyBeginGuards(IcebergWriteContext ctx, String tableName) {
throw new IllegalArgumentException(branchName
+ " is a tag, not a branch. Tags cannot be targets for producing snapshots");
}
+ this.baseSnapshotId = op == WriteOperation.OVERWRITE
+ ? resolveOverwriteBaseSnapshot(ctx, branchRef.snapshotId(), tableName) : null;
} else {
this.branchName = null;
+ this.baseSnapshotId = op == WriteOperation.OVERWRITE
+ ? resolveOverwriteBaseSnapshot(ctx, getSnapshotIdIfPresent(table), tableName) : null;
+ }
+ }
+ }
+
+ private Long resolveOverwriteBaseSnapshot(IcebergWriteContext ctx, Long targetHead, String tableName) {
+ if (!ctx.isReadSnapshotPinned()) {
+ return targetHead;
+ }
+ long readSnapshotId = ctx.getReadSnapshotId();
+ if (readSnapshotId < 0) {
+ // An explicit empty read must conflict with any snapshot created before beginWrite.
+ if (targetHead != null) {
+ throw new DorisConnectorException("Iceberg table " + tableName
+ + " changed after the statement read an empty snapshot");
+ }
+ return null;
+ }
+ if (targetHead == null || !isAncestorOfTarget(readSnapshotId, targetHead)) {
+ throw new DorisConnectorException("Read snapshot " + readSnapshotId
+ + " is not an ancestor of the target branch for Iceberg table " + tableName);
+ }
+ return readSnapshotId;
+ }
+
+ private boolean isAncestorOfTarget(long ancestorId, long targetHeadId) {
+ Long snapshotId = targetHeadId;
+ while (snapshotId != null) {
+ if (snapshotId == ancestorId) {
+ return true;
+ }
+ Snapshot snapshot = table.snapshot(snapshotId);
+ if (snapshot == null) {
+ return false;
}
+ snapshotId = snapshot.parentId();
}
+ return false;
}
@Override
@@ -564,7 +602,13 @@ private void commitReplaceTxn(List pendingResults) {
if (branchName != null) {
overwriteFiles = overwriteFiles.toBranch(branchName);
}
- try (CloseableIterable fileScanTasks = table.newScan().planFiles()) {
+ // Clearing a table must fail if any data or delete landed after the statement's base snapshot.
+ overwriteFiles = validateOverwrite(overwriteFiles, Expressions.alwaysTrue());
+ TableScan overwriteScan = table.newScan();
+ if (branchName != null) {
+ overwriteScan = overwriteScan.useRef(branchName);
+ }
+ try (CloseableIterable fileScanTasks = overwriteScan.planFiles()) {
OverwriteFiles finalOverwriteFiles = overwriteFiles;
fileScanTasks.forEach(f -> finalOverwriteFiles.deleteFile(f.file()));
} catch (IOException e) {
@@ -579,6 +623,11 @@ private void commitReplaceTxn(List pendingResults) {
if (branchName != null) {
appendPartitionOp = appendPartitionOp.toBranch(branchName);
}
+ // Partition replacement must preserve concurrent files instead of deleting or reviving them silently.
+ if (baseSnapshotId != null) {
+ appendPartitionOp = appendPartitionOp.validateFromSnapshot(baseSnapshotId);
+ }
+ appendPartitionOp = appendPartitionOp.validateNoConflictingData().validateNoConflictingDeletes();
for (WriteResult result : pendingResults) {
Preconditions.checkState(result.referencedDataFiles().length == 0,
"Should have no referenced data files.");
@@ -603,6 +652,7 @@ private void commitStaticPartitionOverwrite(List pendingResults) {
overwriteFiles = overwriteFiles.toBranch(branchName);
}
overwriteFiles = overwriteFiles.overwriteByRowFilter(partitionFilter);
+ overwriteFiles = validateOverwrite(overwriteFiles, partitionFilter);
for (WriteResult result : pendingResults) {
Preconditions.checkState(result.referencedDataFiles().length == 0,
@@ -612,6 +662,14 @@ private void commitStaticPartitionOverwrite(List pendingResults) {
overwriteFiles.commit();
}
+ private OverwriteFiles validateOverwrite(OverwriteFiles overwriteFiles, Expression conflictFilter) {
+ overwriteFiles = overwriteFiles.conflictDetectionFilter(conflictFilter);
+ if (baseSnapshotId != null) {
+ overwriteFiles = overwriteFiles.validateFromSnapshot(baseSnapshotId);
+ }
+ return overwriteFiles.validateNoConflictingData().validateNoConflictingDeletes();
+ }
+
/**
* Build an iceberg {@link Expression} from the static partition key-value pairs. Identity partitions
* require the SOURCE column name (not the partition field name) in the expression.
diff --git a/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/IcebergProcedureOps.java b/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/IcebergProcedureOps.java
index a1daff65192e09..09501fc48a0a7e 100644
--- a/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/IcebergProcedureOps.java
+++ b/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/IcebergProcedureOps.java
@@ -43,8 +43,7 @@
import java.util.stream.Collectors;
/**
- * Executes iceberg's {@code ALTER TABLE EXECUTE} procedures (the 9 legacy
- * {@code datasource/iceberg/action/*} actions) behind the {@link ConnectorProcedureOps} SPI.
+ * Executes iceberg's {@code ALTER TABLE EXECUTE} procedures behind the {@link ConnectorProcedureOps} SPI.
*
* Mirrors {@link IcebergWritePlanProvider}: a fresh instance per call over the lazily-built live
* catalog, threading the same {@code properties} / {@link IcebergCatalogOps} / {@link ConnectorContext}
@@ -52,14 +51,11 @@
* runs in the connector; argument validation is connector-local (the engine cannot reach
* {@code org.apache.doris.common.NamedArguments} across the import gate).
*
- * T03 dispatch skeleton. {@link #getSupportedProcedures()} exports the factory's name list and
+ *
{@link #getSupportedProcedures()} exports the factory's name list and
* {@link #execute} routes through {@link IcebergExecuteActionFactory} → {@link BaseIcebergAction}: validate
* arguments, load the SDK table inside {@code context.executeAuthenticated}, run the body and wrap the
- * single row. The 9 procedure bodies (the factory's switch cases) are ported in T04 (the 8 pure-SDK
- * procedures) / T05–T06 ({@code rewrite_data_files}); until then a known name reaches the factory's faithful
- * "Unsupported Iceberg procedure" rejection. Inert pre-cutover regardless: iceberg tables are not
- * {@code PluginDrivenExternalTable} until P6.6, so {@code ExecuteActionCommand} still routes them to the
- * legacy fe-core actions and never reaches this class.
+ * single row. {@code rewrite_data_files} is planned as a distributed INSERT-SELECT operation and therefore
+ * bypasses the single-call action factory.
*/
public class IcebergProcedureOps implements ConnectorProcedureOps {
diff --git a/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/IcebergScanPlanProvider.java b/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/IcebergScanPlanProvider.java
index 42db23c93ac1c4..9d8b07a9d85b65 100644
--- a/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/IcebergScanPlanProvider.java
+++ b/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/IcebergScanPlanProvider.java
@@ -898,7 +898,7 @@ private List doPlanPositionDeletesSystemTableScan(IcebergTab
Table metadataTable, List columns, Optional filter,
ConnectorSession session) {
BatchScan scan = metadataTable.newBatchScan();
- if (handle.hasSnapshotPin()) {
+ if (handle.hasSnapshotSelection()) {
if (handle.getRef() != null) {
scan = scan.useRef(handle.getRef());
} else {
@@ -1120,6 +1120,9 @@ private TableScan buildScan(Table table, IcebergTableHandle handle, Optional
- * {@code snapshotId} ({@code -1} = none) — {@code FOR VERSION AS OF } / {@code FOR TIME AS OF}.
+ * {@code snapshotId} ({@code -1} = none or an explicitly empty-table pin) —
+ * {@code FOR VERSION AS OF } / {@code FOR TIME AS OF}.
* {@code ref} ({@code null} = none) — a tag/branch name; the scan pins by REF ({@code useRef}) so a
* later commit to the tag/branch is honored (legacy parity).
* {@code schemaId} ({@code -1} = latest) — the schema version AS OF the pin, so the field-id dictionary
@@ -53,7 +54,7 @@ public class IcebergTableHandle implements ConnectorTableHandle {
private static final long serialVersionUID = 1L;
- /** Sentinel for "no snapshot / latest schema" — mirrors legacy {@code IcebergUtils.UNKNOWN_SNAPSHOT_ID}. */
+ /** Numeric sentinel shared by no pin and an explicit empty pin; {@link #snapshotPinned} distinguishes them. */
private static final long NO_PIN = -1L;
private final String dbName;
@@ -61,6 +62,7 @@ public class IcebergTableHandle implements ConnectorTableHandle {
private final long snapshotId;
private final String ref;
private final long schemaId;
+ private final boolean snapshotPinned;
/**
* Bare system-table name (no {@code "$"}), lower-cased by the caller
@@ -97,16 +99,18 @@ public class IcebergTableHandle implements ConnectorTableHandle {
private final boolean topnLazyMaterialize;
public IcebergTableHandle(String dbName, String tableName) {
- this(dbName, tableName, NO_PIN, null, NO_PIN, null, null, false);
+ this(dbName, tableName, NO_PIN, null, NO_PIN, false, null, null, false);
}
private IcebergTableHandle(String dbName, String tableName, long snapshotId, String ref, long schemaId,
- String sysTableName, Set rewriteFileScope, boolean topnLazyMaterialize) {
+ boolean snapshotPinned, String sysTableName, Set rewriteFileScope,
+ boolean topnLazyMaterialize) {
this.dbName = dbName;
this.tableName = tableName;
this.snapshotId = snapshotId;
this.ref = ref;
this.schemaId = schemaId;
+ this.snapshotPinned = snapshotPinned;
this.sysTableName = sysTableName;
this.rewriteFileScope = rewriteFileScope;
this.topnLazyMaterialize = topnLazyMaterialize;
@@ -121,7 +125,8 @@ private IcebergTableHandle(String dbName, String tableName, long snapshotId, Str
*/
public static IcebergTableHandle forSystemTable(String dbName, String tableName, String sysName,
long snapshotId, String ref, long schemaId) {
- return new IcebergTableHandle(dbName, tableName, snapshotId, ref, schemaId, sysName, null, false);
+ return new IcebergTableHandle(dbName, tableName, snapshotId, ref, schemaId,
+ snapshotId >= 0 || ref != null, sysName, null, false);
}
public String getDbName() {
@@ -132,7 +137,7 @@ public String getTableName() {
return tableName;
}
- /** The pinned snapshot id, or {@code -1} when there is no snapshot-id pin. */
+ /** The pinned snapshot id, or {@code -1} when no snapshot exists or no snapshot-id pin is present. */
public long getSnapshotId() {
return snapshotId;
}
@@ -157,8 +162,13 @@ public boolean isSystemTable() {
return sysTableName != null;
}
- /** Whether this handle carries an explicit MVCC / time-travel pin (a snapshot id or a tag/branch ref). */
+ /** Whether this handle carries an explicit MVCC pin, including an empty-table query-begin pin. */
public boolean hasSnapshotPin() {
+ return snapshotPinned;
+ }
+
+ /** Whether the pin selects an Iceberg snapshot/ref rather than the explicit empty-table state. */
+ public boolean hasSnapshotSelection() {
return snapshotId >= 0 || ref != null;
}
@@ -183,7 +193,7 @@ public IcebergTableHandle withSnapshot(long snapshotId, String ref, long schemaI
// sysTableName, rewriteFileScope and topnLazyMaterialize are preserved: threading a resolved
// time-travel pin in must not degrade a sys handle (t$snapshots) into a normal data-table handle,
// drop a rewrite scope, or drop the lazy-materialization signal.
- return new IcebergTableHandle(dbName, tableName, snapshotId, ref, schemaId, sysTableName,
+ return new IcebergTableHandle(dbName, tableName, snapshotId, ref, schemaId, true, sysTableName,
rewriteFileScope, topnLazyMaterialize);
}
@@ -196,7 +206,7 @@ public IcebergTableHandle withSnapshot(long snapshotId, String ref, long schemaI
* The other carriers (snapshot/ref/schema/sys) are preserved.
*/
public IcebergTableHandle withRewriteFileScope(Set rawDataFilePaths) {
- return new IcebergTableHandle(dbName, tableName, snapshotId, ref, schemaId, sysTableName,
+ return new IcebergTableHandle(dbName, tableName, snapshotId, ref, schemaId, snapshotPinned, sysTableName,
ImmutableSet.copyOf(rawDataFilePaths), topnLazyMaterialize);
}
@@ -205,7 +215,7 @@ public IcebergTableHandle withRewriteFileScope(Set rawDataFilePaths) {
* {@link #topnLazyMaterialize}). The other carriers (snapshot/ref/schema/sys/rewriteScope) are preserved.
*/
public IcebergTableHandle withTopnLazyMaterialize(boolean topnLazyMaterialize) {
- return new IcebergTableHandle(dbName, tableName, snapshotId, ref, schemaId, sysTableName,
+ return new IcebergTableHandle(dbName, tableName, snapshotId, ref, schemaId, snapshotPinned, sysTableName,
rewriteFileScope, topnLazyMaterialize);
}
@@ -220,6 +230,7 @@ public boolean equals(Object o) {
IcebergTableHandle that = (IcebergTableHandle) o;
return snapshotId == that.snapshotId
&& schemaId == that.schemaId
+ && snapshotPinned == that.snapshotPinned
&& topnLazyMaterialize == that.topnLazyMaterialize
&& Objects.equals(dbName, that.dbName)
&& Objects.equals(tableName, that.tableName)
@@ -230,7 +241,8 @@ public boolean equals(Object o) {
@Override
public int hashCode() {
- return Objects.hash(dbName, tableName, snapshotId, ref, schemaId, sysTableName, rewriteFileScope,
+ return Objects.hash(dbName, tableName, snapshotId, ref, schemaId, snapshotPinned, sysTableName,
+ rewriteFileScope,
topnLazyMaterialize);
}
diff --git a/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/IcebergWriteContext.java b/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/IcebergWriteContext.java
index 18d1445817713e..05878f9a1b6560 100644
--- a/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/IcebergWriteContext.java
+++ b/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/IcebergWriteContext.java
@@ -42,20 +42,28 @@ final class IcebergWriteContext {
private final Map staticPartitionValues;
private final Optional branchName;
private final long readSnapshotId;
+ private final boolean readSnapshotPinned;
IcebergWriteContext(WriteOperation writeOperation, boolean overwrite,
Map staticPartitionValues, Optional branchName) {
- this(writeOperation, overwrite, staticPartitionValues, branchName, -1L);
+ this(writeOperation, overwrite, staticPartitionValues, branchName, -1L, false);
}
IcebergWriteContext(WriteOperation writeOperation, boolean overwrite,
Map staticPartitionValues, Optional branchName, long readSnapshotId) {
+ this(writeOperation, overwrite, staticPartitionValues, branchName, readSnapshotId, true);
+ }
+
+ IcebergWriteContext(WriteOperation writeOperation, boolean overwrite,
+ Map staticPartitionValues, Optional branchName, long readSnapshotId,
+ boolean readSnapshotPinned) {
this.writeOperation = writeOperation;
this.overwrite = overwrite;
this.staticPartitionValues = staticPartitionValues == null
? Collections.emptyMap() : new HashMap<>(staticPartitionValues);
this.branchName = branchName == null ? Optional.empty() : branchName;
this.readSnapshotId = readSnapshotId;
+ this.readSnapshotPinned = readSnapshotPinned;
}
WriteOperation getWriteOperation() {
@@ -81,7 +89,8 @@ Optional getBranchName() {
/**
* The statement's READ snapshot id (the MVCC pin the scan used, S_read), threaded from the write
- * handle in {@code planWrite}; {@code -1} = no pin (the legacy fresh-current behavior). The
+ * handle in {@code planWrite}; {@code -1} means either no pin or an explicitly empty read, as
+ * distinguished by {@link #isReadSnapshotPinned()}. The
* RowDelta path anchors {@code baseSnapshotId} at this snapshot so the commit-time removeDeletes
* (option D) and the scan-time deletes BE unions into the new DV share one snapshot — see
* {@link IcebergConnectorTransaction} [SHOULD-2] / Fix B.
@@ -89,4 +98,8 @@ Optional getBranchName() {
long getReadSnapshotId() {
return readSnapshotId;
}
+
+ boolean isReadSnapshotPinned() {
+ return readSnapshotPinned;
+ }
}
diff --git a/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/IcebergWritePlanProvider.java b/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/IcebergWritePlanProvider.java
index d9410c3dc93621..aa9b12a759910a 100644
--- a/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/IcebergWritePlanProvider.java
+++ b/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/IcebergWritePlanProvider.java
@@ -369,13 +369,15 @@ private IcebergWriteContext buildWriteContext(ConnectorWriteHandle handle) {
// Carry it on the op-context so beginWrite anchors the RowDelta baseSnapshotId at S_read, keeping
// the commit-time removeDeletes (option D) and BE's scan-time DV union on one snapshot. -1 (no pin)
// preserves the legacy begin-time current snapshot.
- long readSnapshotId = handle.getTableHandle() instanceof IcebergTableHandle
- ? ((IcebergTableHandle) handle.getTableHandle()).getSnapshotId() : -1L;
+ IcebergTableHandle icebergHandle = handle.getTableHandle() instanceof IcebergTableHandle
+ ? (IcebergTableHandle) handle.getTableHandle() : null;
+ long readSnapshotId = icebergHandle == null ? -1L : icebergHandle.getSnapshotId();
+ boolean readSnapshotPinned = icebergHandle != null && icebergHandle.hasSnapshotPin();
// Branch-targeted INSERT (INSERT INTO tbl@branch): the branch is threaded from the generic insert
// command context onto the write handle; beginWrite validates it against the table refs and points
// the commit at the branch. Empty for a default-ref write.
return new IcebergWriteContext(op, handle.isOverwrite(), handle.getStaticPartitionSpec(),
- handle.getBranchName(), readSnapshotId);
+ handle.getBranchName(), readSnapshotId, readSnapshotPinned);
}
private TIcebergTableSink buildSink(Table table, IcebergTableHandle tableHandle,
diff --git a/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/action/IcebergExecuteActionFactory.java b/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/action/IcebergExecuteActionFactory.java
index 6a901feb349a74..52e531ab338b25 100644
--- a/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/action/IcebergExecuteActionFactory.java
+++ b/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/action/IcebergExecuteActionFactory.java
@@ -36,7 +36,7 @@
* {@code Optional} / nereids {@code Expression}.
*
* T03 scaffolding. The {@code createAction} switch carries only the faithful default rejection;
- * the 9 procedure cases (their bodies) are ported in T04 ({@code rewrite_data_files} in T05/T06). The
+ * the procedure cases (their bodies) are ported in T04 ({@code rewrite_data_files} in T05/T06). The
* {@link #getSupportedActions()} registry — exported to {@code getSupportedProcedures()} and embedded in
* the rejection message — is complete and final.
*/
@@ -52,6 +52,7 @@ public class IcebergExecuteActionFactory {
public static final String REWRITE_DATA_FILES = "rewrite_data_files";
public static final String PUBLISH_CHANGES = "publish_changes";
public static final String REWRITE_MANIFESTS = "rewrite_manifests";
+ public static final String REMOVE_ORPHAN_FILES = "remove_orphan_files";
/**
* Create an iceberg procedure body for {@code actionType}.
@@ -83,6 +84,8 @@ public static BaseIcebergAction createAction(String actionType, Map properties, List partitionNames,
+ ConnectorPredicate whereCondition) {
+ super("remove_orphan_files", properties, partitionNames, whereCondition);
+ }
+
+ @Override
+ protected void registerIcebergArguments() {
+ namedArguments.registerRequiredArgument(OLDER_THAN, "Creation time cutoff in milliseconds",
+ ArgumentParsers.nonNegativeLong(OLDER_THAN));
+ namedArguments.registerOptionalArgument(LOCATION, "Prefix within the table location",
+ null, ArgumentParsers.nonEmptyString(LOCATION));
+ namedArguments.registerOptionalArgument(DRY_RUN, "Only count orphan files", true,
+ ArgumentParsers.booleanValue(DRY_RUN));
+ }
+
+ @Override
+ protected void validateIcebergAction() {
+ validateNoPartitions();
+ validateNoWhereCondition();
+ String location = namedArguments.getString(LOCATION);
+ if (location != null) {
+ try {
+ normalizeLocation(location);
+ } catch (IllegalArgumentException e) {
+ throw new DorisConnectorException("Invalid location URI: " + location, e);
+ }
+ }
+ }
+
+ @Override
+ protected List executeAction(Table table, ConnectorSession session) {
+ if (!(table.io() instanceof SupportsPrefixOperations)) {
+ throw new DorisConnectorException("remove_orphan_files requires FileIO prefix listing support");
+ }
+ if (!PropertyUtil.propertyAsBoolean(table.properties(), TableProperties.GC_ENABLED,
+ TableProperties.GC_ENABLED_DEFAULT)) {
+ // A GC-disabled table may share files with another table, so no destructive scan is safe.
+ throw new DorisConnectorException("Cannot remove orphan files: Iceberg GC is disabled");
+ }
+ List scanLocations = resolveScanLocations(table);
+
+ try {
+ ReachableIndex reachable = new ReachableIndex(collectReachableFiles(table));
+ long orphanCount = 0;
+ long deletedCount = 0;
+ long olderThan = namedArguments.getLong(OLDER_THAN);
+ // The SQL procedure needs a retention fence because concurrent uploads are not reachable until commit.
+ if (olderThan > System.currentTimeMillis() - MIN_RETENTION_MS) {
+ throw new DorisConnectorException(
+ "older_than must retain at least 24 hours of files");
+ }
+ boolean dryRun = namedArguments.getBoolean(DRY_RUN);
+ Set visitedFiles = new HashSet<>();
+ for (String scanLocation : scanLocations) {
+ // Object stores use raw prefix matching, so the separator excludes sibling prefixes.
+ String listingPrefix = scanLocation.endsWith("/") ? scanLocation : scanLocation + "/";
+ for (FileInfo file : ((SupportsPrefixOperations) table.io()).listPrefix(listingPrefix)) {
+ if (visitedFiles.add(file.location()) && file.createdAtMillis() < olderThan
+ && !isReachable(file.location(), reachable)) {
+ orphanCount++;
+ if (!dryRun) {
+ table.io().deleteFile(file.location());
+ deletedCount++;
+ }
+ }
+ }
+ }
+ return Lists.newArrayList(String.valueOf(orphanCount), String.valueOf(deletedCount));
+ } catch (Exception e) {
+ throw new DorisConnectorException("Failed to remove orphan files: " + e.getMessage(), e);
+ }
+ }
+
+ private List resolveScanLocations(Table table) {
+ String tableRoot = normalizeLocation(table.location());
+ String dataRoot = normalizeLocation(resolveDataLocation(table, tableRoot));
+ Set ownedRoots = new LinkedHashSet<>();
+ ownedRoots.add(tableRoot);
+ ownedRoots.add(dataRoot);
+
+ String requested = namedArguments.getString(LOCATION);
+ if (requested != null) {
+ String normalized = normalizeLocation(requested);
+ boolean owned = ownedRoots.stream().anyMatch(root -> isWithin(normalized, root));
+ if (!owned) {
+ throw new DorisConnectorException(
+ "location must be within an Iceberg table-owned metadata or data location");
+ }
+ return Lists.newArrayList(normalized);
+ }
+
+ List roots = new ArrayList<>();
+ for (String candidate : ownedRoots) {
+ // Avoid listing a nested default data directory twice when the table root already covers it.
+ if (ownedRoots.stream().noneMatch(other -> !other.equals(candidate) && isWithin(candidate, other))) {
+ roots.add(candidate);
+ }
+ }
+ return roots;
+ }
+
+ private String resolveDataLocation(Table table, String tableRoot) {
+ Map properties = table.properties();
+ String dataLocation = nonEmpty(properties.get(TableProperties.WRITE_DATA_LOCATION));
+ if (dataLocation == null && Boolean.parseBoolean(properties.get(TableProperties.OBJECT_STORE_ENABLED))) {
+ dataLocation = nonEmpty(properties.get(TableProperties.OBJECT_STORE_PATH));
+ }
+ if (dataLocation == null) {
+ dataLocation = nonEmpty(properties.get(TableProperties.WRITE_FOLDER_STORAGE_LOCATION));
+ }
+ return dataLocation == null ? tableRoot + "/data" : dataLocation;
+ }
+
+ private String nonEmpty(String location) {
+ return location == null || location.isEmpty() ? null : location;
+ }
+
+ private boolean isWithin(String location, String root) {
+ FileIdentity child = FileIdentity.of(location);
+ FileIdentity parent = FileIdentity.of(root);
+ String pathPrefix = parent.path.endsWith("/") ? parent.path : parent.path + "/";
+ return child.scheme.equals(parent.scheme) && child.authority.equals(parent.authority)
+ && (child.path.equals(parent.path) || child.path.startsWith(pathPrefix));
+ }
+
+ private Set collectReachableFiles(Table table) throws IOException {
+ Set reachable = new HashSet<>(ReachableFileUtil.metadataFileLocations(table, true));
+ // Hadoop tables consult this live pointer even though it is not part of the metadata log.
+ reachable.add(ReachableFileUtil.versionHintLocation(table));
+ Set scannedDataManifests = new HashSet<>();
+ Set scannedDeleteManifests = new HashSet<>();
+ reachable.addAll(ReachableFileUtil.manifestListLocations(table));
+ reachable.addAll(ReachableFileUtil.statisticsFilesLocations(table));
+ for (Snapshot snapshot : table.snapshots()) {
+ for (ManifestFile manifest : snapshot.allManifests(table.io())) {
+ reachable.add(manifest.path());
+ if (manifest.content() == ManifestContent.DATA) {
+ // Snapshots inherit manifests, so read each path once to keep work linear.
+ if (scannedDataManifests.add(manifest.path())) {
+ try (ManifestReader dataFiles =
+ ManifestFiles.read(manifest, table.io(), table.specs())) {
+ dataFiles.forEach(dataFile -> reachable.add(dataFile.location()));
+ }
+ }
+ } else if (scannedDeleteManifests.add(manifest.path())) {
+ // A retained delete file may not apply to any current data task, so read it directly.
+ try (ManifestReader deletes =
+ ManifestFiles.readDeleteManifest(manifest, table.io(), table.specs())) {
+ deletes.forEach(delete -> reachable.add(delete.location()));
+ }
+ }
+ }
+ }
+ return reachable;
+ }
+
+ private static boolean isReachable(String candidate, ReachableIndex reachable) {
+ FileIdentity candidateIdentity = FileIdentity.of(candidate);
+ if (reachable.identities.contains(candidateIdentity)) {
+ return true;
+ }
+ if (reachable.paths.contains(candidateIdentity.path)) {
+ // A path collision across unknown providers/authorities cannot be classified safely.
+ throw new DorisConnectorException(
+ "Cannot determine whether listed and reachable file locations are equivalent");
+ }
+ return false;
+ }
+
+ static boolean sameFileIdentity(String first, String second) {
+ return FileIdentity.of(first).equals(FileIdentity.of(second));
+ }
+
+ static void verifyNoPrefixMismatch(String candidate, Set reachable) {
+ FileIdentity candidateIdentity = FileIdentity.of(candidate);
+ for (String retained : reachable) {
+ FileIdentity retainedIdentity = FileIdentity.of(retained);
+ // Matching paths with different providers/authorities are ambiguous; deletion must fail closed.
+ if (candidateIdentity.path.equals(retainedIdentity.path)
+ && !candidateIdentity.equals(retainedIdentity)) {
+ throw new DorisConnectorException(
+ "Cannot determine whether listed and reachable file locations are equivalent");
+ }
+ }
+ }
+
+ private static final class FileIdentity {
+ private final String scheme;
+ private final String authority;
+ private final String path;
+
+ private FileIdentity(String scheme, String authority, String path) {
+ this.scheme = scheme;
+ this.authority = authority;
+ this.path = path;
+ }
+
+ private static FileIdentity of(String location) {
+ URI uri = URI.create(location).normalize();
+ String scheme = uri.getScheme();
+ scheme = scheme == null ? "" : scheme.toLowerCase(Locale.ROOT);
+ if (scheme.equals("s3a") || scheme.equals("s3n")) {
+ scheme = "s3";
+ }
+ String authority = uri.getAuthority();
+ authority = authority == null ? "" : authority.toLowerCase(Locale.ROOT);
+ String path = uri.getPath();
+ return new FileIdentity(scheme, authority, path == null ? "" : path);
+ }
+
+ @Override
+ public boolean equals(Object other) {
+ if (this == other) {
+ return true;
+ }
+ if (!(other instanceof FileIdentity)) {
+ return false;
+ }
+ FileIdentity that = (FileIdentity) other;
+ return scheme.equals(that.scheme) && authority.equals(that.authority)
+ && path.equals(that.path);
+ }
+
+ @Override
+ public int hashCode() {
+ return Objects.hash(scheme, authority, path);
+ }
+ }
+
+ private static final class ReachableIndex {
+ private final Set identities = new HashSet<>();
+ private final Set paths = new HashSet<>();
+
+ private ReachableIndex(Set locations) {
+ for (String location : locations) {
+ FileIdentity identity = FileIdentity.of(location);
+ identities.add(identity);
+ paths.add(identity.path);
+ }
+ }
+ }
+
+ private String normalizeLocation(String location) {
+ String normalized = URI.create(location).normalize().toString();
+ return normalized.length() > 1 && normalized.endsWith("/")
+ ? normalized.substring(0, normalized.length() - 1) : normalized;
+ }
+
+ @Override
+ protected List getResultSchema() {
+ return Lists.newArrayList(
+ new ConnectorColumn("orphan_file_count", ConnectorType.of("BIGINT"),
+ "Number of old unreachable files", false, null),
+ new ConnectorColumn("deleted_file_count", ConnectorType.of("BIGINT"),
+ "Number of files deleted", false, null));
+ }
+}
diff --git a/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/IcebergConnectorMetadataMvccTest.java b/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/IcebergConnectorMetadataMvccTest.java
index 35748356013e52..283c434905f596 100644
--- a/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/IcebergConnectorMetadataMvccTest.java
+++ b/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/IcebergConnectorMetadataMvccTest.java
@@ -335,16 +335,17 @@ public void applySnapshotThreadsRef() {
}
@Test
- public void applySnapshotLatestPinLeavesHandleUnchanged() {
+ public void applySnapshotPreservesExplicitEmptyPinWhileScanningLatest() {
Fixture f = fixture();
IcebergConnectorMetadata md = metadataFor(f.table, new RecordingIcebergCatalogOps());
ConnectorTableHandle bare = handle();
- // null snapshot and an empty-table (-1, no ref) pin must both read latest (handle unchanged) — a
- // useSnapshot(-1) would be a non-existent snapshot.
+ // Null means no pin, but an empty-table pin must remain observable to the write OCC path even though
+ // the scan still reads latest (useSnapshot(-1) would be a non-existent snapshot).
Assertions.assertSame(bare, md.applySnapshot(null, bare, null));
IcebergTableHandle afterMinusOne = (IcebergTableHandle) md.applySnapshot(null, bare,
ConnectorMvccSnapshot.builder().snapshotId(-1L).build());
- Assertions.assertFalse(afterMinusOne.hasSnapshotPin());
+ Assertions.assertTrue(afterMinusOne.hasSnapshotPin());
+ Assertions.assertEquals(-1L, afterMinusOne.getSnapshotId());
}
// ---------------------------------------------------------------------
diff --git a/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/IcebergConnectorTransactionTest.java b/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/IcebergConnectorTransactionTest.java
index 0ed5a711ae065a..73ad91ee82b916 100644
--- a/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/IcebergConnectorTransactionTest.java
+++ b/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/IcebergConnectorTransactionTest.java
@@ -126,6 +126,11 @@ private static IcebergWriteContext overwriteCtx() {
return new IcebergWriteContext(WriteOperation.OVERWRITE, true, Collections.emptyMap(), Optional.empty());
}
+ private static IcebergWriteContext overwriteCtxPinned(long readSnapshotId) {
+ return new IcebergWriteContext(WriteOperation.OVERWRITE, true, Collections.emptyMap(), Optional.empty(),
+ readSnapshotId);
+ }
+
private static IcebergWriteContext overwriteStaticCtx(Map staticValues) {
return new IcebergWriteContext(WriteOperation.OVERWRITE, true, staticValues, Optional.empty());
}
@@ -577,6 +582,106 @@ public void overwriteDynamicReplacesPartitions() {
Assertions.assertEquals("1", snap.summary().get("added-data-files"));
}
+ @Test
+ public void overwriteDynamicRejectsConcurrentDataInReplacedPartition() {
+ InMemoryCatalog catalog = freshCatalog();
+ TableIdentifier id = TableIdentifier.of("db1", "t1");
+ PartitionSpec spec = PartitionSpec.builderFor(PART_SCHEMA).identity("region").build();
+ Table table = catalog.createTable(id, PART_SCHEMA, spec, props("write.format.default", "parquet"));
+ table.newAppend().appendFile(partitionedDataFile(spec,
+ "s3://b/db1/t1/region=us/seed.parquet", 1L, "region=us")).commit();
+
+ IcebergConnectorTransaction txn = txnFor(opsReturning(catalog.loadTable(id)), new RecordingConnectorContext());
+ txn.beginWrite(SESSION, "db1", "t1", overwriteCtx());
+ Table concurrent = catalog.loadTable(id);
+ concurrent.newAppend().appendFile(partitionedDataFile(spec,
+ "s3://b/db1/t1/region=us/concurrent.parquet", 1L, "region=us")).commit();
+ txn.addCommitData(commitBytes(
+ dataFileItem("s3://b/db1/t1/region=us/replacement.parquet", 2L, 1024L,
+ Collections.singletonList("us"))));
+
+ Assertions.assertThrows(DorisConnectorException.class, txn::commit,
+ "dynamic overwrite must not silently replace data committed after its base snapshot");
+ }
+
+ @Test
+ public void overwriteDynamicRejectsDataCommittedBetweenScanAndBegin() {
+ InMemoryCatalog catalog = freshCatalog();
+ TableIdentifier id = TableIdentifier.of("db1", "t1");
+ PartitionSpec spec = PartitionSpec.builderFor(PART_SCHEMA).identity("region").build();
+ Table table = catalog.createTable(id, PART_SCHEMA, spec, props("write.format.default", "parquet"));
+ table.newAppend().appendFile(partitionedDataFile(spec,
+ "s3://b/db1/t1/region=us/seed.parquet", 1L, "region=us")).commit();
+ long readSnapshotId = table.currentSnapshot().snapshotId();
+ table.newAppend().appendFile(partitionedDataFile(spec,
+ "s3://b/db1/t1/region=us/between-scan-and-begin.parquet", 1L, "region=us")).commit();
+
+ IcebergConnectorTransaction txn = txnFor(
+ opsReturning(catalog.loadTable(id)), new RecordingConnectorContext());
+ txn.beginWrite(SESSION, "db1", "t1", overwriteCtxPinned(readSnapshotId));
+ txn.addCommitData(commitBytes(
+ dataFileItem("s3://b/db1/t1/region=us/replacement.parquet", 2L, 1024L,
+ Collections.singletonList("us"))));
+
+ Assertions.assertThrows(DorisConnectorException.class, txn::commit);
+ }
+
+ @Test
+ public void overwriteRejectsFirstSnapshotCommittedAfterEmptyRead() {
+ InMemoryCatalog catalog = freshCatalog();
+ TableIdentifier id = TableIdentifier.of("db1", "t1");
+ Table table = catalog.createTable(id, SCHEMA, PartitionSpec.unpartitioned(),
+ props("write.format.default", "parquet"));
+ table.newAppend().appendFile(dataFile(table.spec(),
+ "s3://b/db1/t1/between-scan-and-begin.parquet", 1L)).commit();
+ IcebergConnectorTransaction txn = txnFor(
+ opsReturning(catalog.loadTable(id)), new RecordingConnectorContext());
+
+ Assertions.assertThrows(DorisConnectorException.class,
+ () -> txn.beginWrite(SESSION, "db1", "t1", overwriteCtxPinned(-1L)));
+ }
+
+ @Test
+ public void overwriteRejectsFirstSnapshotCommittedAfterBeginFromEmptyRead() {
+ InMemoryCatalog catalog = freshCatalog();
+ TableIdentifier id = TableIdentifier.of("db1", "t1");
+ Table table = catalog.createTable(id, SCHEMA, PartitionSpec.unpartitioned(),
+ props("write.format.default", "parquet"));
+ IcebergConnectorTransaction txn = txnFor(
+ opsReturning(catalog.loadTable(id)), new RecordingConnectorContext());
+ txn.beginWrite(SESSION, "db1", "t1", overwriteCtxPinned(-1L));
+
+ table.newAppend().appendFile(dataFile(table.spec(),
+ "s3://b/db1/t1/after-begin.parquet", 1L)).commit();
+
+ Assertions.assertThrows(DorisConnectorException.class, txn::commit);
+ }
+
+ @Test
+ public void overwriteBranchUsesTheSnapshotReadFromThatBranch() {
+ InMemoryCatalog catalog = freshCatalog();
+ TableIdentifier id = TableIdentifier.of("db1", "t1");
+ PartitionSpec spec = PartitionSpec.builderFor(PART_SCHEMA).identity("region").build();
+ Table table = catalog.createTable(id, PART_SCHEMA, spec, props("write.format.default", "parquet"));
+ table.newAppend().appendFile(partitionedDataFile(spec,
+ "s3://b/db1/t1/region=us/seed.parquet", 1L, "region=us")).commit();
+ long readSnapshotId = table.currentSnapshot().snapshotId();
+ table.manageSnapshots().createBranch("b1", readSnapshotId).commit();
+ table.newAppend().toBranch("b1").appendFile(partitionedDataFile(spec,
+ "s3://b/db1/t1/region=us/between-scan-and-begin.parquet", 1L, "region=us")).commit();
+
+ IcebergConnectorTransaction txn = txnFor(
+ opsReturning(catalog.loadTable(id)), new RecordingConnectorContext());
+ txn.beginWrite(SESSION, "db1", "t1",
+ new IcebergWriteContext(WriteOperation.OVERWRITE, true, Collections.emptyMap(),
+ Optional.of("b1"), readSnapshotId));
+ txn.addCommitData(commitBytes(
+ dataFileItem("s3://b/db1/t1/region=us/replacement.parquet", 2L, 1024L,
+ Collections.singletonList("us"))));
+
+ Assertions.assertThrows(DorisConnectorException.class, txn::commit);
+ }
+
@Test
public void overwriteEmptyUnpartitionedClearsTable() {
InMemoryCatalog catalog = freshCatalog();
@@ -597,6 +702,24 @@ public void overwriteEmptyUnpartitionedClearsTable() {
"the existing data file must be removed (table cleared)");
}
+ @Test
+ public void overwriteEmptyUnpartitionedRejectsDataCommittedBetweenScanAndBegin() {
+ InMemoryCatalog catalog = freshCatalog();
+ TableIdentifier id = TableIdentifier.of("db1", "t1");
+ Table table = catalog.createTable(id, SCHEMA, PartitionSpec.unpartitioned(),
+ props("write.format.default", "parquet"));
+ table.newAppend().appendFile(dataFile(table.spec(), "s3://b/db1/t1/seed.parquet", 1L)).commit();
+ long readSnapshotId = table.currentSnapshot().snapshotId();
+ table.newAppend().appendFile(dataFile(table.spec(),
+ "s3://b/db1/t1/between-scan-and-begin.parquet", 1L)).commit();
+
+ IcebergConnectorTransaction txn = txnFor(
+ opsReturning(catalog.loadTable(id)), new RecordingConnectorContext());
+ txn.beginWrite(SESSION, "db1", "t1", overwriteCtxPinned(readSnapshotId));
+
+ Assertions.assertThrows(DorisConnectorException.class, txn::commit);
+ }
+
@Test
public void overwriteStaticPartitionUsesRowFilter() {
InMemoryCatalog catalog = freshCatalog();
@@ -616,6 +739,52 @@ public void overwriteStaticPartitionUsesRowFilter() {
Assertions.assertEquals("1", snap.summary().get("added-data-files"));
}
+ @Test
+ public void overwriteStaticRejectsConcurrentDataInTargetPartition() {
+ InMemoryCatalog catalog = freshCatalog();
+ TableIdentifier id = TableIdentifier.of("db1", "t1");
+ PartitionSpec spec = PartitionSpec.builderFor(PART_SCHEMA).identity("region").build();
+ Table table = catalog.createTable(id, PART_SCHEMA, spec, props("write.format.default", "parquet"));
+ table.newAppend().appendFile(partitionedDataFile(spec,
+ "s3://b/db1/t1/region=us/seed.parquet", 1L, "region=us")).commit();
+
+ IcebergConnectorTransaction txn = txnFor(opsReturning(catalog.loadTable(id)), new RecordingConnectorContext());
+ txn.beginWrite(SESSION, "db1", "t1", overwriteStaticCtx(Collections.singletonMap("region", "us")));
+ Table concurrent = catalog.loadTable(id);
+ concurrent.newAppend().appendFile(partitionedDataFile(spec,
+ "s3://b/db1/t1/region=us/concurrent.parquet", 1L, "region=us")).commit();
+ txn.addCommitData(commitBytes(
+ dataFileItem("s3://b/db1/t1/region=us/replacement.parquet", 2L, 1024L,
+ Collections.singletonList("us"))));
+
+ Assertions.assertThrows(DorisConnectorException.class, txn::commit,
+ "static overwrite must reject concurrent data matching its target partition filter");
+ }
+
+ @Test
+ public void overwriteStaticRejectsDataCommittedBetweenScanAndBegin() {
+ InMemoryCatalog catalog = freshCatalog();
+ TableIdentifier id = TableIdentifier.of("db1", "t1");
+ PartitionSpec spec = PartitionSpec.builderFor(PART_SCHEMA).identity("region").build();
+ Table table = catalog.createTable(id, PART_SCHEMA, spec, props("write.format.default", "parquet"));
+ table.newAppend().appendFile(partitionedDataFile(spec,
+ "s3://b/db1/t1/region=us/seed.parquet", 1L, "region=us")).commit();
+ long readSnapshotId = table.currentSnapshot().snapshotId();
+ table.newAppend().appendFile(partitionedDataFile(spec,
+ "s3://b/db1/t1/region=us/between-scan-and-begin.parquet", 1L, "region=us")).commit();
+
+ IcebergConnectorTransaction txn = txnFor(
+ opsReturning(catalog.loadTable(id)), new RecordingConnectorContext());
+ txn.beginWrite(SESSION, "db1", "t1",
+ new IcebergWriteContext(WriteOperation.OVERWRITE, true,
+ Collections.singletonMap("region", "us"), Optional.empty(), readSnapshotId));
+ txn.addCommitData(commitBytes(
+ dataFileItem("s3://b/db1/t1/region=us/replacement.parquet", 2L, 1024L,
+ Collections.singletonList("us"))));
+
+ Assertions.assertThrows(DorisConnectorException.class, txn::commit);
+ }
+
@Test
public void deleteWritesRowDeltaDeleteFiles() {
InMemoryCatalog catalog = freshCatalog();
diff --git a/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/IcebergProcedureOpsTest.java b/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/IcebergProcedureOpsTest.java
index d2fa6cf9221b77..32958429302064 100644
--- a/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/IcebergProcedureOpsTest.java
+++ b/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/IcebergProcedureOpsTest.java
@@ -86,7 +86,8 @@ public void getSupportedProceduresExportsFactoryNamesInLegacyOrder() {
"expire_snapshots",
"rewrite_data_files",
"publish_changes",
- "rewrite_manifests"),
+ "rewrite_manifests",
+ "remove_orphan_files"),
newOps().getSupportedProcedures());
}
@@ -120,7 +121,8 @@ public void executeRejectsUnknownProcedureWithLegacyMessage() {
Assertions.assertEquals(
"Unsupported Iceberg procedure: no_such_proc. Supported procedures: rollback_to_snapshot, "
+ "rollback_to_timestamp, set_current_snapshot, cherrypick_snapshot, fast_forward, "
- + "expire_snapshots, rewrite_data_files, publish_changes, rewrite_manifests",
+ + "expire_snapshots, rewrite_data_files, publish_changes, rewrite_manifests, "
+ + "remove_orphan_files",
e.getMessage());
}
diff --git a/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/IcebergTableHandleTest.java b/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/IcebergTableHandleTest.java
index f5e928afaaa50c..f01683a8831258 100644
--- a/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/IcebergTableHandleTest.java
+++ b/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/IcebergTableHandleTest.java
@@ -57,6 +57,16 @@ public void withSnapshotPinsByIdAndCarriesSchemaId() {
Assertions.assertEquals("t1", pinned.getTableName());
}
+ @Test
+ public void explicitEmptySnapshotIsDistinctFromNoPin() {
+ IcebergTableHandle bare = new IcebergTableHandle("db1", "t1");
+ IcebergTableHandle empty = bare.withSnapshot(-1L, null, -1L);
+
+ Assertions.assertTrue(empty.hasSnapshotPin());
+ Assertions.assertFalse(empty.hasSnapshotSelection());
+ Assertions.assertNotEquals(bare, empty);
+ }
+
@Test
public void withSnapshotPinsByRef() {
IcebergTableHandle pinned = new IcebergTableHandle("db1", "t1").withSnapshot(7L, "b1", 2L);
diff --git a/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/action/IcebergExecuteActionFactoryTest.java b/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/action/IcebergExecuteActionFactoryTest.java
index 1e0da5abff5955..d5fdf67695e187 100644
--- a/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/action/IcebergExecuteActionFactoryTest.java
+++ b/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/action/IcebergExecuteActionFactoryTest.java
@@ -19,24 +19,23 @@
import org.apache.doris.connector.api.DorisConnectorException;
+import com.google.common.collect.ImmutableMap;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Test;
import java.util.Collections;
/**
- * Pins the connector port of legacy {@code IcebergExecuteActionFactory} (the name registry + dispatch).
+ * Pins the Iceberg procedure name registry and dispatch.
*
* WHY this matters: the supported-name list is exported to {@code getSupportedProcedures()} and
- * embedded in the unknown-procedure error, so its membership and order must match legacy byte-for-byte
- * (T08 byte-parity). The {@code table} parameter is dropped (it was always dead in legacy). The 9 switch
- * cases are added in T04 (the procedure bodies); T03 fixes the registry + the faithful unknown-procedure
- * rejection.
+ * embedded in the unknown-procedure error, so membership, ordering, and executable action mappings must stay
+ * synchronized.
*/
public class IcebergExecuteActionFactoryTest {
@Test
- public void getSupportedActionsReturnsNineNamesInLegacyOrder() {
+ public void getSupportedActionsIncludesOrphanCleanup() {
Assertions.assertArrayEquals(
new String[] {
"rollback_to_snapshot",
@@ -48,6 +47,7 @@ public void getSupportedActionsReturnsNineNamesInLegacyOrder() {
"rewrite_data_files",
"publish_changes",
"rewrite_manifests",
+ "remove_orphan_files",
},
IcebergExecuteActionFactory.getSupportedActions());
}
@@ -60,15 +60,32 @@ public void createActionRejectsUnknownProcedureWithLegacyMessage() {
Assertions.assertEquals(
"Unsupported Iceberg procedure: no_such_proc. Supported procedures: rollback_to_snapshot, "
+ "rollback_to_timestamp, set_current_snapshot, cherrypick_snapshot, fast_forward, "
- + "expire_snapshots, rewrite_data_files, publish_changes, rewrite_manifests",
+ + "expire_snapshots, rewrite_data_files, publish_changes, rewrite_manifests, "
+ + "remove_orphan_files",
e.getMessage());
}
+ @Test
+ public void createRemoveOrphanFilesAction() {
+ BaseIcebergAction action = IcebergExecuteActionFactory.createAction(
+ "remove_orphan_files", Collections.singletonMap("older_than", "1"),
+ Collections.emptyList(), null);
+ Assertions.assertInstanceOf(IcebergRemoveOrphanFilesAction.class, action);
+ }
+
+ @Test
+ public void removeOrphanFilesRejectsInvalidLocationUri() {
+ BaseIcebergAction action = IcebergExecuteActionFactory.createAction(
+ "remove_orphan_files", ImmutableMap.of("older_than", "1", "location", "://"),
+ Collections.emptyList(), null);
+ Assertions.assertThrows(DorisConnectorException.class, action::validate);
+ }
+
/**
* CANARY for the dormant {@code rewrite_data_files} gap: it is advertised in {@link
- * IcebergExecuteActionFactory#getSupportedActions()} (9 names) but has NO {@code createAction} switch
- * case yet (8 cases), so it falls through to the faithful unknown-procedure rejection. This pins that
- * dormant state and goes RED exactly when the T05/T06 body is wired in.
+ * IcebergExecuteActionFactory#getSupportedActions()} but has NO {@code createAction} switch
+ * case because it is dispatched through the distributed rewrite planner, so it falls through to the
+ * unknown-procedure rejection in this single-call factory.
*/
@Test
public void rewriteDataFilesIsAdvertisedButNotYetExecutable() {
diff --git a/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/action/IcebergRemoveOrphanFilesActionTest.java b/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/action/IcebergRemoveOrphanFilesActionTest.java
new file mode 100644
index 00000000000000..05d3dc1ae4e048
--- /dev/null
+++ b/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/action/IcebergRemoveOrphanFilesActionTest.java
@@ -0,0 +1,308 @@
+// 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.connector.iceberg.action;
+
+import org.apache.doris.connector.api.DorisConnectorException;
+import org.apache.doris.connector.api.procedure.ConnectorProcedureResult;
+
+import org.apache.hadoop.conf.Configuration;
+import org.apache.iceberg.BaseTable;
+import org.apache.iceberg.DataFile;
+import org.apache.iceberg.DataFiles;
+import org.apache.iceberg.HasTableOperations;
+import org.apache.iceberg.PartitionSpec;
+import org.apache.iceberg.ReachableFileUtil;
+import org.apache.iceberg.StaticTableOperations;
+import org.apache.iceberg.Table;
+import org.apache.iceberg.TableProperties;
+import org.apache.iceberg.hadoop.HadoopTables;
+import org.apache.iceberg.io.FileIO;
+import org.apache.iceberg.io.FileInfo;
+import org.apache.iceberg.io.InputFile;
+import org.apache.iceberg.io.OutputFile;
+import org.apache.iceberg.io.SupportsPrefixOperations;
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.attribute.FileTime;
+import java.time.Duration;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.Map;
+import java.util.Set;
+
+public class IcebergRemoveOrphanFilesActionTest {
+ private static final long MIN_RETENTION_MS = Duration.ofHours(24).toMillis();
+
+ @Test
+ public void gcDisabledPreventsDeletion(@TempDir Path temp) throws Exception {
+ Table table = createTable(temp.resolve("table"),
+ Collections.singletonMap(TableProperties.GC_ENABLED, "false"));
+ Path orphan = createOldFile(temp.resolve("table/data/orphan.parquet"));
+ IcebergRemoveOrphanFilesAction action = action(System.currentTimeMillis() - MIN_RETENTION_MS, false);
+ action.validate();
+
+ Assertions.assertThrows(DorisConnectorException.class,
+ () -> action.execute(table, ActionTestTables.session("UTC")));
+ Assertions.assertTrue(Files.exists(orphan));
+ }
+
+ @Test
+ public void recentCutoffCannotRaceAnUncommittedWriter(@TempDir Path temp) throws Exception {
+ Table table = createTable(temp.resolve("table"), Collections.emptyMap());
+ Path uncommitted = createOldFile(temp.resolve("table/data/uncommitted.parquet"));
+ IcebergRemoveOrphanFilesAction action = action(System.currentTimeMillis(), false);
+ action.validate();
+
+ Assertions.assertThrows(DorisConnectorException.class,
+ () -> action.execute(table, ActionTestTables.session("UTC")));
+ Assertions.assertTrue(Files.exists(uncommitted));
+ }
+
+ @Test
+ public void keepsVersionHintWhileDeletingAnOldOrphan(@TempDir Path temp) throws Exception {
+ Table table = createTable(temp.resolve("table"), Collections.emptyMap());
+ Path orphan = createOldFile(temp.resolve("table/data/orphan.parquet"));
+ Path versionHint = Path.of(java.net.URI.create(ReachableFileUtil.versionHintLocation(table)));
+ Files.setLastModifiedTime(versionHint, FileTime.fromMillis(1));
+ IcebergRemoveOrphanFilesAction action = action(System.currentTimeMillis() - MIN_RETENTION_MS, false);
+ action.validate();
+
+ ConnectorProcedureResult result = action.execute(table, ActionTestTables.session("UTC"));
+
+ Assertions.assertEquals("1", result.getRows().get(0).get(0));
+ Assertions.assertEquals("1", result.getRows().get(0).get(1));
+ Assertions.assertFalse(Files.exists(orphan));
+ Assertions.assertTrue(Files.exists(versionHint));
+ }
+
+ @Test
+ public void treatsS3SchemeAliasesAsTheSameFile() {
+ Assertions.assertTrue(IcebergRemoveOrphanFilesAction.sameFileIdentity(
+ "s3://bucket/path/data.parquet", "s3a://bucket/path/data.parquet"));
+ Assertions.assertTrue(IcebergRemoveOrphanFilesAction.sameFileIdentity(
+ "s3n://bucket/path/data.parquet", "s3://BUCKET/path/data.parquet"));
+ }
+
+ @Test
+ public void rejectsUnresolvedPrefixMismatches() {
+ Assertions.assertThrows(DorisConnectorException.class,
+ () -> IcebergRemoveOrphanFilesAction.verifyNoPrefixMismatch(
+ "s3://first/path/data.parquet",
+ Collections.singleton("s3://second/path/data.parquet")));
+ }
+
+ @Test
+ public void readsEachSharedDataManifestOnlyOnce(@TempDir Path temp) throws Exception {
+ Map properties = new HashMap<>();
+ properties.put(TableProperties.MANIFEST_MERGE_ENABLED, "false");
+ Table table = createTable(temp.resolve("table"), properties);
+ appendDataFile(table, createOldFile(temp.resolve("table/data/first.parquet")));
+ appendDataFile(table, createOldFile(temp.resolve("table/data/second.parquet")));
+
+ Set dataManifestPaths = new HashSet<>();
+ int[] manifestReferenceCount = {0};
+ table.snapshots().forEach(snapshot -> snapshot.dataManifests(table.io())
+ .forEach(manifest -> {
+ manifestReferenceCount[0]++;
+ dataManifestPaths.add(manifest.path());
+ }));
+ Assertions.assertTrue(manifestReferenceCount[0] > dataManifestPaths.size());
+ RecordingFileIO recordingFileIO = new RecordingFileIO(table.io(), dataManifestPaths);
+ Table recordingTable = new BaseTable(
+ new StaticTableOperations(((HasTableOperations) table).operations().current(), recordingFileIO),
+ table.name());
+ IcebergRemoveOrphanFilesAction action = action(
+ System.currentTimeMillis() - MIN_RETENTION_MS, true);
+ action.validate();
+
+ action.execute(recordingTable, ActionTestTables.session("UTC"));
+
+ Assertions.assertEquals(dataManifestPaths.size(), recordingFileIO.manifestOpenCount());
+ dataManifestPaths.forEach(path -> Assertions.assertEquals(1,
+ recordingFileIO.openCounts.getOrDefault(path, 0), path));
+ }
+
+ @Test
+ public void scansConfiguredDataRootOutsideTableLocationByDefault(@TempDir Path temp) throws Exception {
+ Path dataRoot = temp.resolve("owned-data");
+ Table table = createTable(temp.resolve("metadata"),
+ Collections.singletonMap(TableProperties.WRITE_DATA_LOCATION,
+ dataRoot.toUri().toString()));
+ Path orphan = createOldFile(dataRoot.resolve("orphan.parquet"));
+ IcebergRemoveOrphanFilesAction action = action(
+ System.currentTimeMillis() - MIN_RETENTION_MS, false);
+ action.validate();
+
+ ConnectorProcedureResult result = action.execute(table, ActionTestTables.session("UTC"));
+
+ Assertions.assertEquals("1", result.getRows().get(0).get(0));
+ Assertions.assertEquals("1", result.getRows().get(0).get(1));
+ Assertions.assertFalse(Files.exists(orphan));
+ }
+
+ @Test
+ public void allowsExplicitConfiguredDataRootButRejectsArbitraryRoot(@TempDir Path temp) throws Exception {
+ Path dataRoot = temp.resolve("owned-data");
+ Table table = createTable(temp.resolve("metadata"),
+ Collections.singletonMap(TableProperties.WRITE_DATA_LOCATION,
+ dataRoot.toUri().toString()));
+ Path orphan = createOldFile(dataRoot.resolve("orphan.parquet"));
+
+ IcebergRemoveOrphanFilesAction configured = action(
+ System.currentTimeMillis() - MIN_RETENTION_MS, false, dataRoot.toUri().toString());
+ configured.validate();
+ configured.execute(table, ActionTestTables.session("UTC"));
+ Assertions.assertFalse(Files.exists(orphan));
+
+ IcebergRemoveOrphanFilesAction arbitrary = action(
+ System.currentTimeMillis() - MIN_RETENTION_MS, false,
+ temp.resolve("unowned").toUri().toString());
+ arbitrary.validate();
+ Assertions.assertThrows(DorisConnectorException.class,
+ () -> arbitrary.execute(table, ActionTestTables.session("UTC")));
+ }
+
+ @Test
+ public void scansObjectStoreAndFolderStorageFallbackRoots(@TempDir Path temp) throws Exception {
+ Path objectRoot = temp.resolve("object-data");
+ Map objectProperties = new HashMap<>();
+ objectProperties.put(TableProperties.OBJECT_STORE_ENABLED, "true");
+ objectProperties.put(TableProperties.OBJECT_STORE_PATH, objectRoot.toUri().toString());
+ Table objectTable = createTable(temp.resolve("object-metadata"), objectProperties);
+ Path objectOrphan = createOldFile(objectRoot.resolve("orphan.parquet"));
+
+ Path folderRoot = temp.resolve("folder-data");
+ Table folderTable = createTable(temp.resolve("folder-metadata"),
+ Collections.singletonMap(TableProperties.WRITE_FOLDER_STORAGE_LOCATION,
+ folderRoot.toUri().toString()));
+ Path folderOrphan = createOldFile(folderRoot.resolve("orphan.parquet"));
+
+ IcebergRemoveOrphanFilesAction objectAction = action(
+ System.currentTimeMillis() - MIN_RETENTION_MS, false);
+ objectAction.validate();
+ objectAction.execute(objectTable, ActionTestTables.session("UTC"));
+ IcebergRemoveOrphanFilesAction folderAction = action(
+ System.currentTimeMillis() - MIN_RETENTION_MS, false);
+ folderAction.validate();
+ folderAction.execute(folderTable, ActionTestTables.session("UTC"));
+
+ Assertions.assertFalse(Files.exists(objectOrphan));
+ Assertions.assertFalse(Files.exists(folderOrphan));
+ }
+
+ private static IcebergRemoveOrphanFilesAction action(long olderThan, boolean dryRun) {
+ return action(olderThan, dryRun, null);
+ }
+
+ private static IcebergRemoveOrphanFilesAction action(long olderThan, boolean dryRun, String location) {
+ Map properties = new HashMap<>();
+ properties.put(IcebergRemoveOrphanFilesAction.OLDER_THAN, String.valueOf(olderThan));
+ properties.put(IcebergRemoveOrphanFilesAction.DRY_RUN, String.valueOf(dryRun));
+ if (location != null) {
+ properties.put(IcebergRemoveOrphanFilesAction.LOCATION, location);
+ }
+ return new IcebergRemoveOrphanFilesAction(properties, Collections.emptyList(), null);
+ }
+
+ private static Table createTable(Path location, Map properties) {
+ HadoopTables tables = new HadoopTables(new Configuration());
+ return tables.create(ActionTestTables.SCHEMA, PartitionSpec.unpartitioned(), properties,
+ location.toUri().toString());
+ }
+
+ private static Path createOldFile(Path path) throws Exception {
+ Files.createDirectories(path.getParent());
+ Files.write(path, new byte[] {1});
+ Files.setLastModifiedTime(path, FileTime.fromMillis(1));
+ return path;
+ }
+
+ private static void appendDataFile(Table table, Path path) {
+ DataFile dataFile = DataFiles.builder(table.spec())
+ .withPath(path.toUri().toString())
+ .withFileSizeInBytes(1)
+ .withRecordCount(1)
+ .build();
+ table.newFastAppend().appendFile(dataFile).commit();
+ }
+
+ private static final class RecordingFileIO implements SupportsPrefixOperations {
+ private final FileIO delegate;
+ private final SupportsPrefixOperations prefixDelegate;
+ private final Set manifestPaths;
+ private final Map openCounts = new HashMap<>();
+
+ private RecordingFileIO(FileIO delegate, Set manifestPaths) {
+ this.delegate = delegate;
+ this.prefixDelegate = (SupportsPrefixOperations) delegate;
+ this.manifestPaths = manifestPaths;
+ }
+
+ private void record(String path) {
+ if (manifestPaths.contains(path)) {
+ openCounts.merge(path, 1, Integer::sum);
+ }
+ }
+
+ private int manifestOpenCount() {
+ return openCounts.values().stream().mapToInt(Integer::intValue).sum();
+ }
+
+ @Override
+ public InputFile newInputFile(String path) {
+ record(path);
+ return delegate.newInputFile(path);
+ }
+
+ @Override
+ public InputFile newInputFile(String path, long length) {
+ record(path);
+ return delegate.newInputFile(path, length);
+ }
+
+ @Override
+ public OutputFile newOutputFile(String path) {
+ return delegate.newOutputFile(path);
+ }
+
+ @Override
+ public void deleteFile(String path) {
+ delegate.deleteFile(path);
+ }
+
+ @Override
+ public Map properties() {
+ return delegate.properties();
+ }
+
+ @Override
+ public Iterable listPrefix(String prefix) {
+ return prefixDelegate.listPrefix(prefix);
+ }
+
+ @Override
+ public void deletePrefix(String prefix) {
+ prefixDelegate.deletePrefix(prefix);
+ }
+ }
+}
diff --git a/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/commands/insert/AbstractInsertExecutor.java b/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/commands/insert/AbstractInsertExecutor.java
index bdcb6e29448e1e..381b38e9a8bd52 100644
--- a/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/commands/insert/AbstractInsertExecutor.java
+++ b/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/commands/insert/AbstractInsertExecutor.java
@@ -140,6 +140,10 @@ public void unregisterListener(InsertExecutorListener listener) {
listeners.remove(listener);
}
+ protected void handleAfterCompleteFailure(Exception e) throws Exception {
+ throw e;
+ }
+
public Coordinator getCoordinator() {
return coordinator;
}
@@ -259,8 +263,9 @@ private void checkStrictModeAndFilterRatio() throws Exception {
* execute insert txn for insert into select command.
*/
public void executeSingleInsert(StmtExecutor executor) throws Exception {
- beforeExec();
try {
+ // Pre-execution work may register external resources, so it must share the transaction cleanup scope.
+ beforeExec();
executor.updateProfile(false);
execImpl(executor);
checkStrictModeAndFilterRatio();
@@ -269,7 +274,11 @@ public void executeSingleInsert(StmtExecutor executor) throws Exception {
}
onComplete();
for (InsertExecutorListener listener : listeners) {
- listener.afterComplete(this, executor, jobId);
+ try {
+ listener.afterComplete(this, executor, jobId);
+ } catch (Exception e) {
+ handleAfterCompleteFailure(e);
+ }
}
} catch (Throwable t) {
onFail(t);
diff --git a/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/commands/insert/BaseExternalTableInsertExecutor.java b/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/commands/insert/BaseExternalTableInsertExecutor.java
index 797c865b7dbf8c..217c524a499f06 100644
--- a/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/commands/insert/BaseExternalTableInsertExecutor.java
+++ b/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/commands/insert/BaseExternalTableInsertExecutor.java
@@ -126,8 +126,13 @@ protected void onComplete() throws UserException {
txnStatus = TransactionStatus.COMMITTED;
long t2 = System.currentTimeMillis();
- // Handle post-commit operations (e.g., cache refresh)
- doAfterCommit();
+ try {
+ doAfterCommit();
+ } catch (Exception e) {
+ // Cache refresh cannot undo a durable remote commit, so it must not make clients retry the write.
+ LOG.warn("Post-commit refresh failed for table {}. Data was committed successfully.",
+ table.getName(), e);
+ }
long t3 = System.currentTimeMillis();
LOG.info("Transaction commit breakdown: doBeforeCommit={}ms, commit={}ms, doAfterCommit={}ms, total={}ms",
t1 - t0, t2 - t1, t3 - t2, t3 - t0);
@@ -149,6 +154,16 @@ protected void doAfterCommit() throws DdlException {
true);
}
+ @Override
+ protected void handleAfterCompleteFailure(Exception e) throws Exception {
+ if (txnStatus != TransactionStatus.COMMITTED) {
+ super.handleAfterCompleteFailure(e);
+ return;
+ }
+ // A post-commit listener cannot undo remote data, so failing the statement would invite duplicate retries.
+ LOG.warn("Post-commit listener failed for table {}. Data was committed successfully.", table.getName(), e);
+ }
+
@Override
protected void finalizeSink(PlanFragment fragment, DataSink sink, PhysicalSink physicalSink) {
try {
diff --git a/fe/fe-core/src/test/java/org/apache/doris/nereids/trees/plans/commands/insert/OlapInsertExecutorTest.java b/fe/fe-core/src/test/java/org/apache/doris/nereids/trees/plans/commands/insert/OlapInsertExecutorTest.java
index 6c8537c5d62bba..96fb43ffbae48e 100644
--- a/fe/fe-core/src/test/java/org/apache/doris/nereids/trees/plans/commands/insert/OlapInsertExecutorTest.java
+++ b/fe/fe-core/src/test/java/org/apache/doris/nereids/trees/plans/commands/insert/OlapInsertExecutorTest.java
@@ -181,6 +181,33 @@ void testOnFailAbortsUncommittedTransaction() throws Exception {
}
}
+ @Test
+ void testBeforeExecFailureUsesTheNormalAbortAndCleanupPath() throws Exception {
+ ConnectContext ctx = createExecutorContext();
+ Coordinator coordinator = createCoordinator();
+ GlobalTransactionMgrIface txnMgr = Mockito.mock(GlobalTransactionMgrIface.class);
+ TransactionState txnState = Mockito.mock(TransactionState.class);
+ LoadManager loadManager = Mockito.mock(LoadManager.class);
+ Env currentEnv = createCurrentEnv(loadManager);
+ StmtExecutor stmtExecutor = createStmtExecutor();
+
+ try (MockedStatic envFactoryMock = Mockito.mockStatic(EnvFactory.class);
+ MockedStatic envMock = Mockito.mockStatic(Env.class)) {
+ prepareFactoryMocks(envFactoryMock, envMock, coordinator, txnMgr, txnState, currentEnv);
+ ctx.setEnv(currentEnv);
+
+ OlapInsertExecutor executor = createExecutorWithBeforeExecFailure(ctx);
+ executor.txnId = 10004L;
+
+ Assertions.assertDoesNotThrow(() -> executor.executeSingleInsert(stmtExecutor));
+ Assertions.assertEquals(MysqlStateType.ERR, ctx.getState().getStateType());
+ Assertions.assertTrue(ctx.getState().getErrorMessage().contains("beforeExec failure"));
+ Mockito.verify(txnMgr).abortTransaction(1L, 10004L, "beforeExec failure");
+ Mockito.verify(coordinator).close();
+ Mockito.verify(stmtExecutor).updateProfile(true);
+ }
+ }
+
// Build a fresh context per case so insertResult and QueryState do not leak between tests.
private ConnectContext createExecutorContext() {
ConnectContext ctx = new ConnectContext();
@@ -256,6 +283,25 @@ private OlapInsertExecutor createExecutor(ConnectContext ctx) {
Optional.empty(), false, 0L);
}
+ private OlapInsertExecutor createExecutorWithBeforeExecFailure(ConnectContext ctx) {
+ Database database = Mockito.mock(Database.class);
+ Mockito.when(database.getFullName()).thenReturn("test_db");
+ Mockito.when(database.getId()).thenReturn(1L);
+
+ OlapTable table = Mockito.mock(OlapTable.class);
+ Mockito.when(table.getDatabase()).thenReturn(database);
+ Mockito.when(table.getName()).thenReturn("test_tbl");
+ Mockito.when(table.getId()).thenReturn(2L);
+
+ return new OlapInsertExecutor(ctx, table, "label_test", Mockito.mock(NereidsPlanner.class),
+ Optional.empty(), false, 0L) {
+ @Override
+ protected void beforeExec() {
+ throw new RuntimeException("beforeExec failure");
+ }
+ };
+ }
+
// Redirect coordinator creation and transaction access to mocks so the test stays deterministic.
private void prepareFactoryMocks(MockedStatic envFactoryMock, MockedStatic envMock,
Coordinator coordinator, GlobalTransactionMgrIface txnMgr, TransactionState txnState, Env currentEnv) {
diff --git a/fe/fe-core/src/test/java/org/apache/doris/nereids/trees/plans/commands/insert/PluginDrivenInsertExecutorTest.java b/fe/fe-core/src/test/java/org/apache/doris/nereids/trees/plans/commands/insert/PluginDrivenInsertExecutorTest.java
index 9cc5f9561a35f6..f05ea67823fb89 100644
--- a/fe/fe-core/src/test/java/org/apache/doris/nereids/trees/plans/commands/insert/PluginDrivenInsertExecutorTest.java
+++ b/fe/fe-core/src/test/java/org/apache/doris/nereids/trees/plans/commands/insert/PluginDrivenInsertExecutorTest.java
@@ -33,6 +33,7 @@
import org.apache.doris.planner.PluginDrivenTableSink;
import org.apache.doris.thrift.TDataSink;
import org.apache.doris.transaction.PluginDrivenTransactionManager;
+import org.apache.doris.transaction.TransactionStatus;
import org.apache.doris.transaction.TransactionType;
import org.junit.jupiter.api.Assertions;
@@ -215,6 +216,17 @@ public void doBeforeCommitKeepsCoordinatorRowCountWhenTransactionReportsNoCount(
"a -1 (no count) transaction must leave the coordinator-counted loadedRows untouched");
}
+ @Test
+ public void postCommitListenerFailureDoesNotTurnACommittedWriteIntoAnError() {
+ PluginDrivenInsertExecutor exec = newUnconstructedExecutor();
+ Deencapsulation.setField(exec, "txnStatus", TransactionStatus.COMMITTED);
+ Deencapsulation.setField(exec, "table", Mockito.mock(PluginDrivenExternalTable.class));
+
+ Assertions.assertDoesNotThrow(() -> Deencapsulation.invoke(exec,
+ "handleAfterCompleteFailure", new RuntimeException("listener failure")),
+ "a listener cannot roll back or fail a connector write after its remote commit is durable");
+ }
+
/**
* Creates a {@link PluginDrivenInsertExecutor} without running its constructor. See the class
* javadoc: the constructor builds a Coordinator that needs a live planner/EnvFactory.
diff --git a/fe/fe-filesystem/fe-filesystem-azure/src/main/java/org/apache/doris/filesystem/azure/AzureObjStorage.java b/fe/fe-filesystem/fe-filesystem-azure/src/main/java/org/apache/doris/filesystem/azure/AzureObjStorage.java
index e97a8284136b05..197b6c64a7851e 100644
--- a/fe/fe-filesystem/fe-filesystem-azure/src/main/java/org/apache/doris/filesystem/azure/AzureObjStorage.java
+++ b/fe/fe-filesystem/fe-filesystem-azure/src/main/java/org/apache/doris/filesystem/azure/AzureObjStorage.java
@@ -250,7 +250,9 @@ public void completeMultipartUpload(String remotePath, String uploadId,
List sorted = new ArrayList<>(parts);
sorted.sort((a, b) -> Integer.compare(a.partNumber(), b.partNumber()));
for (UploadPartResult part : sorted) {
- blockIds.add(toBlockId(part.partNumber()));
+ // New BEs carry their exact UUID-prefixed ID; the fallback completes uploads from older BEs.
+ blockIds.add(part.etag() == null || part.etag().isEmpty()
+ ? toBlockId(part.partNumber()) : part.etag());
}
blockBlobClient.commitBlockList(blockIds);
} catch (BlobStorageException e) {
diff --git a/fe/fe-filesystem/fe-filesystem-azure/src/test/java/org/apache/doris/filesystem/azure/AzureObjStorageExtensionTest.java b/fe/fe-filesystem/fe-filesystem-azure/src/test/java/org/apache/doris/filesystem/azure/AzureObjStorageExtensionTest.java
index eca48e0c4bf867..b6a0bc6f973abb 100644
--- a/fe/fe-filesystem/fe-filesystem-azure/src/test/java/org/apache/doris/filesystem/azure/AzureObjStorageExtensionTest.java
+++ b/fe/fe-filesystem/fe-filesystem-azure/src/test/java/org/apache/doris/filesystem/azure/AzureObjStorageExtensionTest.java
@@ -17,6 +17,7 @@
package org.apache.doris.filesystem.azure;
+import org.apache.doris.filesystem.UploadPartResult;
import org.apache.doris.filesystem.spi.RemoteObjects;
import com.azure.storage.blob.BlobClient;
@@ -378,6 +379,48 @@ void deleteObjectsByKeys_attachesPerKeyExceptionsAsSuppressed() throws Exception
// F20 — abortMultipartUpload safe-noop / commit-empty behaviour
// ------------------------------------------------------------------
+ @Test
+ void completeMultipartUpload_usesExactBlockIdsReportedByBe() throws Exception {
+ com.azure.storage.blob.specialized.BlockBlobClient blockClient =
+ Mockito.mock(com.azure.storage.blob.specialized.BlockBlobClient.class);
+ BlobClient blobClient = Mockito.mock(BlobClient.class);
+ Mockito.when(blobClient.getBlockBlobClient()).thenReturn(blockClient);
+ BlobContainerClient containerClient = Mockito.mock(BlobContainerClient.class);
+ Mockito.when(containerClient.getBlobClient("stage/blob")).thenReturn(blobClient);
+ BlobServiceClient serviceClient = Mockito.mock(BlobServiceClient.class);
+ Mockito.when(serviceClient.getBlobContainerClient("mycontainer")).thenReturn(containerClient);
+ TestableAzureObjStorage storage = new TestableAzureObjStorage(buildBasicProps(), serviceClient);
+
+ String firstBlockId = "YmUtZ2VuZXJhdGVkLXVwbG9hZC1pZDowMDAwMDAwMDAx";
+ String secondBlockId = "YmUtZ2VuZXJhdGVkLXVwbG9hZC1pZDowMDAwMDAwMDAy";
+ storage.completeMultipartUpload(
+ "wasb://mycontainer@myaccount.blob.core.windows.net/stage/blob",
+ "be-generated-upload-id",
+ Arrays.asList(new UploadPartResult(2, secondBlockId),
+ new UploadPartResult(1, firstBlockId)));
+
+ Mockito.verify(blockClient).commitBlockList(Arrays.asList(firstBlockId, secondBlockId));
+ }
+
+ @Test
+ void completeMultipartUpload_fallsBackForOlderBeWithoutBlockIds() throws Exception {
+ com.azure.storage.blob.specialized.BlockBlobClient blockClient =
+ Mockito.mock(com.azure.storage.blob.specialized.BlockBlobClient.class);
+ BlobClient blobClient = Mockito.mock(BlobClient.class);
+ Mockito.when(blobClient.getBlockBlobClient()).thenReturn(blockClient);
+ BlobContainerClient containerClient = Mockito.mock(BlobContainerClient.class);
+ Mockito.when(containerClient.getBlobClient("stage/blob")).thenReturn(blobClient);
+ BlobServiceClient serviceClient = Mockito.mock(BlobServiceClient.class);
+ Mockito.when(serviceClient.getBlobContainerClient("mycontainer")).thenReturn(containerClient);
+ TestableAzureObjStorage storage = new TestableAzureObjStorage(buildBasicProps(), serviceClient);
+
+ storage.completeMultipartUpload(
+ "wasb://mycontainer@myaccount.blob.core.windows.net/stage/blob",
+ "legacy-upload-id", Collections.singletonList(new UploadPartResult(1, "")));
+
+ Mockito.verify(blockClient).commitBlockList(Collections.singletonList("AQAAAA=="));
+ }
+
@Test
void abortMultipartUpload_safeNoopWhenCommittedBlobExists() throws Exception {
com.azure.storage.blob.models.BlobProperties props =