From 58395849ccec6f2b1e575939ec16e593610222c6 Mon Sep 17 00:00:00 2001
From: Gabriel
Date: Sat, 1 Aug 2026 16:15:23 +0800
Subject: [PATCH 01/29] [fix](iceberg) Harden write lifecycle and cleanup
Problem Summary:
Iceberg writes could bypass cleanup, miss overwrite conflicts, undercount
partition sorter memory, hide close errors, exceed report limits, or leave
incomplete remote files. Partition dispatch also allocated full-block filters
for every partition.
Solution:
Unify failure handling, preserve the durable commit point, validate overwrite
conflicts, account for all active sort writers, propagate close failures, bound
commit metadata, abort incomplete uploads, add orphan cleanup, and use compact
row permutations.
---
.../spill_iceberg_table_sink_operator.cpp | 53 +++---
.../pipeline/pipeline_fragment_context.cpp | 10 +-
be/src/exec/sink/viceberg_delete_sink.cpp | 8 +-
.../iceberg/viceberg_partition_writer.cpp | 33 +++-
.../writer/iceberg/viceberg_sort_writer.cpp | 91 +++-------
.../writer/iceberg/viceberg_table_writer.cpp | 66 +++----
.../writer/iceberg/viceberg_table_writer.h | 20 +--
be/src/io/fs/file_writer.h | 3 +
be/src/io/fs/obj_storage_client.h | 4 +
.../io/fs/rate_limited_obj_storage_client.cpp | 9 +
.../io/fs/rate_limited_obj_storage_client.h | 1 +
be/src/io/fs/s3_file_writer.cpp | 43 ++++-
be/src/io/fs/s3_file_writer.h | 3 +-
be/src/io/fs/s3_obj_storage_client.cpp | 19 ++
be/src/io/fs/s3_obj_storage_client.h | 1 +
be/src/runtime/runtime_state.cpp | 24 +++
be/src/runtime/runtime_state.h | 10 +-
.../iceberg/iceberg_partition_writer_test.cpp | 37 +++-
.../iceberg/iceberg_table_writer_test.cpp | 100 +++++++++++
be/test/io/fs/s3_file_writer_test.cpp | 15 ++
.../runtime_state_block_budget_test.cpp | 16 ++
.../iceberg/IcebergConnectorTransaction.java | 23 ++-
.../iceberg/IcebergProcedureOps.java | 12 +-
.../action/IcebergExecuteActionFactory.java | 8 +-
.../IcebergRemoveOrphanFilesAction.java | 163 ++++++++++++++++++
.../IcebergConnectorTransactionTest.java | 45 +++++
.../IcebergExecuteActionFactoryTest.java | 37 ++--
.../insert/AbstractInsertExecutor.java | 13 +-
.../BaseExternalTableInsertExecutor.java | 19 +-
.../insert/OlapInsertExecutorTest.java | 46 +++++
.../PluginDrivenInsertExecutorTest.java | 12 ++
31 files changed, 752 insertions(+), 192 deletions(-)
create mode 100644 be/test/exec/sink/writer/iceberg/iceberg_table_writer_test.cpp
create mode 100644 fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/action/IcebergRemoveOrphanFilesAction.java
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..083d7b86c87c0f 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,26 @@ 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;
+ size_t reserve_size = 0;
+ for (const auto& writer : *_writer->active_writers()) {
+ if (auto* sort_writer = dynamic_cast(writer.get())) {
+ reserve_size += sort_writer->get_reserve_mem_size(state, eos);
+ }
}
-
- return sort_writer->get_reserve_mem_size(state, eos);
+ return reserve_size;
}
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;
+ for (const auto& writer : *_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 +82,23 @@ 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;
+ for (const auto& writer : *_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/pipeline/pipeline_fragment_context.cpp b/be/src/exec/pipeline/pipeline_fragment_context.cpp
index 84840e98f1f799..4f2c6dc00483e6 100644
--- a/be/src/exec/pipeline/pipeline_fragment_context.cpp
+++ b/be/src/exec/pipeline/pipeline_fragment_context.cpp
@@ -2515,16 +2515,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..168199f1d00849 100644
--- a/be/src/exec/sink/writer/iceberg/viceberg_sort_writer.cpp
+++ b/be/src/exec/sink/writer/iceberg/viceberg_sort_writer.cpp
@@ -102,80 +102,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_table_writer.cpp b/be/src/exec/sink/writer/iceberg/viceberg_table_writer.cpp
index 3519252d7ca940..52157c5a80ab27 100644
--- a/be/src/exec/sink/writer/iceberg/viceberg_table_writer.cpp
+++ b/be/src/exec/sink/writer/iceberg/viceberg_table_writer.cpp
@@ -49,6 +49,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) {
@@ -329,7 +330,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
@@ -346,6 +348,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());
@@ -355,6 +358,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);
@@ -363,6 +367,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;
}
@@ -371,7 +376,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();
}
@@ -389,6 +393,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());
@@ -398,6 +403,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);
@@ -406,6 +412,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;
}
@@ -414,7 +421,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();
}
@@ -466,10 +472,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();
@@ -478,8 +482,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(
@@ -488,7 +492,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();
@@ -498,53 +501,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) {
@@ -564,6 +567,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 20d9562c35ee52..ce3c920986388e 100644
--- a/be/src/exec/sink/writer/iceberg/viceberg_table_writer.h
+++ b/be/src/exec/sink/writer/iceberg/viceberg_table_writer.h
@@ -73,23 +73,16 @@ 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:
FRIEND_TEST(VIcebergTableWriterTest, RejectMissingPartitionSource);
FRIEND_TEST(VIcebergTableWriterTest, ResolvesNestedPartitionSource);
+ friend class VIcebergTableWriterTest;
- // 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;
+ // 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,
@@ -150,8 +143,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/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..5db9071e64538c 100644
--- a/be/src/io/fs/obj_storage_client.h
+++ b/be/src/io/fs/obj_storage_client.h
@@ -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..73cf80d73c1630 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,15 @@ ObjectStorageResponse RateLimitedObjStorageClient::complete_multipart_upload(
return _inner->complete_multipart_upload(opts, completed_parts);
}
+ObjectStorageResponse RateLimitedObjStorageClient::abort_multipart_upload(
+ const ObjectStoragePathOptions& opts) {
+ S3RateLimitGuard guard(S3RateLimitType::PUT, 0);
+ if (!guard.ok()) {
+ return rate_limited_response(S3RateLimitType::PUT, guard.reject_reason());
+ }
+ 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..0ebb616a0e8cb5 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 (_obj_storage_path_opts.upload_id.has_value()) {
+ 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();
@@ -162,6 +193,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 +207,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..bfebae917b1386 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();
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..7b3cae3d86d5f2 100644
--- a/be/src/runtime/runtime_state.cpp
+++ b/be/src/runtime/runtime_state.cpp
@@ -52,12 +52,36 @@
#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 lock(_iceberg_commit_datas_mutex);
+ // Reject metadata while its file can still be removed; a later oversized report would strand every output.
+ if (_iceberg_commit_datas_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");
+ }
+ _iceberg_commit_datas_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 cf844dd8c45dcb..d440e3b45b9636 100644
--- a/be/src/runtime/runtime_state.h
+++ b/be/src/runtime/runtime_state.h
@@ -525,15 +525,12 @@ 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);
std::vector mc_commit_datas() const {
std::lock_guard lock(_mc_commit_datas_mutex);
@@ -978,6 +975,7 @@ class RuntimeState {
mutable std::mutex _iceberg_commit_datas_mutex;
std::vector _iceberg_commit_datas;
+ size_t _iceberg_commit_datas_serialized_bytes = 0;
mutable std::mutex _mc_commit_datas_mutex;
std::vector _mc_commit_datas;
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..264c9895990a4b
--- /dev/null
+++ b/be/test/exec/sink/writer/iceberg/iceberg_table_writer_test.cpp
@@ -0,0 +1,100 @@
+// 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 "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:
+ 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";
+};
+
+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 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);
+}
+
+} // namespace doris
diff --git a/be/test/io/fs/s3_file_writer_test.cpp b/be/test/io/fs/s3_file_writer_test.cpp
index 3937d6e38561fe..a3033c9f1c07e4 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;
diff --git a/be/test/runtime/runtime_state_block_budget_test.cpp b/be/test/runtime/runtime_state_block_budget_test.cpp
index 22ebc5ebf8a0ee..b2b1e141508101 100644
--- a/be/test/runtime/runtime_state_block_budget_test.cpp
+++ b/be/test/runtime/runtime_state_block_budget_test.cpp
@@ -24,6 +24,22 @@
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());
+}
+
// ---------------------------------------------------------------------------
// RuntimeState::batch_size()
// ---------------------------------------------------------------------------
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 21b301a3416490..f21dfb99359082 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
@@ -154,8 +154,8 @@ public class IcebergConnectorTransaction implements ConnectorTransaction, Rewrit
private Map staticPartitionValues = Collections.emptyMap();
private String branchName;
private IcebergWriteSchemaContext writeSchemaContext;
- // 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;
@@ -430,7 +430,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);
@@ -440,8 +439,10 @@ 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 ? branchRef.snapshotId() : null;
} else {
this.branchName = null;
+ this.baseSnapshotId = op == WriteOperation.OVERWRITE ? getSnapshotIdIfPresent(table) : null;
}
}
}
@@ -705,6 +706,8 @@ private void commitReplaceTxn(List pendingResults) {
if (branchName != null) {
overwriteFiles = overwriteFiles.toBranch(branchName);
}
+ // 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);
@@ -724,6 +727,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.");
@@ -749,6 +757,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,
@@ -758,6 +767,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 536babad0e517c..a75beb97ad12a5 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/action/IcebergExecuteActionFactory.java b/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/action/IcebergExecuteActionFactory.java
index 37d949c1d7abcf..7b785c9ac31674 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");
+ }
+ String tableLocation = normalizeLocation(table.location());
+ String scanLocation = namedArguments.getString(LOCATION);
+ scanLocation = scanLocation == null ? tableLocation : normalizeLocation(scanLocation);
+ // Normalize dot segments before the containment check so local FileIO paths cannot escape the table root.
+ if (!scanLocation.equals(tableLocation) && !scanLocation.startsWith(tableLocation + "/")) {
+ throw new DorisConnectorException("location must be within the Iceberg table location");
+ }
+
+ try {
+ Set reachable = collectReachableFiles(table);
+ long orphanCount = 0;
+ long deletedCount = 0;
+ long olderThan = namedArguments.getLong(OLDER_THAN);
+ boolean dryRun = namedArguments.getBoolean(DRY_RUN);
+ // Object stores use raw prefix matching, so the separator prevents "table_backup" siblings
+ // from being treated as children of "table".
+ String listingPrefix = scanLocation.endsWith("/") ? scanLocation : scanLocation + "/";
+ for (FileInfo file : ((SupportsPrefixOperations) table.io()).listPrefix(listingPrefix)) {
+ if (file.createdAtMillis() < olderThan && !reachable.contains(file.location())) {
+ 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 Set collectReachableFiles(Table table) throws IOException {
+ Set reachable = new HashSet<>(ReachableFileUtil.metadataFileLocations(table, true));
+ 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());
+ }
+ for (ManifestFile manifest : snapshot.deleteManifests(table.io())) {
+ if (!scannedDeleteManifests.add(manifest.path())) {
+ continue;
+ }
+ // A retained delete file may not apply to any current data task, so read delete manifests directly.
+ try (ManifestReader deletes =
+ ManifestFiles.readDeleteManifest(manifest, table.io(), table.specs())) {
+ deletes.forEach(delete -> reachable.add(delete.location()));
+ }
+ }
+ try (CloseableIterable tasks = table.newScan()
+ .useSnapshot(snapshot.snapshotId()).planFiles()) {
+ for (FileScanTask task : tasks) {
+ reachable.add(task.file().location());
+ task.deletes().forEach(delete -> reachable.add(delete.location()));
+ }
+ }
+ }
+ return reachable;
+ }
+
+ 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/IcebergConnectorTransactionTest.java b/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/IcebergConnectorTransactionTest.java
index cdc1a341f40cc1..1fa84febaac271 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
@@ -592,6 +592,28 @@ 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 overwriteEmptyUnpartitionedClearsTable() {
InMemoryCatalog catalog = freshCatalog();
@@ -682,6 +704,29 @@ public void overwriteStaticPartitionRejectsUnmatchedPartitionField() {
"a stale static overwrite must fail at begin before it can degrade to an always-true filter");
}
+ @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(table, 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 deleteWritesRowDeltaDeleteFiles() {
InMemoryCatalog catalog = freshCatalog();
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 d1978fe7741fd7..76d4f34b57e2d1 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.spi.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-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 c9e5f60a71a2c3..c24931a1a55ba9 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 86569395c902c9..75fd22c20ce220 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.
From f29923204c6b8b3d7fea6b5da9e3c2ca535418a2 Mon Sep 17 00:00:00 2001
From: Gabriel
Date: Sat, 1 Aug 2026 23:33:48 +0800
Subject: [PATCH 02/29] [fix](iceberg) Address write safety review findings
### What problem does this PR solve?
Issue Number: None
Related PR: #66348
Problem Summary: Parallel Iceberg sink tasks could exceed the shared report limit, partition writers could over-reserve memory or outlive a temporary snapshot, failed multipart uploads could skip mandatory cleanup, and orphan-file deletion lacked Iceberg safety fences. Share the report budget, retain writer snapshots, bound reservations, make abort cleanup provider-aware, and enforce safe canonical orphan detection.
### Release note
Improve Iceberg write failure cleanup and orphan-file deletion safety.
### Check List (For Author)
- Test: Unit Test
- BE ASAN focused tests with Azure support enabled
- Full fe-connector-iceberg unit test suite
- Behavior changed: Yes, unsafe orphan cutoffs and GC-disabled deletion are rejected, and failed multipart uploads are cleaned up reliably.
- Does this need documentation: No
---
.../spill_iceberg_table_sink_operator.cpp | 28 +++-
.../spill_iceberg_table_sink_operator.h | 5 +-
.../pipeline/pipeline_fragment_context.cpp | 2 +
be/src/io/fs/azure_obj_storage_client.cpp | 54 ++++++--
be/src/io/fs/azure_obj_storage_client.h | 1 +
be/src/io/fs/obj_storage_client.h | 4 +-
.../io/fs/rate_limited_obj_storage_client.cpp | 5 +-
be/src/io/fs/s3_file_writer.cpp | 4 +-
be/src/io/fs/s3_file_writer.h | 1 +
be/src/runtime/runtime_state.cpp | 9 +-
be/src/runtime/runtime_state.h | 19 ++-
...spill_iceberg_table_sink_operator_test.cpp | 30 +++++
.../iceberg/iceberg_table_writer_test.cpp | 44 +++++++
.../io/fs/azure_obj_storage_client_test.cpp | 20 +++
.../rate_limited_obj_storage_client_test.cpp | 23 ++++
be/test/io/fs/s3_file_writer_test.cpp | 23 ++++
.../runtime_state_block_budget_test.cpp | 19 +++
.../IcebergRemoveOrphanFilesAction.java | 108 +++++++++++++++-
.../iceberg/IcebergProcedureOpsTest.java | 6 +-
.../IcebergRemoveOrphanFilesActionTest.java | 121 ++++++++++++++++++
20 files changed, 495 insertions(+), 31 deletions(-)
create mode 100644 be/test/exec/operator/spill_iceberg_table_sink_operator_test.cpp
create mode 100644 fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/action/IcebergRemoveOrphanFilesActionTest.java
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 083d7b86c87c0f..a974e575916b7f 100644
--- a/be/src/exec/operator/spill_iceberg_table_sink_operator.cpp
+++ b/be/src/exec/operator/spill_iceberg_table_sink_operator.cpp
@@ -17,6 +17,8 @@
#include "exec/operator/spill_iceberg_table_sink_operator.h"
+#include
+
#include "common/status.h"
#include "exec/operator/iceberg_table_sink_operator.h"
#include "exec/sink/writer/iceberg/viceberg_sort_writer.h"
@@ -24,6 +26,13 @@
namespace doris {
+size_t bounded_iceberg_reserve_size(const std::vector& per_partition_reservations) {
+ return per_partition_reservations.empty()
+ ? 0
+ : *std::max_element(per_partition_reservations.begin(),
+ per_partition_reservations.end());
+}
+
SpillIcebergTableSinkLocalState::SpillIcebergTableSinkLocalState(DataSinkOperatorXBase* parent,
RuntimeState* state)
: Base(parent, state) {}
@@ -55,13 +64,16 @@ size_t SpillIcebergTableSinkLocalState::get_reserve_mem_size(RuntimeState* state
if (!_writer) {
return 0;
}
- size_t reserve_size = 0;
- for (const auto& writer : *_writer->active_writers()) {
+ 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())) {
- reserve_size += sort_writer->get_reserve_mem_size(state, eos);
+ per_partition_reservations.push_back(sort_writer->get_reserve_mem_size(state, eos));
}
}
- return reserve_size;
+ // One input block is partitioned among writers and consumed serially, so their full-batch estimates overlap.
+ return bounded_iceberg_reserve_size(per_partition_reservations);
}
size_t SpillIcebergTableSinkLocalState::get_revocable_mem_size(RuntimeState* state) const {
@@ -69,7 +81,9 @@ size_t SpillIcebergTableSinkLocalState::get_revocable_mem_size(RuntimeState* sta
return 0;
}
size_t revocable_size = 0;
- for (const auto& writer : *_writer->active_writers()) {
+ // 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();
}
@@ -84,7 +98,9 @@ Status SpillIcebergTableSinkLocalState::revoke_memory(RuntimeState* state) {
}
std::shared_ptr largest_writer;
size_t largest_size = 0;
- for (const auto& writer : *_writer->active_writers()) {
+ // 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) {
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..60eefd1bfb5242 100644
--- a/be/src/exec/operator/spill_iceberg_table_sink_operator.h
+++ b/be/src/exec/operator/spill_iceberg_table_sink_operator.h
@@ -18,6 +18,7 @@
#pragma once
#include
+#include
#include "exec/operator/operator.h"
#include "exec/sink/writer/iceberg/viceberg_table_writer.h"
@@ -27,6 +28,8 @@ namespace doris {
class SpillIcebergTableSinkLocalState;
class SpillIcebergTableSinkOperatorX;
+size_t bounded_iceberg_reserve_size(const std::vector& per_partition_reservations);
+
class SpillIcebergTableSinkLocalState final
: public AsyncWriterSink {
public:
@@ -87,4 +90,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 4f2c6dc00483e6..8ccaca43dbf3c0 100644
--- a/be/src/exec/pipeline/pipeline_fragment_context.cpp
+++ b/be/src/exec/pipeline/pipeline_fragment_context.cpp
@@ -468,6 +468,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());
diff --git a/be/src/io/fs/azure_obj_storage_client.cpp b/be/src/io/fs/azure_obj_storage_client.cpp
index 9702c87b3b304b..5bc0c8102e47b2 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(),
};
}
@@ -223,7 +229,7 @@ ObjectStorageUploadResponse AzureObjStorageClient::upload_part(const ObjectStora
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(azure_block_id(opts, part_num), memory_body);
},
opts, _tls_debug_context);
return ObjectStorageUploadResponse {
@@ -238,7 +244,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 +253,38 @@ 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);
+ if (block_list.Value.CommittedBlocks.empty()) {
+ DeleteBlobOptions delete_options;
+ // The ETag fence, when present, protects a concurrently committed blob.
+ delete_options.AccessConditions.IfMatch = block_list.Value.ETag;
+ client.Delete(delete_options);
+ 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..58c8a24481de69 100644
--- a/be/src/io/fs/azure_obj_storage_client.h
+++ b/be/src/io/fs/azure_obj_storage_client.h
@@ -49,6 +49,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/obj_storage_client.h b/be/src/io/fs/obj_storage_client.h
index 5db9071e64538c..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;
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 73cf80d73c1630..218c39cb4b19ec 100644
--- a/be/src/io/fs/rate_limited_obj_storage_client.cpp
+++ b/be/src/io/fs/rate_limited_obj_storage_client.cpp
@@ -75,10 +75,7 @@ ObjectStorageResponse RateLimitedObjStorageClient::complete_multipart_upload(
ObjectStorageResponse RateLimitedObjStorageClient::abort_multipart_upload(
const ObjectStoragePathOptions& opts) {
- S3RateLimitGuard guard(S3RateLimitType::PUT, 0);
- if (!guard.ok()) {
- return rate_limited_response(S3RateLimitType::PUT, guard.reject_reason());
- }
+ // Cleanup must reach the provider even when a hard PUT limit caused the upload failure.
return _inner->abort_multipart_upload(opts);
}
diff --git a/be/src/io/fs/s3_file_writer.cpp b/be/src/io/fs/s3_file_writer.cpp
index 0ebb616a0e8cb5..43663afa6dfdf8 100644
--- a/be/src/io/fs/s3_file_writer.cpp
+++ b/be/src/io/fs/s3_file_writer.cpp
@@ -109,7 +109,7 @@ Status S3FileWriter::_abort_impl() {
_wait_until_finish(
fmt::format("wait s3 file {} before abort", _obj_storage_path_opts.path.native()));
_pending_buf.reset();
- if (_obj_storage_path_opts.upload_id.has_value()) {
+ if (_multipart_upload_started) {
const auto& client = _obj_client->get();
if (client == nullptr) {
return Status::InternalError("invalid obj storage client");
@@ -131,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)};
diff --git a/be/src/io/fs/s3_file_writer.h b/be/src/io/fs/s3_file_writer.h
index bfebae917b1386..eb40772eea095e 100644
--- a/be/src/io/fs/s3_file_writer.h
+++ b/be/src/io/fs/s3_file_writer.h
@@ -119,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/runtime/runtime_state.cpp b/be/src/runtime/runtime_state.cpp
index 7b3cae3d86d5f2..fde7ca3bc2d0ad 100644
--- a/be/src/runtime/runtime_state.cpp
+++ b/be/src/runtime/runtime_state.cpp
@@ -69,15 +69,16 @@ Status RuntimeState::add_iceberg_commit_datas(TIcebergCommitData iceberg_commit_
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 lock(_iceberg_commit_datas_mutex);
- // Reject metadata while its file can still be removed; a later oversized report would strand every output.
- if (_iceberg_commit_datas_serialized_bytes + serialized_size + sizeof(uint32_t) >
+ 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");
}
- _iceberg_commit_datas_serialized_bytes += serialized_size + sizeof(uint32_t);
+ 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();
}
diff --git a/be/src/runtime/runtime_state.h b/be/src/runtime/runtime_state.h
index d440e3b45b9636..9ae8484d81709a 100644
--- a/be/src/runtime/runtime_state.h
+++ b/be/src/runtime/runtime_state.h
@@ -76,6 +76,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 {
@@ -532,6 +540,14 @@ class RuntimeState {
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 {
std::lock_guard lock(_mc_commit_datas_mutex);
return _mc_commit_datas;
@@ -975,7 +991,8 @@ class RuntimeState {
mutable std::mutex _iceberg_commit_datas_mutex;
std::vector _iceberg_commit_datas;
- size_t _iceberg_commit_datas_serialized_bytes = 0;
+ 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..87a22e4071fbcc
--- /dev/null
+++ b/be/test/exec/operator/spill_iceberg_table_sink_operator_test.cpp
@@ -0,0 +1,30 @@
+// 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 "exec/operator/spill_iceberg_table_sink_operator.h"
+
+#include
+
+namespace doris {
+
+TEST(SpillIcebergTableSinkOperatorTest, BoundsManyPartitionReservationToOneInputBlock) {
+ std::vector per_partition_reservations(128, 8 * 1024 * 1024);
+
+ EXPECT_EQ(8 * 1024 * 1024, bounded_iceberg_reserve_size(per_partition_reservations));
+}
+
+} // 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
index 264c9895990a4b..e38da59cfbc176 100644
--- a/be/test/exec/sink/writer/iceberg/iceberg_table_writer_test.cpp
+++ b/be/test/exec/sink/writer/iceberg/iceberg_table_writer_test.cpp
@@ -17,6 +17,9 @@
#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"
@@ -28,6 +31,12 @@ 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();
}
@@ -39,6 +48,7 @@ class FakePartitionWriter final : public IPartitionWriterBase {
private:
std::string _name = "fake";
+ std::atomic* _destroyed;
};
TDataSink make_sink() {
@@ -62,6 +72,15 @@ class VIcebergTableWriterTest : public testing::Test {
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();
}
@@ -97,4 +116,29 @@ TEST_F(VIcebergTableWriterTest, ActiveWriterSnapshotContainsEveryOpenPartition)
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..d9f10e41e94caf 100644
--- a/be/test/io/fs/azure_obj_storage_client_test.cpp
+++ b/be/test/io/fs/azure_obj_storage_client_test.cpp
@@ -156,6 +156,26 @@ 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);
+ 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);
+}
#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 a3033c9f1c07e4..b00f6c491c72d4 100644
--- a/be/test/io/fs/s3_file_writer_test.cpp
+++ b/be/test/io/fs/s3_file_writer_test.cpp
@@ -1169,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(),
@@ -1243,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 {
@@ -1281,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();
@@ -1322,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 b2b1e141508101..24afe920d197c5 100644
--- a/be/test/runtime/runtime_state_block_budget_test.cpp
+++ b/be/test/runtime/runtime_state_block_budget_test.cpp
@@ -40,6 +40,25 @@ TEST(RuntimeStateIcebergCommitDataTest, RejectsMetadataBeforeItCanExceedTheThrif
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/action/IcebergRemoveOrphanFilesAction.java b/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/action/IcebergRemoveOrphanFilesAction.java
index 0b353703ba723e..cbdf75169e4eb0 100644
--- a/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/action/IcebergRemoveOrphanFilesAction.java
+++ b/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/action/IcebergRemoveOrphanFilesAction.java
@@ -33,19 +33,25 @@
import org.apache.iceberg.ReachableFileUtil;
import org.apache.iceberg.Snapshot;
import org.apache.iceberg.Table;
+import org.apache.iceberg.TableProperties;
import org.apache.iceberg.io.CloseableIterable;
import org.apache.iceberg.io.FileInfo;
import org.apache.iceberg.io.SupportsPrefixOperations;
+import org.apache.iceberg.util.PropertyUtil;
import java.io.IOException;
import java.net.URI;
+import java.time.Duration;
import java.util.HashSet;
import java.util.List;
+import java.util.Locale;
import java.util.Map;
+import java.util.Objects;
import java.util.Set;
/** Safely lists or deletes old files that are unreachable from every retained snapshot. */
public class IcebergRemoveOrphanFilesAction extends BaseIcebergAction {
+ private static final long MIN_RETENTION_MS = Duration.ofHours(24).toMillis();
public static final String OLDER_THAN = "older_than";
public static final String LOCATION = "location";
public static final String DRY_RUN = "dry_run";
@@ -84,6 +90,11 @@ 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");
+ }
String tableLocation = normalizeLocation(table.location());
String scanLocation = namedArguments.getString(LOCATION);
scanLocation = scanLocation == null ? tableLocation : normalizeLocation(scanLocation);
@@ -93,16 +104,21 @@ protected List executeAction(Table table, ConnectorSession session) {
}
try {
- Set reachable = collectReachableFiles(table);
+ 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);
// Object stores use raw prefix matching, so the separator prevents "table_backup" siblings
// from being treated as children of "table".
String listingPrefix = scanLocation.endsWith("/") ? scanLocation : scanLocation + "/";
for (FileInfo file : ((SupportsPrefixOperations) table.io()).listPrefix(listingPrefix)) {
- if (file.createdAtMillis() < olderThan && !reachable.contains(file.location())) {
+ if (file.createdAtMillis() < olderThan && !isReachable(file.location(), reachable)) {
orphanCount++;
if (!dryRun) {
table.io().deleteFile(file.location());
@@ -118,6 +134,8 @@ protected List executeAction(Table table, ConnectorSession session) {
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 scannedDeleteManifests = new HashSet<>();
reachable.addAll(ReachableFileUtil.manifestListLocations(table));
reachable.addAll(ReachableFileUtil.statisticsFilesLocations(table));
@@ -146,6 +164,92 @@ private Set collectReachableFiles(Table table) throws IOException {
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("/")
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 2ab67da81fd841..3d996d4926e93b 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/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..fdccb1c026352a
--- /dev/null
+++ b/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/action/IcebergRemoveOrphanFilesActionTest.java
@@ -0,0 +1,121 @@
+// 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.PartitionSpec;
+import org.apache.iceberg.ReachableFileUtil;
+import org.apache.iceberg.Table;
+import org.apache.iceberg.TableProperties;
+import org.apache.iceberg.hadoop.HadoopTables;
+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.Map;
+
+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")));
+ }
+
+ private static IcebergRemoveOrphanFilesAction action(long olderThan, boolean dryRun) {
+ Map properties = new HashMap<>();
+ properties.put(IcebergRemoveOrphanFilesAction.OLDER_THAN, String.valueOf(olderThan));
+ properties.put(IcebergRemoveOrphanFilesAction.DRY_RUN, String.valueOf(dryRun));
+ 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;
+ }
+}
From 08b5ac7793ac7deb549228d3e0f8f7e3cfc0fed6 Mon Sep 17 00:00:00 2001
From: Gabriel
Date: Sun, 2 Aug 2026 11:02:42 +0800
Subject: [PATCH 03/29] [fix](iceberg) Preserve Azure data and deduplicate
orphan scans
### What problem does this PR solve?
Issue Number: None
Related PR: #66348
Problem Summary: Azure abort could delete a pre-existing Put Blob object because an empty committed block list does not mean the key has no committed content. Iceberg orphan cleanup also reopened inherited data manifests once per retained snapshot, making long histories quadratic. Preserve Put Blob content while discarding only staged block-list replacements, and scan each unique retained manifest once.
### Release note
Preserve existing Azure objects during failed Iceberg replacements and bound orphan cleanup manifest reads.
### Check List (For Author)
- Test: Unit Test
- Azure abort helper BE unit test
- Iceberg orphan action FE unit tests
- Behavior changed: Yes, Azure abort no longer deletes pre-existing Put Blob content and orphan cleanup deduplicates manifest reads.
- Does this need documentation: No
---
be/src/io/fs/azure_obj_storage_client.cpp | 11 +-
be/src/io/fs/azure_obj_storage_client.h | 4 +
.../io/fs/azure_obj_storage_client_test.cpp | 38 ++++++
.../IcebergRemoveOrphanFilesAction.java | 35 +++---
.../IcebergRemoveOrphanFilesActionTest.java | 112 ++++++++++++++++++
5 files changed, 178 insertions(+), 22 deletions(-)
diff --git a/be/src/io/fs/azure_obj_storage_client.cpp b/be/src/io/fs/azure_obj_storage_client.cpp
index 5bc0c8102e47b2..5a03c6831d05df 100644
--- a/be/src/io/fs/azure_obj_storage_client.cpp
+++ b/be/src/io/fs/azure_obj_storage_client.cpp
@@ -261,11 +261,14 @@ ObjectStorageResponse AzureObjStorageClient::abort_multipart_upload(
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()) {
- DeleteBlobOptions delete_options;
- // The ETag fence, when present, protects a concurrently committed blob.
- delete_options.AccessConditions.IfMatch = block_list.Value.ETag;
- client.Delete(delete_options);
+ // Azure cannot selectively discard staged blocks without replacing Put Blob content.
return;
}
std::vector committed_ids;
diff --git a/be/src/io/fs/azure_obj_storage_client.h b/be/src/io/fs/azure_obj_storage_client.h
index 58c8a24481de69..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:
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 d9f10e41e94caf..560fc3664475c6 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;
@@ -176,6 +184,36 @@ TEST_F(AzureObjStorageClientTest, abort_multipart_upload_discards_staged_blocks)
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/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/action/IcebergRemoveOrphanFilesAction.java b/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/action/IcebergRemoveOrphanFilesAction.java
index cbdf75169e4eb0..af77d11b2ab677 100644
--- a/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/action/IcebergRemoveOrphanFilesAction.java
+++ b/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/action/IcebergRemoveOrphanFilesAction.java
@@ -25,8 +25,9 @@
import org.apache.doris.foundation.util.ArgumentParsers;
import com.google.common.collect.Lists;
+import org.apache.iceberg.DataFile;
import org.apache.iceberg.DeleteFile;
-import org.apache.iceberg.FileScanTask;
+import org.apache.iceberg.ManifestContent;
import org.apache.iceberg.ManifestFile;
import org.apache.iceberg.ManifestFiles;
import org.apache.iceberg.ManifestReader;
@@ -34,7 +35,6 @@
import org.apache.iceberg.Snapshot;
import org.apache.iceberg.Table;
import org.apache.iceberg.TableProperties;
-import org.apache.iceberg.io.CloseableIterable;
import org.apache.iceberg.io.FileInfo;
import org.apache.iceberg.io.SupportsPrefixOperations;
import org.apache.iceberg.util.PropertyUtil;
@@ -136,28 +136,27 @@ 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());
- }
- for (ManifestFile manifest : snapshot.deleteManifests(table.io())) {
- if (!scannedDeleteManifests.add(manifest.path())) {
- continue;
- }
- // A retained delete file may not apply to any current data task, so read delete manifests directly.
- try (ManifestReader deletes =
- ManifestFiles.readDeleteManifest(manifest, table.io(), table.specs())) {
- deletes.forEach(delete -> reachable.add(delete.location()));
- }
- }
- try (CloseableIterable tasks = table.newScan()
- .useSnapshot(snapshot.snapshotId()).planFiles()) {
- for (FileScanTask task : tasks) {
- reachable.add(task.file().location());
- task.deletes().forEach(delete -> reachable.add(delete.location()));
+ 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()));
+ }
}
}
}
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
index fdccb1c026352a..0e9ed5bc849371 100644
--- 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
@@ -21,11 +21,21 @@
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;
@@ -36,7 +46,9 @@
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();
@@ -99,6 +111,37 @@ public void rejectsUnresolvedPrefixMismatches() {
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));
+ }
+
private static IcebergRemoveOrphanFilesAction action(long olderThan, boolean dryRun) {
Map properties = new HashMap<>();
properties.put(IcebergRemoveOrphanFilesAction.OLDER_THAN, String.valueOf(olderThan));
@@ -118,4 +161,73 @@ private static Path createOldFile(Path path) throws Exception {
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);
+ }
+ }
}
From 065e5df46b4f01f1c5fb936bfecfe74df294aea9 Mon Sep 17 00:00:00 2001
From: Gabriel
Date: Sun, 2 Aug 2026 12:30:11 +0800
Subject: [PATCH 04/29] [fix](iceberg) Address follow-up write safety review
---
.../operator/iceberg_sorter_reserve_memory.h | 45 +++++++
.../spill_iceberg_table_sink_operator.cpp | 18 +--
.../spill_iceberg_table_sink_operator.h | 3 +-
.../writer/iceberg/viceberg_sort_writer.cpp | 7 +
.../writer/iceberg/viceberg_sort_writer.h | 2 +
be/src/exec/sort/sorter.cpp | 17 ++-
be/src/exec/sort/sorter.h | 9 ++
be/src/io/fs/azure_obj_storage_client.cpp | 5 +-
...spill_iceberg_table_sink_operator_test.cpp | 15 ++-
.../io/fs/azure_obj_storage_client_test.cpp | 2 +
.../iceberg/IcebergConnectorTransaction.java | 41 +++++-
.../iceberg/IcebergScanPlanProvider.java | 5 +-
.../connector/iceberg/IcebergTableHandle.java | 11 +-
.../IcebergRemoveOrphanFilesAction.java | 83 +++++++++---
.../IcebergConnectorTransactionTest.java | 125 ++++++++++++++++++
.../iceberg/IcebergTableHandleTest.java | 11 ++
.../IcebergRemoveOrphanFilesActionTest.java | 75 +++++++++++
.../filesystem/azure/AzureObjStorage.java | 4 +-
.../azure/AzureObjStorageExtensionTest.java | 43 ++++++
19 files changed, 474 insertions(+), 47 deletions(-)
create mode 100644 be/src/exec/operator/iceberg_sorter_reserve_memory.h
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 a974e575916b7f..5d0d1cb00a7916 100644
--- a/be/src/exec/operator/spill_iceberg_table_sink_operator.cpp
+++ b/be/src/exec/operator/spill_iceberg_table_sink_operator.cpp
@@ -17,8 +17,6 @@
#include "exec/operator/spill_iceberg_table_sink_operator.h"
-#include
-
#include "common/status.h"
#include "exec/operator/iceberg_table_sink_operator.h"
#include "exec/sink/writer/iceberg/viceberg_sort_writer.h"
@@ -26,13 +24,6 @@
namespace doris {
-size_t bounded_iceberg_reserve_size(const std::vector& per_partition_reservations) {
- return per_partition_reservations.empty()
- ? 0
- : *std::max_element(per_partition_reservations.begin(),
- per_partition_reservations.end());
-}
-
SpillIcebergTableSinkLocalState::SpillIcebergTableSinkLocalState(DataSinkOperatorXBase* parent,
RuntimeState* state)
: Base(parent, state) {}
@@ -64,15 +55,18 @@ size_t SpillIcebergTableSinkLocalState::get_reserve_mem_size(RuntimeState* state
if (!_writer) {
return 0;
}
- std::vector per_partition_reservations;
+ 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())) {
- per_partition_reservations.push_back(sort_writer->get_reserve_mem_size(state, eos));
+ 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});
}
}
- // One input block is partitioned among writers and consumed serially, so their full-batch estimates overlap.
+ // Column growth remains in every touched sorter, while sorting workspace is reused by serial dispatch.
return bounded_iceberg_reserve_size(per_partition_reservations);
}
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 60eefd1bfb5242..5ffdd7505599ea 100644
--- a/be/src/exec/operator/spill_iceberg_table_sink_operator.h
+++ b/be/src/exec/operator/spill_iceberg_table_sink_operator.h
@@ -20,6 +20,7 @@
#include
#include
+#include "exec/operator/iceberg_sorter_reserve_memory.h"
#include "exec/operator/operator.h"
#include "exec/sink/writer/iceberg/viceberg_table_writer.h"
@@ -28,8 +29,6 @@ namespace doris {
class SpillIcebergTableSinkLocalState;
class SpillIcebergTableSinkOperatorX;
-size_t bounded_iceberg_reserve_size(const std::vector& per_partition_reservations);
-
class SpillIcebergTableSinkLocalState final
: public AsyncWriterSink {
public:
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 168199f1d00849..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) {
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/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 5a03c6831d05df..daa215e3c58d9e 100644
--- a/be/src/io/fs/azure_obj_storage_client.cpp
+++ b/be/src/io/fs/azure_obj_storage_client.cpp
@@ -223,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(azure_block_id(opts, 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,
};
}
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
index 87a22e4071fbcc..5d1fa473205f0b 100644
--- a/be/test/exec/operator/spill_iceberg_table_sink_operator_test.cpp
+++ b/be/test/exec/operator/spill_iceberg_table_sink_operator_test.cpp
@@ -15,16 +15,25 @@
// specific language governing permissions and limitations
// under the License.
-#include "exec/operator/spill_iceberg_table_sink_operator.h"
-
#include
+#include "exec/operator/iceberg_sorter_reserve_memory.h"
+
namespace doris {
TEST(SpillIcebergTableSinkOperatorTest, BoundsManyPartitionReservationToOneInputBlock) {
- std::vector per_partition_reservations(128, 8 * 1024 * 1024);
+ 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/io/fs/azure_obj_storage_client_test.cpp b/be/test/io/fs/azure_obj_storage_client_test.cpp
index 560fc3664475c6..88e81ea7fe6ed0 100644
--- a/be/test/io/fs/azure_obj_storage_client_test.cpp
+++ b/be/test/io/fs/azure_obj_storage_client_test.cpp
@@ -177,6 +177,8 @@ TEST_F(AzureObjStorageClientTest, abort_multipart_upload_discards_staged_blocks)
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);
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 f21dfb99359082..9306f29bbcfd18 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
@@ -439,14 +439,51 @@ 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 ? branchRef.snapshotId() : null;
+ this.baseSnapshotId = op == WriteOperation.OVERWRITE
+ ? resolveOverwriteBaseSnapshot(ctx, branchRef.snapshotId(), tableName) : null;
} else {
this.branchName = null;
- this.baseSnapshotId = op == WriteOperation.OVERWRITE ? getSnapshotIdIfPresent(table) : null;
+ this.baseSnapshotId = op == WriteOperation.OVERWRITE
+ ? resolveOverwriteBaseSnapshot(ctx, getSnapshotIdIfPresent(table), tableName) : null;
}
}
}
+ private Long resolveOverwriteBaseSnapshot(IcebergWriteContext ctx, Long targetHead, String tableName) {
+ if (!ctx.isReadSnapshotResolved()) {
+ 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
public long getTransactionId() {
return transactionId;
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 09f8fa44615093..bfabf1c986adba 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
@@ -936,7 +936,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 {
@@ -1157,6 +1157,9 @@ private TableScan buildScan(Table table, IcebergTableHandle handle, Optional= 0 || ref != null;
}
+ /** Whether the pin selects an Iceberg snapshot/ref rather than the explicit empty-table state. */
+ public boolean hasSnapshotSelection() {
+ return snapshotId >= 0 || ref != null;
+ }
+
/** Whether snapshot resolution observed a table before its first snapshot was committed. */
public boolean isResolvedEmptySnapshot() {
return snapshotResolved && snapshotId < 0 && ref == null;
diff --git a/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/action/IcebergRemoveOrphanFilesAction.java b/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/action/IcebergRemoveOrphanFilesAction.java
index af77d11b2ab677..37226b21fde047 100644
--- a/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/action/IcebergRemoveOrphanFilesAction.java
+++ b/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/action/IcebergRemoveOrphanFilesAction.java
@@ -42,7 +42,9 @@
import java.io.IOException;
import java.net.URI;
import java.time.Duration;
+import java.util.ArrayList;
import java.util.HashSet;
+import java.util.LinkedHashSet;
import java.util.List;
import java.util.Locale;
import java.util.Map;
@@ -95,13 +97,7 @@ protected List executeAction(Table table, ConnectorSession session) {
// 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");
}
- String tableLocation = normalizeLocation(table.location());
- String scanLocation = namedArguments.getString(LOCATION);
- scanLocation = scanLocation == null ? tableLocation : normalizeLocation(scanLocation);
- // Normalize dot segments before the containment check so local FileIO paths cannot escape the table root.
- if (!scanLocation.equals(tableLocation) && !scanLocation.startsWith(tableLocation + "/")) {
- throw new DorisConnectorException("location must be within the Iceberg table location");
- }
+ List scanLocations = resolveScanLocations(table);
try {
ReachableIndex reachable = new ReachableIndex(collectReachableFiles(table));
@@ -114,15 +110,18 @@ protected List executeAction(Table table, ConnectorSession session) {
"older_than must retain at least 24 hours of files");
}
boolean dryRun = namedArguments.getBoolean(DRY_RUN);
- // Object stores use raw prefix matching, so the separator prevents "table_backup" siblings
- // from being treated as children of "table".
- String listingPrefix = scanLocation.endsWith("/") ? scanLocation : scanLocation + "/";
- for (FileInfo file : ((SupportsPrefixOperations) table.io()).listPrefix(listingPrefix)) {
- if (file.createdAtMillis() < olderThan && !isReachable(file.location(), reachable)) {
- orphanCount++;
- if (!dryRun) {
- table.io().deleteFile(file.location());
- deletedCount++;
+ 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++;
+ }
}
}
}
@@ -132,6 +131,58 @@ protected List executeAction(Table table, ConnectorSession session) {
}
}
+ 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.
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 1fa84febaac271..0df759d7a215a9 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
@@ -138,6 +138,11 @@ private static IcebergWriteContext overwriteToBranch(String branch) {
WriteOperation.OVERWRITE, true, Collections.emptyMap(), Optional.of(branch));
}
+ private static IcebergWriteContext overwriteCtxPinned(long readSnapshotId) {
+ return new IcebergWriteContext(WriteOperation.OVERWRITE, true, Collections.emptyMap(), Optional.empty(),
+ readSnapshotId);
+ }
+
private static IcebergWriteContext overwriteStaticCtx(Table table, Map staticValues) {
IcebergWriteSchemaContext schemaContext =
IcebergWriteSchemaContext.create(table, table.name(), Optional.empty(), false, false);
@@ -614,6 +619,84 @@ public void overwriteDynamicRejectsConcurrentDataInReplacedPartition() {
"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();
@@ -664,6 +747,24 @@ public void overwriteEmptyUnpartitionedBranchClearsOnlyBranchFiles() {
}
}
+ @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();
@@ -727,6 +828,30 @@ public void overwriteStaticRejectsConcurrentDataInTargetPartition() {
"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/IcebergTableHandleTest.java b/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/IcebergTableHandleTest.java
index f5e928afaaa50c..4492d097b96dd8 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,17 @@ 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.assertFalse(empty.hasSnapshotPin());
+ Assertions.assertFalse(empty.hasSnapshotSelection());
+ Assertions.assertTrue(empty.isSnapshotResolved());
+ 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/IcebergRemoveOrphanFilesActionTest.java b/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/action/IcebergRemoveOrphanFilesActionTest.java
index 0e9ed5bc849371..05d3dc1ae4e048 100644
--- 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
@@ -142,10 +142,85 @@ public void readsEachSharedDataManifestOnlyOnce(@TempDir Path temp) throws Excep
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);
}
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 =
From 87b32359b0e0f38db4fec96a6bd2bcd20977405f Mon Sep 17 00:00:00 2001
From: Gabriel
Date: Sun, 2 Aug 2026 14:25:57 +0800
Subject: [PATCH 05/29] [fix](iceberg) Address multipart and snapshot review
feedback
---
.../operator/iceberg_sorter_reserve_memory.h | 9 +++
.../spill_iceberg_table_sink_operator.cpp | 3 +-
be/src/io/fs/azure_obj_storage_client.cpp | 74 ++++++++++++-------
be/src/io/fs/azure_obj_storage_client.h | 5 +-
be/src/io/fs/obj_storage_client.h | 1 +
be/src/io/fs/s3_file_writer.cpp | 1 +
...spill_iceberg_table_sink_operator_test.cpp | 6 ++
.../io/fs/azure_obj_storage_client_test.cpp | 36 ++++++++-
.../iceberg/IcebergConnectorTransaction.java | 14 ++--
.../IcebergRemoveOrphanFilesAction.java | 59 +++++++++++----
.../IcebergConnectorTransactionTest.java | 37 +++++++++-
.../iceberg/IcebergScanPlanProviderTest.java | 17 +++++
.../IcebergRemoveOrphanFilesActionTest.java | 18 +++++
.../filesystem/azure/AzureObjStorage.java | 67 ++++++++---------
.../azure/AzureObjStorageExtensionTest.java | 66 +++++++++--------
15 files changed, 285 insertions(+), 128 deletions(-)
diff --git a/be/src/exec/operator/iceberg_sorter_reserve_memory.h b/be/src/exec/operator/iceberg_sorter_reserve_memory.h
index 1ad1454b4af5f3..835343cfec806b 100644
--- a/be/src/exec/operator/iceberg_sorter_reserve_memory.h
+++ b/be/src/exec/operator/iceberg_sorter_reserve_memory.h
@@ -42,4 +42,13 @@ inline size_t bounded_iceberg_reserve_size(
retained_growth;
}
+inline size_t iceberg_reserve_size(
+ const std::vector& per_partition_reservations,
+ size_t incoming_block_bytes) {
+ size_t sorter_reserve = bounded_iceberg_reserve_size(per_partition_reservations);
+ // The incoming block creates cold partition writers before they can appear in the published snapshot.
+ return std::min(std::numeric_limits::max() - sorter_reserve, incoming_block_bytes) +
+ sorter_reserve;
+}
+
} // 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 5d0d1cb00a7916..1fde8f882b280e 100644
--- a/be/src/exec/operator/spill_iceberg_table_sink_operator.cpp
+++ b/be/src/exec/operator/spill_iceberg_table_sink_operator.cpp
@@ -67,7 +67,8 @@ size_t SpillIcebergTableSinkLocalState::get_reserve_mem_size(RuntimeState* state
}
}
// Column growth remains in every touched sorter, while sorting workspace is reused by serial dispatch.
- return bounded_iceberg_reserve_size(per_partition_reservations);
+ return iceberg_reserve_size(per_partition_reservations,
+ eos ? 0 : state->minimum_operator_memory_required_bytes());
}
size_t SpillIcebergTableSinkLocalState::get_revocable_mem_size(RuntimeState* state) const {
diff --git a/be/src/io/fs/azure_obj_storage_client.cpp b/be/src/io/fs/azure_obj_storage_client.cpp
index daa215e3c58d9e..a870193deeefe7 100644
--- a/be/src/io/fs/azure_obj_storage_client.cpp
+++ b/be/src/io/fs/azure_obj_storage_client.cpp
@@ -21,6 +21,7 @@
#include
#include
+#include
#include
#include
#include
@@ -74,6 +75,16 @@ std::string azure_block_id(const doris::io::ObjectStoragePathOptions& opts, int
return Aws::Utils::HashingUtils::Base64Encode(bytes);
}
+std::string legacy_azure_block_id(int part_num) {
+ std::array bytes {
+ static_cast(part_num & 0xff),
+ static_cast((part_num >> 8) & 0xff),
+ static_cast((part_num >> 16) & 0xff),
+ static_cast((part_num >> 24) & 0xff)};
+ Aws::Utils::ByteBuffer buffer(bytes.data(), bytes.size());
+ return Aws::Utils::HashingUtils::Base64Encode(buffer);
+}
+
// Rate limiting is applied by RateLimitedObjStorageClient, the decorator that
// S3ClientFactory wraps around this client when the bucket is subject to limiting.
@@ -83,6 +94,10 @@ constexpr char BlobNotFound[] = "BlobNotFound";
namespace doris::io {
+std::string azure_multipart_temp_key(std::string_view key, std::string_view upload_id) {
+ return fmt::format("{}.__doris_multipart/{}", key, upload_id);
+}
+
// As Azure's doc said, the batch size is 256
// You can find out the num in https://learn.microsoft.com/en-us/rest/api/storageservices/blob-batch?tabs=microsoft-entra-id
// > Each batch request supports a maximum of 256 subrequests.
@@ -222,7 +237,8 @@ ObjectStorageResponse AzureObjStorageClient::put_object(const ObjectStoragePathO
ObjectStorageUploadResponse AzureObjStorageClient::upload_part(const ObjectStoragePathOptions& opts,
std::string_view stream,
int part_num) {
- auto client = _client->GetBlockBlobClient(opts.key);
+ DCHECK(opts.upload_id.has_value());
+ auto client = _client->GetBlockBlobClient(azure_multipart_temp_key(opts.key, *opts.upload_id));
std::string block_id = azure_block_id(opts, part_num);
auto resp = do_azure_client_call(
[&]() {
@@ -231,6 +247,13 @@ ObjectStorageUploadResponse AzureObjStorageClient::upload_part(const ObjectStora
// The blockId must be base64 encoded
SCOPED_BVAR_LATENCY(s3_bvar::s3_multi_part_upload_latency);
client.StageBlock(block_id, memory_body);
+ if (opts.deferred_completion) {
+ // During rolling upgrades an old FE still commits deterministic IDs on the final blob.
+ Azure::Core::IO::MemoryBodyStream legacy_body(
+ reinterpret_cast(stream.data()), stream.size());
+ _client->GetBlockBlobClient(opts.key).StageBlock(
+ legacy_azure_block_id(part_num), legacy_body);
+ }
},
opts, _tls_debug_context);
return ObjectStorageUploadResponse {
@@ -243,46 +266,43 @@ ObjectStorageUploadResponse AzureObjStorageClient::upload_part(const ObjectStora
ObjectStorageResponse AzureObjStorageClient::complete_multipart_upload(
const ObjectStoragePathOptions& opts,
const std::vector& completed_parts) {
- auto client = _client->GetBlockBlobClient(opts.key);
+ DCHECK(opts.upload_id.has_value());
+ auto temp_client =
+ _client->GetBlockBlobClient(azure_multipart_temp_key(opts.key, *opts.upload_id));
+ auto target_client = _client->GetBlockBlobClient(opts.key);
std::vector string_block_ids;
std::ranges::transform(
completed_parts, std::back_inserter(string_block_ids),
[&opts](const ObjectCompleteMultiPart& i) { return azure_block_id(opts, i.part_num); });
- return do_azure_client_call(
+ auto response = do_azure_client_call(
[&]() {
SCOPED_BVAR_LATENCY(s3_bvar::s3_multi_part_upload_latency);
- client.CommitBlockList(string_block_ids);
+ // Per-upload temporary blobs keep Azure's blob-wide staged-block namespace isolated.
+ temp_client.CommitBlockList(string_block_ids);
+ auto copy = target_client.StartCopyFromUri(temp_client.GetUrl());
+ copy.PollUntilDone(std::chrono::milliseconds(100));
},
opts, _tls_debug_context);
+ if (response.status.code != ErrorCode::OK) {
+ return response;
+ }
+ auto cleanup = do_azure_client_call([&]() { temp_client.Delete(); }, opts, _tls_debug_context);
+ if (cleanup.status.code != ErrorCode::OK &&
+ cleanup.http_code != static_cast(Azure::Core::Http::HttpStatusCode::NotFound)) {
+ LOG(WARNING) << "Azure multipart temporary blob cleanup failed after publication";
+ }
+ // Publication already succeeded; a cleanup failure must not turn a retry into a conflicting overwrite.
+ return response;
}
ObjectStorageResponse AzureObjStorageClient::abort_multipart_upload(
const ObjectStoragePathOptions& opts) {
- auto client = _client->GetBlockBlobClient(opts.key);
+ DCHECK(opts.upload_id.has_value());
+ auto client = _client->GetBlockBlobClient(azure_multipart_temp_key(opts.key, *opts.upload_id));
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);
+ // Never recommit the final blob: a legacy staged block may shadow a committed block ID.
+ client.Delete();
},
opts, _tls_debug_context);
// Azure creates no server-side object until the first block is staged, so absence is clean.
diff --git a/be/src/io/fs/azure_obj_storage_client.h b/be/src/io/fs/azure_obj_storage_client.h
index fe3cf01417aa3b..62968557490ab0 100644
--- a/be/src/io/fs/azure_obj_storage_client.h
+++ b/be/src/io/fs/azure_obj_storage_client.h
@@ -33,10 +33,7 @@ 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;
-}
+std::string azure_multipart_temp_key(std::string_view key, std::string_view upload_id);
class AzureObjStorageClient final : public ObjStorageClient {
public:
diff --git a/be/src/io/fs/obj_storage_client.h b/be/src/io/fs/obj_storage_client.h
index db326a931719f9..6ed2803c53e13b 100644
--- a/be/src/io/fs/obj_storage_client.h
+++ b/be/src/io/fs/obj_storage_client.h
@@ -45,6 +45,7 @@ struct ObjectStoragePathOptions {
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; // provider-specific upload token
+ bool deferred_completion = false; // another process commits the uploaded parts
};
struct ObjectCompleteMultiPart {
diff --git a/be/src/io/fs/s3_file_writer.cpp b/be/src/io/fs/s3_file_writer.cpp
index 43663afa6dfdf8..4d4f5d751cdf67 100644
--- a/be/src/io/fs/s3_file_writer.cpp
+++ b/be/src/io/fs/s3_file_writer.cpp
@@ -66,6 +66,7 @@ S3FileWriter::S3FileWriter(std::shared_ptr client, std::string
.key = std::move(key)}),
_used_by_s3_committer(opts ? opts->used_by_s3_committer : false),
_obj_client(std::move(client)) {
+ _obj_storage_path_opts.deferred_completion = _used_by_s3_committer;
s3_file_writer_total << 1;
s3_file_being_written << 1;
Aws::Http::SetCompliantRfc3986Encoding(true);
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
index 5d1fa473205f0b..62df6d4d210117 100644
--- a/be/test/exec/operator/spill_iceberg_table_sink_operator_test.cpp
+++ b/be/test/exec/operator/spill_iceberg_table_sink_operator_test.cpp
@@ -36,4 +36,10 @@ TEST(SpillIcebergTableSinkOperatorTest, AccumulatesRetainedGrowthAcrossTouchedPa
EXPECT_EQ(14 * 1024 * 1024, bounded_iceberg_reserve_size(per_partition_reservations));
}
+TEST(SpillIcebergTableSinkOperatorTest, ReservesIncomingBlockBeforeAnyPartitionWriterExists) {
+ std::vector no_published_sorters;
+
+ EXPECT_EQ(6 * 1024 * 1024, iceberg_reserve_size(no_published_sorters, 6 * 1024 * 1024));
+}
+
} // 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 88e81ea7fe6ed0..83671a9d997d59 100644
--- a/be/test/io/fs/azure_obj_storage_client_test.cpp
+++ b/be/test/io/fs/azure_obj_storage_client_test.cpp
@@ -34,10 +34,11 @@
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));
+TEST(AzureObjStorageClientMultipartHelperTest, isolates_uploads_with_temporary_blob_keys) {
+ EXPECT_EQ("table/file.parquet.__doris_multipart/upload-a",
+ io::azure_multipart_temp_key("table/file.parquet", "upload-a"));
+ EXPECT_NE(io::azure_multipart_temp_key("table/file.parquet", "upload-a"),
+ io::azure_multipart_temp_key("table/file.parquet", "upload-b"));
}
#ifdef USE_AZURE
@@ -216,6 +217,33 @@ TEST_F(AzureObjStorageClientTest, abort_multipart_upload_preserves_existing_put_
EXPECT_EQ(AzureObjStorageClientTest::obj_storage_client->delete_object(opts).status.code,
ErrorCode::OK);
}
+
+TEST_F(AzureObjStorageClientTest, concurrent_multipart_uploads_do_not_share_staged_blocks) {
+ io::ObjectStoragePathOptions first {.key = "AzureObjStorageClientTest/concurrent_multipart"};
+ io::ObjectStoragePathOptions second = first;
+ auto first_create = obj_storage_client->create_multipart_upload(first);
+ auto second_create = obj_storage_client->create_multipart_upload(second);
+ ASSERT_TRUE(first_create.upload_id.has_value());
+ ASSERT_TRUE(second_create.upload_id.has_value());
+ first.upload_id = first_create.upload_id;
+ second.upload_id = second_create.upload_id;
+
+ ASSERT_EQ(obj_storage_client->upload_part(first, "first", 1).resp.status.code, ErrorCode::OK);
+ ASSERT_EQ(obj_storage_client->upload_part(second, "second", 1).resp.status.code, ErrorCode::OK);
+ ASSERT_EQ(obj_storage_client->complete_multipart_upload(first, {{.part_num = 1}}).status.code,
+ ErrorCode::OK);
+ ASSERT_EQ(obj_storage_client->complete_multipart_upload(second, {{.part_num = 1}}).status.code,
+ ErrorCode::OK);
+
+ std::array contents {};
+ size_t size_return = 0;
+ ASSERT_EQ(obj_storage_client
+ ->get_object(second, contents.data(), 0, contents.size(), &size_return)
+ .status.code,
+ ErrorCode::OK);
+ EXPECT_EQ(std::string_view(contents.data(), size_return), "second");
+ EXPECT_EQ(obj_storage_client->delete_object(second).status.code, ErrorCode::OK);
+}
#else
class AzureObjStorageClientTest : public testing::Test {
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 9306f29bbcfd18..b4c2ce5111ba64 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
@@ -413,14 +413,14 @@ private void applyBeginGuards(IcebergWriteContext ctx, String tableName) {
// scan used, S_read), threaded onto the write handle and carried on the ctx. The commit-time
// removeDeletes (option D) re-derives from baseSnapshotId, and BE unions the scan-time (S_read)
// old deletes into the new DV — anchoring both at S_read keeps supply and remove on one snapshot
- // (no resurrection under a concurrent commit in the read->begin-write window).
+ // (no resurrection under a concurrent commit in the read->begin-write window). An explicitly pinned
+ // -1 is the empty-table generation and must remain an OCC fence; only an unpinned caller may fall
+ // back to the begin-time current snapshot.
long pinnedReadSnapshot = ctx.getReadSnapshotId();
- if (ctx.isReadSnapshotResolved()) {
- // An explicitly empty read stays null so validation covers a concurrent first append.
- this.baseSnapshotId = pinnedReadSnapshot >= 0 ? Long.valueOf(pinnedReadSnapshot) : null;
- } else {
- this.baseSnapshotId = getSnapshotIdIfPresent(table);
- }
+ // Keep both ternary arms boxed (Long): getSnapshotIdIfPresent returns null for an empty table
+ // (no snapshot), and a primitive arm would force-unbox that null into an NPE.
+ this.baseSnapshotId = ctx.isReadSnapshotResolved()
+ ? Long.valueOf(pinnedReadSnapshot) : getSnapshotIdIfPresent(table);
if (table instanceof HasTableOperations) {
int formatVersion = ((HasTableOperations) table).operations().current().formatVersion();
if (formatVersion < 2) {
diff --git a/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/action/IcebergRemoveOrphanFilesAction.java b/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/action/IcebergRemoveOrphanFilesAction.java
index 37226b21fde047..9c4dd71f61c5a4 100644
--- a/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/action/IcebergRemoveOrphanFilesAction.java
+++ b/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/action/IcebergRemoveOrphanFilesAction.java
@@ -44,7 +44,7 @@
import java.time.Duration;
import java.util.ArrayList;
import java.util.HashSet;
-import java.util.LinkedHashSet;
+import java.util.LinkedHashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
@@ -132,11 +132,7 @@ protected List executeAction(Table table, ConnectorSession session) {
}
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);
+ List ownedRoots = resolveOwnedRoots(table.location(), table.properties());
String requested = namedArguments.getString(LOCATION);
if (requested != null) {
@@ -149,18 +145,45 @@ private List resolveScanLocations(Table table) {
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 minimalOwnedRoots(ownedRoots);
+ }
+
+ static List resolveOwnedRoots(String tableLocation, Map properties) {
+ String tableRoot = normalizeLocation(tableLocation);
+ String dataRoot = normalizeLocation(resolveDataLocation(properties, tableRoot));
+ List configuredRoots = new ArrayList<>();
+ configuredRoots.add(tableRoot);
+ configuredRoots.add(dataRoot);
+ // Iceberg may place metadata outside both table and data roots, so it remains an independent owned root.
+ String metadataRoot = nonEmpty(properties.get(TableProperties.WRITE_METADATA_LOCATION));
+ if (metadataRoot != null) {
+ configuredRoots.add(normalizeLocation(metadataRoot));
+ }
+ return canonicalOwnedRoots(configuredRoots);
+ }
+
+ static List minimalOwnedRoots(List roots) {
+ List canonicalRoots = canonicalOwnedRoots(roots);
+ List minimal = new ArrayList<>();
+ for (String candidate : canonicalRoots) {
+ // Canonically equal aliases are deduplicated first, so only strict containment removes a root.
+ if (canonicalRoots.stream().noneMatch(other -> !sameFileIdentity(other, candidate)
+ && isWithinLocation(candidate, other))) {
+ minimal.add(candidate);
}
}
- return roots;
+ return minimal;
}
- private String resolveDataLocation(Table table, String tableRoot) {
- Map properties = table.properties();
+ private static List canonicalOwnedRoots(List roots) {
+ Map byIdentity = new LinkedHashMap<>();
+ for (String root : roots) {
+ byIdentity.putIfAbsent(FileIdentity.of(root), root);
+ }
+ return new ArrayList<>(byIdentity.values());
+ }
+
+ private static String resolveDataLocation(Map properties, String tableRoot) {
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));
@@ -171,11 +194,15 @@ private String resolveDataLocation(Table table, String tableRoot) {
return dataLocation == null ? tableRoot + "/data" : dataLocation;
}
- private String nonEmpty(String location) {
+ private static String nonEmpty(String location) {
return location == null || location.isEmpty() ? null : location;
}
private boolean isWithin(String location, String root) {
+ return isWithinLocation(location, root);
+ }
+
+ private static boolean isWithinLocation(String location, String root) {
FileIdentity child = FileIdentity.of(location);
FileIdentity parent = FileIdentity.of(root);
String pathPrefix = parent.path.endsWith("/") ? parent.path : parent.path + "/";
@@ -300,7 +327,7 @@ private ReachableIndex(Set locations) {
}
}
- private String normalizeLocation(String location) {
+ private static String normalizeLocation(String location) {
String normalized = URI.create(location).normalize().toString();
return normalized.length() > 1 && normalized.endsWith("/")
? normalized.substring(0, normalized.length() - 1) : normalized;
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 0df759d7a215a9..0219b12416abbd 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
@@ -164,12 +164,12 @@ private static IcebergWriteContext rewriteCtx() {
private static IcebergWriteContext deleteCtxPinned(long readSnapshotId) {
return new IcebergWriteContext(
- WriteOperation.DELETE, false, Collections.emptyMap(), Optional.empty(), readSnapshotId);
+ WriteOperation.DELETE, false, Collections.emptyMap(), Optional.empty(), readSnapshotId, true);
}
private static IcebergWriteContext mergeCtxPinned(long readSnapshotId) {
return new IcebergWriteContext(
- WriteOperation.MERGE, false, Collections.emptyMap(), Optional.empty(), readSnapshotId);
+ WriteOperation.MERGE, false, Collections.emptyMap(), Optional.empty(), readSnapshotId, true);
}
/**
@@ -522,6 +522,22 @@ public void beginMergeHonorsPinnedReadSnapshotOverCurrent() {
"MERGE must anchor baseSnapshotId at the pinned read snapshot, not the current snapshot");
}
+ @Test
+ public void beginDeletePreservesExplicitEmptyReadSnapshot() {
+ InMemoryCatalog catalog = freshCatalog();
+ TableIdentifier id = TableIdentifier.of("db1", "t1");
+ Table table = catalog.createTable(id, SCHEMA, PartitionSpec.unpartitioned(), props("format-version", "2"));
+ table.newAppend().appendFile(
+ dataFile(table.spec(), "s3://b/db1/t1/concurrent.parquet", 1L)).commit();
+
+ IcebergConnectorTransaction txn = txnFor(
+ opsReturning(catalog.loadTable(id)), new RecordingConnectorContext());
+ txn.beginWrite(SESSION, "db1", "t1", deleteCtxPinned(-1L));
+
+ Assertions.assertEquals(Long.valueOf(-1L), txn.getBaseSnapshotId(),
+ "an explicit empty read is an OCC fence, not an absent pin");
+ }
+
@Test
public void beginInsertDoesNotCaptureBaseSnapshotId() {
InMemoryCatalog catalog = freshCatalog();
@@ -1046,6 +1062,23 @@ public void mergeFromResolvedEmptySnapshotRejectsConcurrentFirstAppend() throws
committedFiles.get(0).path().toString());
}
+ @Test
+ public void deleteFromExplicitEmptySnapshotDetectsFirstConcurrentCommit() {
+ InMemoryCatalog catalog = freshCatalog();
+ TableIdentifier id = TableIdentifier.of("db1", "t1");
+ Table table = catalog.createTable(id, SCHEMA, PartitionSpec.unpartitioned(), props("format-version", "2"));
+ IcebergConnectorTransaction txn = txnFor(opsReturning(table), new RecordingConnectorContext());
+ txn.beginWrite(SESSION, "db1", "t1", deleteCtxPinned(-1L));
+
+ catalog.loadTable(id).newAppend().appendFile(
+ dataFile(table.spec(), "s3://b/db1/t1/concurrent.parquet", 7L)).commit();
+ txn.addCommitData(commitBytes(positionDeleteItem(
+ "s3://b/db1/t1/del.parquet", 1L, "s3://b/db1/t1/concurrent.parquet")));
+
+ Assertions.assertThrows(DorisConnectorException.class, txn::commit,
+ "validateFromSnapshot(-1) must reject the first snapshot committed after an empty read");
+ }
+
@Test
public void deletePassesValidationSuiteWhenNoConcurrentChange() {
InMemoryCatalog catalog = freshCatalog();
diff --git a/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/IcebergScanPlanProviderTest.java b/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/IcebergScanPlanProviderTest.java
index 26fa913062afea..d2d963e6829d9e 100644
--- a/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/IcebergScanPlanProviderTest.java
+++ b/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/IcebergScanPlanProviderTest.java
@@ -319,6 +319,23 @@ public void planScanEnumeratesOneRangePerDataFile() {
Assertions.assertEquals(2048L, ranges.get(1).getLength());
}
+ @Test
+ public void explicitEmptySnapshotDoesNotDriftToFirstConcurrentCommit() {
+ Table table = createTable("t1", SCHEMA, PartitionSpec.unpartitioned());
+ IcebergTableHandle emptyPin = new IcebergTableHandle("db1", "t1")
+ .withSnapshot(-1L, null, -1L);
+ table.newAppend().appendFile(
+ dataFile(table.spec(), "s3://b/db/t1/concurrent.parquet", 1024, null, null)).commit();
+ IcebergScanPlanProvider provider = new IcebergScanPlanProvider(
+ Collections.emptyMap(), opsReturning(table));
+
+ List ranges = provider.planScan(null,
+ ConnectorScanRequest.builder(emptyPin, Collections.emptyList()).build());
+
+ Assertions.assertTrue(ranges.isEmpty(),
+ "an explicit empty MVCC pin must not be replaced by the table's first snapshot");
+ }
+
@Test
public void planScanRewriteFileScopeKeepsOnlyRawScopedFiles() {
Table table = createTable("t1", SCHEMA, PartitionSpec.unpartitioned());
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
index 05d3dc1ae4e048..f4aa7b126a52b4 100644
--- 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
@@ -103,6 +103,24 @@ public void treatsS3SchemeAliasesAsTheSameFile() {
"s3n://bucket/path/data.parquet", "s3://BUCKET/path/data.parquet"));
}
+ @Test
+ public void canonicalAliasRootsCollapseToOneOwnedScanRoot() {
+ Assertions.assertEquals(Collections.singletonList("s3a://bucket/table"),
+ IcebergRemoveOrphanFilesAction.minimalOwnedRoots(
+ java.util.List.of("s3a://bucket/table", "s3://BUCKET/table")));
+ }
+
+ @Test
+ public void configuredDataAndMetadataLocationsAreSeparateOwnedRoots() {
+ Map properties = new HashMap<>();
+ properties.put(TableProperties.WRITE_DATA_LOCATION, "s3://bucket/data-root");
+ properties.put(TableProperties.WRITE_METADATA_LOCATION, "s3://bucket/metadata-root");
+
+ Assertions.assertEquals(java.util.List.of(
+ "s3://bucket/table-root", "s3://bucket/data-root", "s3://bucket/metadata-root"),
+ IcebergRemoveOrphanFilesAction.resolveOwnedRoots("s3://bucket/table-root", properties));
+ }
+
@Test
public void rejectsUnresolvedPrefixMismatches() {
Assertions.assertThrows(DorisConnectorException.class,
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 197b6c64a7851e..1772f5c72b8dca 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
@@ -56,6 +56,7 @@
import java.util.List;
import java.util.Map;
import java.util.NoSuchElementException;
+import java.util.UUID;
/**
* Azure Blob Storage implementation of {@link ObjStorage}.
@@ -219,8 +220,7 @@ public void copyObject(String srcPath, String dstPath) throws IOException {
@Override
public String initiateMultipartUpload(String remotePath) throws IOException {
// Azure block blobs don't have an explicit "initiate" API.
- // Return the path itself as the upload ID; block IDs are derived from part numbers.
- return remotePath;
+ return UUID.randomUUID().toString();
}
@Override
@@ -229,7 +229,7 @@ public UploadPartResult uploadPart(String remotePath, String uploadId, int partN
try {
AzureUri uri = AzureUri.parse(remotePath);
BlockBlobClient blockBlobClient = getClient().getBlobContainerClient(uri.container())
- .getBlobClient(uri.key()).getBlockBlobClient();
+ .getBlobClient(multipartTempKey(uri.key(), uploadId)).getBlockBlobClient();
String blockId = toBlockId(partNum);
blockBlobClient.stageBlock(blockId, body.content(), body.contentLength());
return new UploadPartResult(partNum, blockId);
@@ -244,17 +244,31 @@ public void completeMultipartUpload(String remotePath, String uploadId,
List parts) throws IOException {
try {
AzureUri uri = AzureUri.parse(remotePath);
- BlockBlobClient blockBlobClient = getClient().getBlobContainerClient(uri.container())
- .getBlobClient(uri.key()).getBlockBlobClient();
+ BlobContainerClient containerClient = getClient().getBlobContainerClient(uri.container());
List blockIds = new ArrayList<>();
List sorted = new ArrayList<>(parts);
sorted.sort((a, b) -> Integer.compare(a.partNumber(), b.partNumber()));
+ boolean exactBlockIds = !sorted.isEmpty() && sorted.stream()
+ .allMatch(part -> part.etag() != null && !part.etag().isEmpty());
for (UploadPartResult part : sorted) {
- // 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());
+ // A mixed-version upload must use one namespace consistently; new BEs also stage legacy IDs.
+ blockIds.add(exactBlockIds ? part.etag() : toBlockId(part.partNumber()));
+ }
+ String commitKey = exactBlockIds ? multipartTempKey(uri.key(), uploadId) : uri.key();
+ BlobClient commitBlob = containerClient.getBlobClient(commitKey);
+ commitBlob.getBlockBlobClient().commitBlockList(blockIds);
+ if (exactBlockIds) {
+ BlobClient targetBlob = containerClient.getBlobClient(uri.key());
+ // The temporary blob is the provider-visible writer fence; only a completed copy publishes it.
+ targetBlob.beginCopy(commitBlob.getBlobUrl(), null).waitForCompletion();
+ try {
+ commitBlob.delete();
+ } catch (BlobStorageException cleanupFailure) {
+ // Publication already succeeded; cleanup must not make a retry overwrite a newer writer.
+ LOG.warn("Azure multipart temporary blob cleanup failed after publication: {}",
+ cleanupFailure.getMessage());
+ }
}
- blockBlobClient.commitBlockList(blockIds);
} catch (BlobStorageException e) {
throw new IOException("completeMultipartUpload failed for " + remotePath
+ ": " + e.getMessage(), e);
@@ -263,35 +277,12 @@ public void completeMultipartUpload(String remotePath, String uploadId,
@Override
public void abortMultipartUpload(String remotePath, String uploadId) throws IOException {
- // Azure has no native "abort multipart upload" API; the closest equivalent is to
- // commit an empty block list (which atomically discards any uncommitted blocks
- // for that blob) and then delete the resulting empty blob so no trace remains.
- //
- // SAFETY: commitBlockList(empty) overwrites whatever is at the target blob, so we
- // MUST refuse to run when a committed blob already exists at this path — otherwise
- // an abort call could destroy real user data. In that case the staged blocks are
- // left to expire on their own (Azure GCs them after the service-side timeout).
try {
AzureUri uri = AzureUri.parse(remotePath);
- BlockBlobClient blockBlobClient = getClient().getBlobContainerClient(uri.container())
- .getBlobClient(uri.key()).getBlockBlobClient();
- boolean committedBlobExists;
- try {
- blockBlobClient.getProperties();
- committedBlobExists = true;
- } catch (BlobStorageException e) {
- if (e.getStatusCode() != HTTP_NOT_FOUND) {
- throw e;
- }
- committedBlobExists = false;
- }
- if (committedBlobExists) {
- LOG.warn("abortMultipartUpload skipped for {}: a committed blob already exists; "
- + "uncommitted blocks will expire automatically.", remotePath);
- return;
- }
- blockBlobClient.commitBlockList(Collections.emptyList());
- blockBlobClient.delete();
+ // Abort is scoped to this writer's temporary blob. Legacy final-blob blocks are left to expire,
+ // because recommitting their IDs can select a staged replacement for committed user data.
+ getClient().getBlobContainerClient(uri.container())
+ .getBlobClient(multipartTempKey(uri.key(), uploadId)).deleteIfExists();
} catch (BlobStorageException e) {
// Best-effort: log and swallow rather than mask the original failure that
// triggered the abort path. Uncommitted blocks will be GC'd by the service.
@@ -526,4 +517,8 @@ private static String toBlockId(int partNum) {
byte[] bytes = ByteBuffer.allocate(4).order(ByteOrder.LITTLE_ENDIAN).putInt(partNum).array();
return Base64.getEncoder().encodeToString(bytes);
}
+
+ static String multipartTempKey(String key, String uploadId) {
+ return key + ".__doris_multipart/" + uploadId;
+ }
}
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 b6a0bc6f973abb..4e687917e034c4 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
@@ -379,14 +379,30 @@ void deleteObjectsByKeys_attachesPerKeyExceptionsAsSuppressed() throws Exception
// F20 — abortMultipartUpload safe-noop / commit-empty behaviour
// ------------------------------------------------------------------
+ @Test
+ void multipartTempKey_isolatesSameTargetWriters() {
+ Assertions.assertEquals("stage/blob.__doris_multipart/upload-a",
+ AzureObjStorage.multipartTempKey("stage/blob", "upload-a"));
+ Assertions.assertNotEquals(
+ AzureObjStorage.multipartTempKey("stage/blob", "upload-a"),
+ AzureObjStorage.multipartTempKey("stage/blob", "upload-b"));
+ }
+
@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);
+ BlobClient tempBlob = Mockito.mock(BlobClient.class);
+ Mockito.when(tempBlob.getBlockBlobClient()).thenReturn(blockClient);
+ Mockito.when(tempBlob.getBlobUrl()).thenReturn("https://account/container/temp");
+ BlobClient targetBlob = Mockito.mock(BlobClient.class);
+ com.azure.core.util.polling.SyncPoller poller =
+ Mockito.mock(com.azure.core.util.polling.SyncPoller.class);
+ Mockito.when(targetBlob.beginCopy("https://account/container/temp", null)).thenReturn(poller);
BlobContainerClient containerClient = Mockito.mock(BlobContainerClient.class);
- Mockito.when(containerClient.getBlobClient("stage/blob")).thenReturn(blobClient);
+ Mockito.when(containerClient.getBlobClient(
+ "stage/blob.__doris_multipart/be-generated-upload-id")).thenReturn(tempBlob);
+ Mockito.when(containerClient.getBlobClient("stage/blob")).thenReturn(targetBlob);
BlobServiceClient serviceClient = Mockito.mock(BlobServiceClient.class);
Mockito.when(serviceClient.getBlobContainerClient("mycontainer")).thenReturn(containerClient);
TestableAzureObjStorage storage = new TestableAzureObjStorage(buildBasicProps(), serviceClient);
@@ -400,6 +416,8 @@ void completeMultipartUpload_usesExactBlockIdsReportedByBe() throws Exception {
new UploadPartResult(1, firstBlockId)));
Mockito.verify(blockClient).commitBlockList(Arrays.asList(firstBlockId, secondBlockId));
+ Mockito.verify(poller).waitForCompletion();
+ Mockito.verify(tempBlob).delete();
}
@Test
@@ -422,47 +440,33 @@ void completeMultipartUpload_fallsBackForOlderBeWithoutBlockIds() throws Excepti
}
@Test
- void abortMultipartUpload_safeNoopWhenCommittedBlobExists() throws Exception {
- com.azure.storage.blob.models.BlobProperties props =
- Mockito.mock(com.azure.storage.blob.models.BlobProperties.class);
- Mockito.when(props.getBlobSize()).thenReturn(1024L);
-
+ void completeMultipartUpload_usesLegacyNamespaceWhenAnyBlockIdIsMissing() throws Exception {
com.azure.storage.blob.specialized.BlockBlobClient blockClient =
Mockito.mock(com.azure.storage.blob.specialized.BlockBlobClient.class);
- Mockito.when(blockClient.getProperties()).thenReturn(props);
-
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.abortMultipartUpload(
- "wasb://mycontainer@myaccount.blob.core.windows.net/stage/blob", "uploadId");
+ storage.completeMultipartUpload(
+ "wasb://mycontainer@myaccount.blob.core.windows.net/stage/blob",
+ "mixed-upload-id",
+ Arrays.asList(new UploadPartResult(1, "exact-id"), new UploadPartResult(2, "")));
- // The committed blob must NOT be touched (no commitBlockList, no delete).
- Mockito.verify(blockClient, Mockito.never()).commitBlockList(Mockito.anyList());
- Mockito.verify(blockClient, Mockito.never()).delete();
+ Mockito.verify(blockClient).commitBlockList(Arrays.asList("AQAAAA==", "AgAAAA=="));
+ Mockito.verify(containerClient, Mockito.never()).getBlobClient(
+ "stage/blob.__doris_multipart/mixed-upload-id");
}
@Test
- void abortMultipartUpload_commitsEmptyAndDeletesWhenNoCommittedBlob() throws Exception {
- com.azure.storage.blob.specialized.BlockBlobClient blockClient =
- Mockito.mock(com.azure.storage.blob.specialized.BlockBlobClient.class);
- BlobStorageException notFoundEx = Mockito.mock(BlobStorageException.class);
- Mockito.when(notFoundEx.getStatusCode()).thenReturn(404);
- Mockito.when(blockClient.getProperties()).thenThrow(notFoundEx);
-
- BlobClient blobClient = Mockito.mock(BlobClient.class);
- Mockito.when(blobClient.getBlockBlobClient()).thenReturn(blockClient);
-
+ void abortMultipartUpload_deletesOnlyWriterTemporaryBlob() throws Exception {
+ BlobClient tempBlob = Mockito.mock(BlobClient.class);
BlobContainerClient containerClient = Mockito.mock(BlobContainerClient.class);
- Mockito.when(containerClient.getBlobClient("stage/blob")).thenReturn(blobClient);
+ Mockito.when(containerClient.getBlobClient(
+ "stage/blob.__doris_multipart/uploadId")).thenReturn(tempBlob);
BlobServiceClient serviceClient = Mockito.mock(BlobServiceClient.class);
Mockito.when(serviceClient.getBlobContainerClient("mycontainer")).thenReturn(containerClient);
@@ -472,8 +476,8 @@ void abortMultipartUpload_commitsEmptyAndDeletesWhenNoCommittedBlob() throws Exc
storage.abortMultipartUpload(
"wasb://mycontainer@myaccount.blob.core.windows.net/stage/blob", "uploadId");
- Mockito.verify(blockClient).commitBlockList(Collections.emptyList());
- Mockito.verify(blockClient).delete();
+ Mockito.verify(tempBlob).deleteIfExists();
+ Mockito.verify(containerClient, Mockito.never()).getBlobClient("stage/blob");
}
// ------------------------------------------------------------------
From 25f5e23e9b9985e7b23f2c58c3b186dca56369e2 Mon Sep 17 00:00:00 2001
From: Gabriel
Date: Sun, 2 Aug 2026 16:56:21 +0800
Subject: [PATCH 06/29] [fix](iceberg) Address remaining write safety feedback
Issue Number: None
Related PR: #66348
Problem Summary: The latest review found that async memory admission ended before queued writes, Azure temporary-copy publication and its compatibility mirror were unsafe for same-key writers, BE did not know FE smaller Thrift limit, branch writes could lose the exact MVCC pin, and orphan cleanup assumed ownership for layouts it could not prove. Transfer reservations across the async queue, publish Azure block lists atomically on the target, negotiate the receiver limit, bind branch-aware pins after the target branch is known, and use ownership-aware bounded orphan scans.
Harden Iceberg async write admission, Azure multipart publication, and orphan-file cleanup safety.
- Test: Unit Test
- BE targeted unit tests: 7 passed.
- FE targeted unit tests: 37 passed; all 59 reactor modules built successfully.
- Azure-enabled production and test translation units compiled successfully.
- Behavior changed: Yes. Unsafe orphan scan roots now require an explicit guarded override, and conflicting Azure same-key multipart writers fail closed instead of mixing data.
- Does this need documentation: No
---
.../spill_iceberg_table_sink_operator.cpp | 3 +-
.../exec/sink/writer/async_result_writer.cpp | 37 ++-
be/src/exec/sink/writer/async_result_writer.h | 11 +-
be/src/io/fs/azure_obj_storage_client.cpp | 74 ++----
be/src/io/fs/azure_obj_storage_client.h | 2 +-
be/src/io/fs/obj_storage_client.h | 1 -
be/src/io/fs/s3_file_writer.cpp | 1 -
.../runtime/memory/thread_mem_tracker_mgr.cpp | 40 +++
.../runtime/memory/thread_mem_tracker_mgr.h | 40 +++
be/src/runtime/runtime_state.cpp | 9 +-
.../io/fs/azure_obj_storage_client_test.cpp | 22 +-
.../memory/thread_mem_tracker_mgr_test.cpp | 33 +++
.../runtime_state_block_budget_test.cpp | 14 +
.../IcebergRemoveOrphanFilesAction.java | 251 +++++++++++-------
.../IcebergRemoveOrphanFilesActionTest.java | 157 ++++++++---
.../translator/PhysicalPlanTranslator.java | 18 +-
.../doris/planner/PluginDrivenTableSink.java | 50 +++-
.../org/apache/doris/qe/SessionVariable.java | 2 +
...lPlanTranslatorIcebergRowLevelDmlTest.java | 25 +-
.../PluginDrivenTableSinkBindingTest.java | 42 +++
.../apache/doris/qe/SessionVariablesTest.java | 9 +
.../filesystem/azure/AzureObjStorage.java | 44 +--
.../azure/AzureObjStorageExtensionTest.java | 30 +--
gensrc/thrift/PaloInternalService.thrift | 2 +
24 files changed, 623 insertions(+), 294 deletions(-)
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 1fde8f882b280e..75cb06e62ef74a 100644
--- a/be/src/exec/operator/spill_iceberg_table_sink_operator.cpp
+++ b/be/src/exec/operator/spill_iceberg_table_sink_operator.cpp
@@ -67,8 +67,9 @@ size_t SpillIcebergTableSinkLocalState::get_reserve_mem_size(RuntimeState* state
}
}
// Column growth remains in every touched sorter, while sorting workspace is reused by serial dispatch.
+ // The final queued item may contain rows and also owns the reservation used by async finish().
return iceberg_reserve_size(per_partition_reservations,
- eos ? 0 : state->minimum_operator_memory_required_bytes());
+ state->minimum_operator_memory_required_bytes());
}
size_t SpillIcebergTableSinkLocalState::get_revocable_mem_size(RuntimeState* state) const {
diff --git a/be/src/exec/sink/writer/async_result_writer.cpp b/be/src/exec/sink/writer/async_result_writer.cpp
index 206f6adc8445d7..4bce381dfd7c31 100644
--- a/be/src/exec/sink/writer/async_result_writer.cpp
+++ b/be/src/exec/sink/writer/async_result_writer.cpp
@@ -44,6 +44,8 @@ Status AsyncResultWriter::sink(Block* block, bool eos) {
add_block = _get_free_block(block, rows);
}
+ // The pipeline reservation protects allocations performed after this block is dequeued.
+ auto reservation = thread_context()->thread_mem_tracker_mgr->take_reserved_memory();
std::lock_guard l(_m);
// if io task failed, just return error status to
// end the query
@@ -55,9 +57,12 @@ Status AsyncResultWriter::sink(Block* block, bool eos) {
if (_is_finished()) {
_dependency->set_ready();
}
- if (rows) {
- _memory_used_counter->update(add_block->allocated_bytes());
- _data_queue.emplace_back(std::move(add_block));
+ if (rows || eos) {
+ if (rows) {
+ _memory_used_counter->update(add_block->allocated_bytes());
+ }
+ _data_queue.emplace_back(QueuedBlock {
+ .block = std::move(add_block), .reservation = std::move(reservation), .eos = eos});
if (!_data_queue_is_available() && !_is_finished()) {
_dependency->block();
}
@@ -71,17 +76,19 @@ Status AsyncResultWriter::sink(Block* block, bool eos) {
return Status::OK();
}
-std::unique_ptr AsyncResultWriter::_get_block_from_queue() {
+AsyncResultWriter::QueuedBlock AsyncResultWriter::_get_block_from_queue() {
std::lock_guard l(_m);
DCHECK(!_data_queue.empty());
- auto block = std::move(_data_queue.front());
+ auto queued = std::move(_data_queue.front());
_data_queue.pop_front();
DCHECK(_dependency);
if (_data_queue_is_available()) {
_dependency->set_ready();
}
- _memory_used_counter->update(-block->allocated_bytes());
- return block;
+ if (queued.block) {
+ _memory_used_counter->update(-queued.block->allocated_bytes());
+ }
+ return queued;
}
Status AsyncResultWriter::start_writer(RuntimeState* state, RuntimeProfile* operator_profile) {
@@ -165,9 +172,12 @@ void AsyncResultWriter::process_block(RuntimeState* state, RuntimeProfile* opera
}
//2) get the block from data queue and write to downstream
- auto block = _get_block_from_queue();
- auto status = write(state, *block);
+ auto queued = _get_block_from_queue();
+ thread_context()->thread_mem_tracker_mgr->adopt_reserved_memory(
+ std::move(queued.reservation));
+ Status status = queued.block ? write(state, *queued.block) : Status::OK();
if (!status.ok()) [[unlikely]] {
+ thread_context()->thread_mem_tracker_mgr->shrink_reserved();
std::unique_lock l(_m);
_writer_status.update(status);
if (_is_finished()) {
@@ -176,7 +186,14 @@ void AsyncResultWriter::process_block(RuntimeState* state, RuntimeProfile* opera
break;
}
- _return_free_block(std::move(block));
+ if (queued.block) {
+ _return_free_block(std::move(queued.block));
+ }
+ if (queued.eos) {
+ // Keep the final reservation through finish(), where buffered sorters are committed.
+ break;
+ }
+ thread_context()->thread_mem_tracker_mgr->shrink_reserved();
}
bool need_finish = false;
diff --git a/be/src/exec/sink/writer/async_result_writer.h b/be/src/exec/sink/writer/async_result_writer.h
index 99d4f8eaa59eff..516201ebd818e9 100644
--- a/be/src/exec/sink/writer/async_result_writer.h
+++ b/be/src/exec/sink/writer/async_result_writer.h
@@ -23,6 +23,7 @@
#include "exec/sink/writer/result_writer.h"
#include "exprs/vexpr_fwd.h"
+#include "runtime/memory/thread_mem_tracker_mgr.h"
#include "runtime/runtime_profile.h"
namespace doris {
@@ -77,18 +78,24 @@ class AsyncResultWriter : public ResultWriter {
std::unique_ptr _get_free_block(Block*, size_t rows);
private:
+ struct QueuedBlock {
+ std::unique_ptr block;
+ ReservedMemoryToken reservation;
+ bool eos = false;
+ };
+
void process_block(RuntimeState* state, RuntimeProfile* operator_profile);
[[nodiscard]] bool _data_queue_is_available() const { return _data_queue.size() < QUEUE_SIZE; }
[[nodiscard]] bool _is_finished() const { return !_writer_status.ok() || _eos; }
void _set_ready_to_finish();
void _return_free_block(std::unique_ptr);
- std::unique_ptr _get_block_from_queue();
+ QueuedBlock _get_block_from_queue();
static constexpr auto QUEUE_SIZE = 3;
std::mutex _m;
std::condition_variable _cv;
- std::deque> _data_queue;
+ std::deque _data_queue;
// Default value is ok
AtomicStatus _writer_status;
bool _eos = false;
diff --git a/be/src/io/fs/azure_obj_storage_client.cpp b/be/src/io/fs/azure_obj_storage_client.cpp
index a870193deeefe7..7396c355a0ca70 100644
--- a/be/src/io/fs/azure_obj_storage_client.cpp
+++ b/be/src/io/fs/azure_obj_storage_client.cpp
@@ -21,7 +21,6 @@
#include
#include
-#include
#include
#include
#include
@@ -35,7 +34,6 @@
#include
#include
#include
-#include
#include
#include
#include
@@ -66,25 +64,14 @@ std::string to_lower_ascii(std::string_view input) {
return lowered;
}
-std::string azure_block_id(const doris::io::ObjectStoragePathOptions& opts, int part_num) {
- DCHECK(opts.upload_id.has_value());
+std::string encode_azure_block_id(std::string_view upload_id, int part_num) {
// 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);
+ std::string raw_id = fmt::format("{}:{:010}", upload_id, part_num);
Aws::Utils::ByteBuffer bytes(reinterpret_cast(raw_id.data()),
raw_id.size());
return Aws::Utils::HashingUtils::Base64Encode(bytes);
}
-std::string legacy_azure_block_id(int part_num) {
- std::array bytes {
- static_cast(part_num & 0xff),
- static_cast((part_num >> 8) & 0xff),
- static_cast((part_num >> 16) & 0xff),
- static_cast((part_num >> 24) & 0xff)};
- Aws::Utils::ByteBuffer buffer(bytes.data(), bytes.size());
- return Aws::Utils::HashingUtils::Base64Encode(buffer);
-}
-
// Rate limiting is applied by RateLimitedObjStorageClient, the decorator that
// S3ClientFactory wraps around this client when the bucket is subject to limiting.
@@ -94,8 +81,8 @@ constexpr char BlobNotFound[] = "BlobNotFound";
namespace doris::io {
-std::string azure_multipart_temp_key(std::string_view key, std::string_view upload_id) {
- return fmt::format("{}.__doris_multipart/{}", key, upload_id);
+std::string azure_multipart_block_id(std::string_view upload_id, int part_num) {
+ return encode_azure_block_id(upload_id, part_num);
}
// As Azure's doc said, the batch size is 256
@@ -238,22 +225,16 @@ ObjectStorageUploadResponse AzureObjStorageClient::upload_part(const ObjectStora
std::string_view stream,
int part_num) {
DCHECK(opts.upload_id.has_value());
- auto client = _client->GetBlockBlobClient(azure_multipart_temp_key(opts.key, *opts.upload_id));
- std::string block_id = azure_block_id(opts, part_num);
+ auto client = _client->GetBlockBlobClient(opts.key);
+ std::string block_id = azure_multipart_block_id(*opts.upload_id, 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);
+ // Upload-scoped IDs make a conflicting writer fail closed instead of selecting its blocks.
client.StageBlock(block_id, memory_body);
- if (opts.deferred_completion) {
- // During rolling upgrades an old FE still commits deterministic IDs on the final blob.
- Azure::Core::IO::MemoryBodyStream legacy_body(
- reinterpret_cast(stream.data()), stream.size());
- _client->GetBlockBlobClient(opts.key).StageBlock(
- legacy_azure_block_id(part_num), legacy_body);
- }
},
opts, _tls_debug_context);
return ObjectStorageUploadResponse {
@@ -267,48 +248,27 @@ ObjectStorageResponse AzureObjStorageClient::complete_multipart_upload(
const ObjectStoragePathOptions& opts,
const std::vector& completed_parts) {
DCHECK(opts.upload_id.has_value());
- auto temp_client =
- _client->GetBlockBlobClient(azure_multipart_temp_key(opts.key, *opts.upload_id));
auto target_client = _client->GetBlockBlobClient(opts.key);
std::vector string_block_ids;
- std::ranges::transform(
- completed_parts, std::back_inserter(string_block_ids),
- [&opts](const ObjectCompleteMultiPart& i) { return azure_block_id(opts, i.part_num); });
- auto response = do_azure_client_call(
+ std::ranges::transform(completed_parts, std::back_inserter(string_block_ids),
+ [&opts](const ObjectCompleteMultiPart& i) {
+ return azure_multipart_block_id(*opts.upload_id, i.part_num);
+ });
+ return do_azure_client_call(
[&]() {
SCOPED_BVAR_LATENCY(s3_bvar::s3_multi_part_upload_latency);
- // Per-upload temporary blobs keep Azure's blob-wide staged-block namespace isolated.
- temp_client.CommitBlockList(string_block_ids);
- auto copy = target_client.StartCopyFromUri(temp_client.GetUrl());
- copy.PollUntilDone(std::chrono::milliseconds(100));
+ // Put Block List atomically replaces the committed blob; no scan-visible staging blob exists.
+ target_client.CommitBlockList(string_block_ids);
},
opts, _tls_debug_context);
- if (response.status.code != ErrorCode::OK) {
- return response;
- }
- auto cleanup = do_azure_client_call([&]() { temp_client.Delete(); }, opts, _tls_debug_context);
- if (cleanup.status.code != ErrorCode::OK &&
- cleanup.http_code != static_cast(Azure::Core::Http::HttpStatusCode::NotFound)) {
- LOG(WARNING) << "Azure multipart temporary blob cleanup failed after publication";
- }
- // Publication already succeeded; a cleanup failure must not turn a retry into a conflicting overwrite.
- return response;
}
ObjectStorageResponse AzureObjStorageClient::abort_multipart_upload(
const ObjectStoragePathOptions& opts) {
DCHECK(opts.upload_id.has_value());
- auto client = _client->GetBlockBlobClient(azure_multipart_temp_key(opts.key, *opts.upload_id));
- auto response = do_azure_client_call(
- [&]() {
- // Never recommit the final blob: a legacy staged block may shadow a committed block ID.
- client.Delete();
- },
- 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;
+ // Azure cannot delete one upload's uncommitted blocks without changing the committed blob.
+ // Leaving them to service GC preserves the last successfully published value.
+ return ObjectStorageResponse::OK();
}
ObjectStorageHeadResponse AzureObjStorageClient::head_object(const ObjectStoragePathOptions& opts) {
diff --git a/be/src/io/fs/azure_obj_storage_client.h b/be/src/io/fs/azure_obj_storage_client.h
index 62968557490ab0..de4ea5459a7cf7 100644
--- a/be/src/io/fs/azure_obj_storage_client.h
+++ b/be/src/io/fs/azure_obj_storage_client.h
@@ -33,7 +33,7 @@ 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);
-std::string azure_multipart_temp_key(std::string_view key, std::string_view upload_id);
+std::string azure_multipart_block_id(std::string_view upload_id, int part_num);
class AzureObjStorageClient final : public ObjStorageClient {
public:
diff --git a/be/src/io/fs/obj_storage_client.h b/be/src/io/fs/obj_storage_client.h
index 6ed2803c53e13b..db326a931719f9 100644
--- a/be/src/io/fs/obj_storage_client.h
+++ b/be/src/io/fs/obj_storage_client.h
@@ -45,7 +45,6 @@ struct ObjectStoragePathOptions {
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; // provider-specific upload token
- bool deferred_completion = false; // another process commits the uploaded parts
};
struct ObjectCompleteMultiPart {
diff --git a/be/src/io/fs/s3_file_writer.cpp b/be/src/io/fs/s3_file_writer.cpp
index 4d4f5d751cdf67..43663afa6dfdf8 100644
--- a/be/src/io/fs/s3_file_writer.cpp
+++ b/be/src/io/fs/s3_file_writer.cpp
@@ -66,7 +66,6 @@ S3FileWriter::S3FileWriter(std::shared_ptr client, std::string
.key = std::move(key)}),
_used_by_s3_committer(opts ? opts->used_by_s3_committer : false),
_obj_client(std::move(client)) {
- _obj_storage_path_opts.deferred_completion = _used_by_s3_committer;
s3_file_writer_total << 1;
s3_file_being_written << 1;
Aws::Http::SetCompliantRfc3986Encoding(true);
diff --git a/be/src/runtime/memory/thread_mem_tracker_mgr.cpp b/be/src/runtime/memory/thread_mem_tracker_mgr.cpp
index 5de03560c289d2..f86e155f9acd44 100644
--- a/be/src/runtime/memory/thread_mem_tracker_mgr.cpp
+++ b/be/src/runtime/memory/thread_mem_tracker_mgr.cpp
@@ -27,6 +27,46 @@
namespace doris {
+void ReservedMemoryToken::release() {
+ if (_bytes == 0 && _untracked_bytes == 0) {
+ return;
+ }
+ // A queued item may be discarded after an async failure; its reservation still needs full rollback.
+ GlobalMemoryArbitrator::shrink_process_reserved(_bytes + _untracked_bytes);
+ _limiter_tracker->shrink_reserved(_bytes + _untracked_bytes);
+ _limiter_tracker->release(_bytes);
+ if (auto wg = _wg_wptr.lock()) {
+ wg->sub_wg_refresh_interval_memory_growth(_bytes);
+ }
+ _bytes = 0;
+ _untracked_bytes = 0;
+}
+
+ReservedMemoryToken ThreadMemTrackerMgr::take_reserved_memory() {
+ CHECK(init());
+ if (_reserved_mem == 0) {
+ return {};
+ }
+ ReservedMemoryToken token(_limiter_tracker_sptr, _wg_wptr, _reserved_mem, _untracked_mem);
+ // Accounting remains reserved globally; only its thread-local ownership moves into the token.
+ _reserved_mem = 0;
+ _untracked_mem = 0;
+ return token;
+}
+
+void ThreadMemTrackerMgr::adopt_reserved_memory(ReservedMemoryToken&& token) {
+ CHECK(init());
+ if (token._bytes == 0 && token._untracked_bytes == 0) {
+ return;
+ }
+ flush_untracked_mem();
+ CHECK(token._limiter_tracker == _limiter_tracker_sptr);
+ _reserved_mem += token._bytes;
+ _untracked_mem += token._untracked_bytes;
+ token._bytes = 0;
+ token._untracked_bytes = 0;
+}
+
void ThreadMemTrackerMgr::attach_limiter_tracker(
const std::shared_ptr& mem_tracker) {
DCHECK(mem_tracker);
diff --git a/be/src/runtime/memory/thread_mem_tracker_mgr.h b/be/src/runtime/memory/thread_mem_tracker_mgr.h
index d9a915c439bc62..b4494997ca7cd1 100644
--- a/be/src/runtime/memory/thread_mem_tracker_mgr.h
+++ b/be/src/runtime/memory/thread_mem_tracker_mgr.h
@@ -25,6 +25,7 @@
#include
#include
#include
+#include
#include
#include "common/be_mock_util.h"
@@ -39,6 +40,42 @@
namespace doris {
+class ReservedMemoryToken {
+public:
+ ReservedMemoryToken() = default;
+ ReservedMemoryToken(const ReservedMemoryToken&) = delete;
+ ReservedMemoryToken& operator=(const ReservedMemoryToken&) = delete;
+ ReservedMemoryToken(ReservedMemoryToken&& other) noexcept { *this = std::move(other); }
+ ReservedMemoryToken& operator=(ReservedMemoryToken&& other) noexcept {
+ if (this != &other) {
+ release();
+ _limiter_tracker = std::move(other._limiter_tracker);
+ _wg_wptr = std::move(other._wg_wptr);
+ _bytes = std::exchange(other._bytes, 0);
+ _untracked_bytes = std::exchange(other._untracked_bytes, 0);
+ }
+ return *this;
+ }
+ ~ReservedMemoryToken() { release(); }
+ [[nodiscard]] int64_t bytes() const { return _bytes; }
+
+private:
+ friend class ThreadMemTrackerMgr;
+ ReservedMemoryToken(std::shared_ptr limiter_tracker,
+ std::weak_ptr wg_wptr, int64_t bytes,
+ int64_t untracked_bytes)
+ : _limiter_tracker(std::move(limiter_tracker)),
+ _wg_wptr(std::move(wg_wptr)),
+ _bytes(bytes),
+ _untracked_bytes(untracked_bytes) {}
+ void release();
+
+ std::shared_ptr _limiter_tracker;
+ std::weak_ptr _wg_wptr;
+ int64_t _bytes = 0;
+ int64_t _untracked_bytes = 0;
+};
+
constexpr size_t SYNC_PROC_RESERVED_INTERVAL_BYTES = (1ULL << 20); // 1M
static std::string MEMORY_ORPHAN_CHECK_MSG =
"The ThreadContext of the current thread not attach a valid MemoryTracker. after the "
@@ -99,6 +136,9 @@ class ThreadMemTrackerMgr {
void shrink_reserved();
+ ReservedMemoryToken take_reserved_memory();
+ void adopt_reserved_memory(ReservedMemoryToken&& token);
+
MemTrackerLimiter* limiter_mem_tracker() {
CHECK(init());
return _limiter_tracker;
diff --git a/be/src/runtime/runtime_state.cpp b/be/src/runtime/runtime_state.cpp
index fde7ca3bc2d0ad..c805b5310a1f17 100644
--- a/be/src/runtime/runtime_state.cpp
+++ b/be/src/runtime/runtime_state.cpp
@@ -66,7 +66,14 @@ Status RuntimeState::add_iceberg_commit_datas(TIcebergCommitData iceberg_commit_
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));
+ int32_t effective_thrift_limit = std::max(config::thrift_max_message_size, 0);
+ if (_query_options.__isset.coordinator_thrift_max_message_size &&
+ _query_options.coordinator_thrift_max_message_size > 0) {
+ // An older FE omits this field; otherwise the receiver's smaller limit is authoritative.
+ effective_thrift_limit = std::min(effective_thrift_limit,
+ _query_options.coordinator_thrift_max_message_size);
+ }
+ const size_t thrift_limit = static_cast(effective_thrift_limit);
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);
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 83671a9d997d59..21de949500cf80 100644
--- a/be/test/io/fs/azure_obj_storage_client_test.cpp
+++ b/be/test/io/fs/azure_obj_storage_client_test.cpp
@@ -34,15 +34,15 @@
namespace doris {
-TEST(AzureObjStorageClientMultipartHelperTest, isolates_uploads_with_temporary_blob_keys) {
- EXPECT_EQ("table/file.parquet.__doris_multipart/upload-a",
- io::azure_multipart_temp_key("table/file.parquet", "upload-a"));
- EXPECT_NE(io::azure_multipart_temp_key("table/file.parquet", "upload-a"),
- io::azure_multipart_temp_key("table/file.parquet", "upload-b"));
-}
-
#ifdef USE_AZURE
+TEST(AzureObjStorageClientMultipartHelperTest, isolates_uploads_with_fixed_length_block_ids) {
+ EXPECT_NE(io::azure_multipart_block_id("upload-a", 1),
+ io::azure_multipart_block_id("upload-b", 1));
+ EXPECT_EQ(io::azure_multipart_block_id("upload-a", 1).size(),
+ io::azure_multipart_block_id("upload-a", 999).size());
+}
+
using namespace Azure::Storage::Blobs;
TEST(AzureObjStorageClientTlsHelperTest, detects_tls_ca_error) {
@@ -166,7 +166,7 @@ TEST_F(AzureObjStorageClientTest, delete_objects_recursively) {
EXPECT_EQ(files.size(), 0);
}
-TEST_F(AzureObjStorageClientTest, abort_multipart_upload_discards_staged_blocks) {
+TEST_F(AzureObjStorageClientTest, abort_multipart_upload_leaves_no_visible_blob) {
io::ObjectStoragePathOptions opts;
auto create_response =
AzureObjStorageClientTest::obj_storage_client->create_multipart_upload(opts);
@@ -232,7 +232,9 @@ TEST_F(AzureObjStorageClientTest, concurrent_multipart_uploads_do_not_share_stag
ASSERT_EQ(obj_storage_client->upload_part(second, "second", 1).resp.status.code, ErrorCode::OK);
ASSERT_EQ(obj_storage_client->complete_multipart_upload(first, {{.part_num = 1}}).status.code,
ErrorCode::OK);
- ASSERT_EQ(obj_storage_client->complete_multipart_upload(second, {{.part_num = 1}}).status.code,
+ // Committing one writer discards all other uncommitted blocks on the blob. The loser must fail
+ // instead of publishing a mixture of blocks from two uploads.
+ EXPECT_NE(obj_storage_client->complete_multipart_upload(second, {{.part_num = 1}}).status.code,
ErrorCode::OK);
std::array contents {};
@@ -241,7 +243,7 @@ TEST_F(AzureObjStorageClientTest, concurrent_multipart_uploads_do_not_share_stag
->get_object(second, contents.data(), 0, contents.size(), &size_return)
.status.code,
ErrorCode::OK);
- EXPECT_EQ(std::string_view(contents.data(), size_return), "second");
+ EXPECT_EQ(std::string_view(contents.data(), size_return), "first");
EXPECT_EQ(obj_storage_client->delete_object(second).status.code, ErrorCode::OK);
}
#else
diff --git a/be/test/runtime/memory/thread_mem_tracker_mgr_test.cpp b/be/test/runtime/memory/thread_mem_tracker_mgr_test.cpp
index 716e5f2e9a4958..188f16e96a8ee6 100644
--- a/be/test/runtime/memory/thread_mem_tracker_mgr_test.cpp
+++ b/be/test/runtime/memory/thread_mem_tracker_mgr_test.cpp
@@ -331,6 +331,39 @@ TEST_F(ThreadMemTrackerMgrTest, ReserveMemory) {
EXPECT_EQ(doris::GlobalMemoryArbitrator::process_reserved_memory(), 0);
}
+TEST_F(ThreadMemTrackerMgrTest, TransfersReservationBetweenAsyncTasks) {
+ auto tracker = MemTrackerLimiter::create_shared(MemTrackerLimiter::Type::OTHER,
+ "UT-TransferReservation");
+ auto resource_context = ResourceContext::create_shared();
+ resource_context->memory_context()->set_mem_tracker(tracker);
+ ThreadContext producer;
+ ThreadContext consumer;
+ producer.attach_task(resource_context);
+ consumer.attach_task(resource_context);
+ constexpr int64_t reservation = 4 * 1024 * 1024;
+
+ ASSERT_TRUE(producer.thread_mem_tracker_mgr->try_reserve(reservation).ok());
+ auto token = producer.thread_mem_tracker_mgr->take_reserved_memory();
+ EXPECT_EQ(producer.thread_mem_tracker_mgr->reserved_mem(), 0);
+ EXPECT_EQ(token.bytes(), reservation);
+
+ consumer.thread_mem_tracker_mgr->adopt_reserved_memory(std::move(token));
+ EXPECT_EQ(consumer.thread_mem_tracker_mgr->reserved_mem(), reservation);
+ consumer.thread_mem_tracker_mgr->consume(reservation);
+ EXPECT_EQ(consumer.thread_mem_tracker_mgr->reserved_mem(), 0);
+
+ ASSERT_TRUE(producer.thread_mem_tracker_mgr->try_reserve(reservation).ok());
+ {
+ auto abandoned = producer.thread_mem_tracker_mgr->take_reserved_memory();
+ EXPECT_EQ(abandoned.bytes(), reservation);
+ }
+ EXPECT_EQ(GlobalMemoryArbitrator::process_reserved_memory(), 0);
+
+ producer.detach_task();
+ consumer.detach_task();
+ EXPECT_EQ(GlobalMemoryArbitrator::process_reserved_memory(), 0);
+}
+
TEST_F(ThreadMemTrackerMgrTest, NestedReserveMemory) {
std::unique_ptr thread_context = std::make_unique();
std::shared_ptr t = MemTrackerLimiter::create_shared(
diff --git a/be/test/runtime/runtime_state_block_budget_test.cpp b/be/test/runtime/runtime_state_block_budget_test.cpp
index 24afe920d197c5..dc02d35bd32398 100644
--- a/be/test/runtime/runtime_state_block_budget_test.cpp
+++ b/be/test/runtime/runtime_state_block_budget_test.cpp
@@ -59,6 +59,20 @@ TEST(RuntimeStateIcebergCommitDataTest, SharesTheReportBudgetAcrossParallelTasks
EXPECT_FALSE(second_status.ok());
}
+TEST(RuntimeStateIcebergCommitDataTest, UsesTheSmallerCoordinatorThriftLimit) {
+ RuntimeState state;
+ const int32_t saved_limit = config::thrift_max_message_size;
+ config::thrift_max_message_size = 4 * 1024 * 1024;
+ state._query_options.__set_coordinator_thrift_max_message_size(1024 * 1024 + 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());
+}
+
// ---------------------------------------------------------------------------
// RuntimeState::batch_size()
// ---------------------------------------------------------------------------
diff --git a/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/action/IcebergRemoveOrphanFilesAction.java b/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/action/IcebergRemoveOrphanFilesAction.java
index 9c4dd71f61c5a4..cef3b5d2a7ebed 100644
--- a/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/action/IcebergRemoveOrphanFilesAction.java
+++ b/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/action/IcebergRemoveOrphanFilesAction.java
@@ -50,13 +50,16 @@
import java.util.Map;
import java.util.Objects;
import java.util.Set;
+import java.util.regex.Pattern;
/** Safely lists or deletes old files that are unreachable from every retained snapshot. */
public class IcebergRemoveOrphanFilesAction extends BaseIcebergAction {
private static final long MIN_RETENTION_MS = Duration.ofHours(24).toMillis();
+ private static final int MAX_REACHABLE_FILES = 5_000_000;
public static final String OLDER_THAN = "older_than";
public static final String LOCATION = "location";
public static final String DRY_RUN = "dry_run";
+ public static final String ALLOW_UNSAFE_LOCATION = "allow_unsafe_location";
public IcebergRemoveOrphanFilesAction(Map properties, List partitionNames,
ConnectorPredicate whereCondition) {
@@ -67,10 +70,13 @@ public IcebergRemoveOrphanFilesAction(Map properties, List executeAction(Table table, ConnectorSession session) {
// 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);
+ long olderThan = namedArguments.getLong(OLDER_THAN);
+ // Reject an unsafe cutoff before opening any metadata or manifest file.
+ if (olderThan > System.currentTimeMillis() - MIN_RETENTION_MS) {
+ throw new DorisConnectorException("older_than must retain at least 24 hours of files");
+ }
+ List scanScopes = resolveScanScopes(table);
try {
- ReachableIndex reachable = new ReachableIndex(collectReachableFiles(table));
+ ReachableIndex reachable = 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) {
+ for (ScanScope scope : scanScopes) {
// Object stores use raw prefix matching, so the separator excludes sibling prefixes.
- String listingPrefix = scanLocation.endsWith("/") ? scanLocation : scanLocation + "/";
+ String listingPrefix = scope.root.endsWith("/") ? scope.root : scope.root + "/";
for (FileInfo file : ((SupportsPrefixOperations) table.io()).listPrefix(listingPrefix)) {
- if (visitedFiles.add(file.location()) && file.createdAtMillis() < olderThan
+ if (scope.owns(file.location()) && file.createdAtMillis() < olderThan
&& !isReachable(file.location(), reachable)) {
orphanCount++;
if (!dryRun) {
@@ -131,67 +135,69 @@ protected List executeAction(Table table, ConnectorSession session) {
}
}
- private List resolveScanLocations(Table table) {
- List ownedRoots = resolveOwnedRoots(table.location(), table.properties());
-
+ private List resolveScanScopes(Table table) {
+ String tableRoot = normalizeLocation(table.location());
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");
+ if (isWithin(normalized, tableRoot)) {
+ return Lists.newArrayList(ScanScope.exclusive(normalized));
}
- return Lists.newArrayList(normalized);
- }
-
- return minimalOwnedRoots(ownedRoots);
- }
-
- static List resolveOwnedRoots(String tableLocation, Map properties) {
- String tableRoot = normalizeLocation(tableLocation);
- String dataRoot = normalizeLocation(resolveDataLocation(properties, tableRoot));
- List configuredRoots = new ArrayList<>();
- configuredRoots.add(tableRoot);
- configuredRoots.add(dataRoot);
- // Iceberg may place metadata outside both table and data roots, so it remains an independent owned root.
- String metadataRoot = nonEmpty(properties.get(TableProperties.WRITE_METADATA_LOCATION));
- if (metadataRoot != null) {
- configuredRoots.add(normalizeLocation(metadataRoot));
- }
- return canonicalOwnedRoots(configuredRoots);
- }
-
- static List minimalOwnedRoots(List