This is an automated email from the ASF dual-hosted git repository.
Gabriel39 pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/doris.git
The following commit(s) were added to refs/heads/master by this push:
new 1052fc31b1f [fix](iceberg) Validate required NULLs before file
creation (#69327)
1052fc31b1f is described below
commit 1052fc31b1f3bff28577f553598ee9d9aa85f757
Author: daidai <[email protected]>
AuthorDate: Thu Oct 22 10:33:39 2026 +0800
[fix](iceberg) Validate required NULLs before file creation (#69327)
### What problem does this PR solve?
Issue Number: N/A
Related PR: N/A
Problem Summary:
Iceberg required fields could be written as NULL for ORC and Parquet,
and failed writes could leave data files behind. This PR adds a shared
BE Block validator for required nested fields and MAP keys, runs
validation before writers/files are created, and cleans up failed files
without publishing a snapshot. FE rollback now also removes files
already reported by completed BE fragments.
### Release note
Fix Iceberg ORC/Parquet writes to reject required NULL values and clean
up failed files without publishing a snapshot.
### Check List (For Author)
- Test
- [ ] Regression test
- [x] Unit Test
- BE validator, TableWriter, and cleanup tests pass.
- FE rollback tests pass.
- [x] Manual test
- Verified ORC/Parquet failures leave 0 snapshots, 0 physical objects,
and 0 rows; optional parent NULL writes successfully.
- [ ] No need to test or manual test. Explain why:
- [ ] This is a refactor/code format and no logic has been changed.
- [ ] Previous test can cover this change.
- [ ] No code files have been changed.
- [ ] Other reason
- Behavior changed:
- [ ] No.
- [x] Yes.
- Does this need documentation?
- [x] No.
- [ ] Yes.
### Check List (For Reviewer who merge this PR)
- [ ] Confirm the release note
- [ ] Confirm test cases
- [ ] Confirm document
- [ ] Add branch pick label
---
.../writer/iceberg/iceberg_write_validator.cpp | 355 +++++++++++++++++++++
.../sink/writer/iceberg/iceberg_write_validator.h | 35 ++
.../writer/iceberg/viceberg_partition_writer.cpp | 44 ++-
.../writer/iceberg/viceberg_partition_writer.h | 8 +-
.../sink/writer/iceberg/viceberg_table_writer.cpp | 2 +
.../sink/writer/iceberg/viceberg_table_writer.h | 2 +
.../iceberg/iceberg_partition_writer_test.cpp | 74 ++++-
.../iceberg/iceberg_write_validator_test.cpp | 235 ++++++++++++++
.../writer/iceberg/viceberg_table_writer_test.cpp | 67 ++++
.../iceberg/IcebergConnectorTransaction.java | 50 ++-
.../iceberg/IcebergConnectorTransactionTest.java | 32 +-
...berg_write_required_null_select_negative.groovy | 74 +++--
...berg_write_required_null_values_negative.groovy | 173 ++++++++--
13 files changed, 1062 insertions(+), 89 deletions(-)
diff --git a/be/src/exec/sink/writer/iceberg/iceberg_write_validator.cpp
b/be/src/exec/sink/writer/iceberg/iceberg_write_validator.cpp
new file mode 100644
index 00000000000..c1eb2a97b41
--- /dev/null
+++ b/be/src/exec/sink/writer/iceberg/iceberg_write_validator.cpp
@@ -0,0 +1,355 @@
+// 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/sink/writer/iceberg/iceberg_write_validator.h"
+
+#include <fmt/format.h>
+
+#include <algorithm>
+#include <optional>
+#include <string>
+#include <string_view>
+#include <vector>
+
+#include "core/assert_cast.h"
+#include "core/block/block.h"
+#include "core/column/column_array.h"
+#include "core/column/column_map.h"
+#include "core/column/column_nullable.h"
+#include "core/column/column_struct.h"
+#include "core/data_type/data_type_array.h"
+#include "core/data_type/data_type_map.h"
+#include "core/data_type/data_type_nullable.h"
+#include "core/data_type/data_type_struct.h"
+#include "format/table/iceberg/schema.h"
+#include "format/table/iceberg/types.h"
+
+namespace doris::iceberg {
+namespace {
+
+using Path = std::vector<std::string_view>;
+
+struct ValidationLocation {
+ size_t row = 0;
+ std::optional<size_t> ordinal;
+ std::string_view ordinal_kind;
+};
+
+enum class NonNullReason {
+ SCHEMA_REQUIRED,
+ MAP_KEY,
+};
+
+std::string path_to_string(const Path& path) {
+ std::string result;
+ for (size_t i = 0; i < path.size(); ++i) {
+ if (i > 0) {
+ result.push_back('.');
+ }
+ result.append(path[i]);
+ }
+ return result;
+}
+
+Status null_violation(const Path& path, const ValidationLocation& location,
NonNullReason reason) {
+ std::string position = fmt::format("row {}", location.row);
+ if (location.ordinal.has_value()) {
+ position += fmt::format(", {} {}", location.ordinal_kind,
*location.ordinal);
+ }
+ return Status::DataQualityError("Iceberg {} '{}' contains NULL at {}",
+ reason == NonNullReason::MAP_KEY ? "map
key" : "required field",
+ path_to_string(path), position);
+}
+
+bool requires_validation(const NestedField& field) {
+ if (field.is_required()) {
+ return true;
+ }
+ switch (field.field_type()->type_id()) {
+ case TypeID::STRUCT: {
+ const auto& fields = field.field_type()->as_struct_type()->fields();
+ return std::any_of(fields.begin(), fields.end(),
+ [](const NestedField& child) { return
requires_validation(child); });
+ }
+ case TypeID::LIST:
+ return
requires_validation(field.field_type()->as_list_type()->element_field());
+ case TypeID::MAP:
+ // Iceberg map keys are required independently of the map field's
nullability.
+ return true;
+ default:
+ return false;
+ }
+}
+
+template <typename Offsets>
+Status validate_collection_offsets(const Offsets& offsets, size_t position,
size_t row,
+ std::string_view container_name, const
Path& path, size_t* start,
+ size_t* end) {
+ if (position >= offsets.size()) {
+ return Status::InternalError(
+ "Iceberg {} field '{}' has {} offsets, but position {} is
required", container_name,
+ path_to_string(path), offsets.size(), position);
+ }
+ *start = position == 0 ? 0 : offsets[position - 1];
+ *end = offsets[position];
+ if (*start > *end) {
+ return Status::InternalError(
+ "Iceberg {} field '{}' has invalid offsets at row {}: [{},
{}]", container_name,
+ path_to_string(path), row, *start, *end);
+ }
+ return Status::OK();
+}
+
+Status validate_value(const NestedField& field, const IColumn& column, const
DataTypePtr& type,
+ size_t position, const ValidationLocation& location,
Path& path,
+ NonNullReason reason) {
+ if (position >= column.size()) {
+ return Status::InternalError(
+ "Iceberg write column '{}' has {} values, but position {} "
+ "is required",
+ path_to_string(path), column.size(), position);
+ }
+
+ if (column.is_null_at(position)) {
+ if (reason == NonNullReason::MAP_KEY || field.is_required()) {
+ return null_violation(path, location, reason);
+ }
+ // An optional STRUCT/LIST/MAP is a NULL value in its own right. Its
children and
+ // elements are masked by the parent NULL and must not be inspected.
+ return Status::OK();
+ }
+
+ const IColumn* data_column = &column;
+ if (const auto* nullable = check_and_get_column<ColumnNullable>(column)) {
+ data_column = &nullable->get_nested_column();
+ }
+
+ const auto primitive_type = remove_nullable(type)->get_primitive_type();
+ switch (field.field_type()->type_id()) {
+ case TypeID::STRUCT: {
+ if (primitive_type != TYPE_STRUCT) {
+ return Status::InternalError("Iceberg STRUCT field '{}' does not
match Doris type {}",
+ path_to_string(path),
type->get_name());
+ }
+ const auto* struct_column =
check_and_get_column<ColumnStruct>(*data_column);
+ const auto* struct_type = assert_cast<const
DataTypeStruct*>(remove_nullable(type).get());
+ if (struct_column == nullptr) {
+ return Status::InternalError("Iceberg STRUCT field '{}' is not
stored as a STRUCT",
+ path_to_string(path));
+ }
+ const auto& fields = field.field_type()->as_struct_type()->fields();
+ if (struct_column->tuple_size() != fields.size() ||
+ struct_type->get_elements().size() != fields.size()) {
+ return Status::InternalError(
+ "Iceberg STRUCT field '{}' has {} schema fields, {} Doris
type fields, and "
+ "{} column fields",
+ path_to_string(path), fields.size(),
struct_type->get_elements().size(),
+ struct_column->tuple_size());
+ }
+ for (size_t i = 0; i < fields.size(); ++i) {
+ if (!requires_validation(fields[i])) {
+ continue;
+ }
+ path.emplace_back(fields[i].field_name());
+ Status status = validate_value(fields[i],
struct_column->get_column(i),
+ struct_type->get_element(i),
position, location, path,
+ NonNullReason::SCHEMA_REQUIRED);
+ path.pop_back();
+ RETURN_IF_ERROR(status);
+ }
+ return Status::OK();
+ }
+ case TypeID::LIST: {
+ if (primitive_type != TYPE_ARRAY) {
+ return Status::InternalError("Iceberg LIST field '{}' does not
match Doris type {}",
+ path_to_string(path),
type->get_name());
+ }
+ const auto* array_column =
check_and_get_column<ColumnArray>(*data_column);
+ const auto* array_type = assert_cast<const
DataTypeArray*>(remove_nullable(type).get());
+ if (array_column == nullptr) {
+ return Status::InternalError("Iceberg LIST field '{}' is not
stored as an ARRAY",
+ path_to_string(path));
+ }
+ const auto& offsets = array_column->get_offsets();
+ size_t start = 0;
+ size_t end = 0;
+ RETURN_IF_ERROR(validate_collection_offsets(offsets, position,
location.row, "LIST", path,
+ &start, &end));
+ if (end > array_column->get_data().size()) {
+ return Status::InternalError(
+ "Iceberg LIST field '{}' has invalid offsets at row {}:
[{}, {}], "
+ "element count {}",
+ path_to_string(path), location.row, start, end,
+ array_column->get_data().size());
+ }
+ const auto& element =
field.field_type()->as_list_type()->element_field();
+ if (!requires_validation(element)) {
+ return Status::OK();
+ }
+ path.emplace_back(element.field_name());
+ for (size_t i = start; i < end; ++i) {
+ ValidationLocation element_location {
+ .row = location.row,
+ .ordinal = i - start,
+ .ordinal_kind = "element",
+ };
+ Status status =
+ validate_value(element, array_column->get_data(),
array_type->get_nested_type(),
+ i, element_location, path,
NonNullReason::SCHEMA_REQUIRED);
+ if (!status.ok()) {
+ path.pop_back();
+ return status;
+ }
+ }
+ path.pop_back();
+ return Status::OK();
+ }
+ case TypeID::MAP: {
+ if (primitive_type != TYPE_MAP) {
+ return Status::InternalError("Iceberg MAP field '{}' does not
match Doris type {}",
+ path_to_string(path),
type->get_name());
+ }
+ const auto* map_column = check_and_get_column<ColumnMap>(*data_column);
+ const auto* map_type = assert_cast<const
DataTypeMap*>(remove_nullable(type).get());
+ if (map_column == nullptr) {
+ return Status::InternalError("Iceberg MAP field '{}' is not stored
as a MAP",
+ path_to_string(path));
+ }
+ const auto& offsets = map_column->get_offsets();
+ size_t start = 0;
+ size_t end = 0;
+ RETURN_IF_ERROR(validate_collection_offsets(offsets, position,
location.row, "MAP", path,
+ &start, &end));
+ if (end > map_column->get_keys().size() || end >
map_column->get_values().size()) {
+ return Status::InternalError(
+ "Iceberg MAP field '{}' has invalid offsets at row {}:
[{}, {}], "
+ "key count {}, value count {}",
+ path_to_string(path), location.row, start, end,
map_column->get_keys().size(),
+ map_column->get_values().size());
+ }
+
+ const auto& map_type_desc = field.field_type()->as_map_type();
+ path.emplace_back(map_type_desc->key_field().field_name());
+ for (size_t i = start; i < end; ++i) {
+ ValidationLocation entry_location {
+ .row = location.row,
+ .ordinal = i - start,
+ .ordinal_kind = "map entry",
+ };
+ Status status = validate_value(map_type_desc->key_field(),
map_column->get_keys(),
+ map_type->get_key_type(), i,
entry_location, path,
+ NonNullReason::MAP_KEY);
+ if (!status.ok()) {
+ path.pop_back();
+ return status;
+ }
+ }
+ path.pop_back();
+
+ const auto& value = map_type_desc->value_field();
+ if (!requires_validation(value)) {
+ return Status::OK();
+ }
+ path.emplace_back(value.field_name());
+ for (size_t i = start; i < end; ++i) {
+ ValidationLocation entry_location {
+ .row = location.row,
+ .ordinal = i - start,
+ .ordinal_kind = "map entry",
+ };
+ Status status =
+ validate_value(value, map_column->get_values(),
map_type->get_value_type(), i,
+ entry_location, path,
NonNullReason::SCHEMA_REQUIRED);
+ if (!status.ok()) {
+ path.pop_back();
+ return status;
+ }
+ }
+ path.pop_back();
+ return Status::OK();
+ }
+ default:
+ return Status::OK();
+ }
+}
+
+} // namespace
+
+Status validate_write_block(const Schema& schema, const Block& block) {
+ if (block.columns() != schema.columns().size()) {
+ return Status::InternalError(
+ "Iceberg write block has {} columns, but pinned schema has {}
fields",
+ block.columns(), schema.columns().size());
+ }
+
+ Path path;
+ path.reserve(8);
+ for (size_t field_index = 0; field_index < schema.columns().size();
++field_index) {
+ const auto& field = schema.columns()[field_index];
+ if (!requires_validation(field)) {
+ continue;
+ }
+ const auto& column_with_name = block.get_by_position(field_index);
+ ColumnPtr column =
column_with_name.column->convert_to_full_column_if_const();
+ if (!column) {
+ return Status::InternalError("Iceberg write block column {} is
null", field_index);
+ }
+ if (column->size() < block.rows()) {
+ return Status::InternalError(
+ "Iceberg write block column '{}' has {} values, but block
has {} rows",
+ field.field_name(), column->size(), block.rows());
+ }
+
+ path.emplace_back(field.field_name());
+ if (field.field_type()->is_primitive_type() && field.is_required()) {
+ if (column->has_null()) {
+ for (size_t row = 0; row < block.rows(); ++row) {
+ if (!column->is_null_at(row)) {
+ continue;
+ }
+ ValidationLocation location {
+ .row = row,
+ .ordinal = std::nullopt,
+ .ordinal_kind = {},
+ };
+ Status status = null_violation(path, location,
NonNullReason::SCHEMA_REQUIRED);
+ path.pop_back();
+ return status;
+ }
+ }
+ path.pop_back();
+ continue;
+ }
+ for (size_t row = 0; row < block.rows(); ++row) {
+ ValidationLocation location {
+ .row = row,
+ .ordinal = std::nullopt,
+ .ordinal_kind = {},
+ };
+ Status status = validate_value(field, *column,
column_with_name.type, row, location,
+ path,
NonNullReason::SCHEMA_REQUIRED);
+ if (!status.ok()) {
+ path.pop_back();
+ return status;
+ }
+ }
+ path.pop_back();
+ }
+ return Status::OK();
+}
+
+} // namespace doris::iceberg
diff --git a/be/src/exec/sink/writer/iceberg/iceberg_write_validator.h
b/be/src/exec/sink/writer/iceberg/iceberg_write_validator.h
new file mode 100644
index 00000000000..30d55e1f52a
--- /dev/null
+++ b/be/src/exec/sink/writer/iceberg/iceberg_write_validator.h
@@ -0,0 +1,35 @@
+// 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 "common/status.h"
+
+namespace doris {
+
+class Block;
+
+namespace iceberg {
+
+class Schema;
+
+// Validates the data-dependent Iceberg write contract for one materialized
Block.
+// Schema identity, field IDs, partition specs, and sort orders are pinned and
checked in FE.
+Status validate_write_block(const Schema& schema, const Block& block);
+
+} // namespace iceberg
+} // namespace doris
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 0be4d1d371d..5410b67a673 100644
--- a/be/src/exec/sink/writer/iceberg/viceberg_partition_writer.cpp
+++ b/be/src/exec/sink/writer/iceberg/viceberg_partition_writer.cpp
@@ -138,13 +138,34 @@ Status VIcebergPartitionWriter::open(RuntimeState* state,
RuntimeProfile* profil
}
}
if (!open_status.ok()) {
- // A transformer failure happens after object creation, so remove any
published file.
- WARN_IF_ERROR(_fs->delete_file(_path), "failed to delete Iceberg file
after open error");
+ // A transformer failure happens after writer creation. Defer deletion
until the output
+ // stream has finished closing so an asynchronous object upload cannot
publish it later.
+ _defer_file_cleanup();
}
return open_status;
}
+void VIcebergPartitionWriter::_defer_file_cleanup() {
+ if (_fs == nullptr || _file_cleanup_deferred) {
+ return;
+ }
+ _file_cleanup_deferred = true;
+ // Destroy the format writer and its output stream before deleting the
physical object. In
+ // particular, a failed close may leave an asynchronous upload running,
and the object must not
+ // be visible after the rollback has completed.
+ _file_format_transformer.reset();
+ _file_writer.reset();
+ if (_closed_file_callback) {
+ _closed_file_callback(_fs, _path);
+ } else {
+ WARN_IF_ERROR(_fs->delete_file(_path), "failed to delete Iceberg
file");
+ }
+}
+
Status VIcebergPartitionWriter::close(const Status& status) {
+ if (_file_cleanup_deferred) {
+ return _terminal_close_status;
+ }
Status result_status;
if (_file_format_transformer != nullptr) {
result_status = _file_format_transformer->close();
@@ -155,28 +176,21 @@ Status VIcebergPartitionWriter::close(const Status&
status) {
}
bool status_ok = result_status.ok() && status.ok();
if (!status_ok && _fs != nullptr) {
- // delete the actual created file, otherwise an orphan file is left
behind
- Status st = _fs->delete_file(_path);
- if (!st.ok()) {
- LOG(WARNING) << fmt::format("Delete file {} failed, reason: {}",
_path, st.to_string());
- }
+ _terminal_close_status = result_status;
+ _defer_file_cleanup();
}
if (status_ok) {
TIcebergCommitData commit_data;
Status commit_status = _build_iceberg_commit_data(&commit_data);
if (!commit_status.ok()) {
- // A closed object without commit data can never be published, so
remove it immediately.
- Status delete_status = _fs->delete_file(_path);
- if (!delete_status.ok()) {
- LOG(WARNING) << fmt::format("Delete file {} failed, reason:
{}", _path,
- delete_status.to_string());
- }
+ _terminal_close_status = commit_status;
+ _defer_file_cleanup();
return commit_status;
}
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");
+ _terminal_close_status = report_status;
+ _defer_file_cleanup();
return report_status;
}
if (_closed_file_callback) {
diff --git a/be/src/exec/sink/writer/iceberg/viceberg_partition_writer.h
b/be/src/exec/sink/writer/iceberg/viceberg_partition_writer.h
index 954d458538c..b0f5f4eefd2 100644
--- a/be/src/exec/sink/writer/iceberg/viceberg_partition_writer.h
+++ b/be/src/exec/sink/writer/iceberg/viceberg_partition_writer.h
@@ -70,7 +70,9 @@ public:
inline int file_name_index() const override { return _file_name_index; }
- inline size_t written_len() const override { return
_file_format_transformer->written_len(); }
+ inline size_t written_len() const override {
+ return _file_format_transformer == nullptr ? 0 :
_file_format_transformer->written_len();
+ }
private:
friend class VIcebergPartitionWriterTest;
@@ -79,6 +81,8 @@ private:
Status _build_iceberg_commit_data(TIcebergCommitData* commit_data);
+ void _defer_file_cleanup();
+
std::string _get_file_extension(TFileFormatType::type file_format_type,
TFileCompressType::type
write_compress_type);
@@ -100,6 +104,8 @@ private:
TFileCompressType::type _compress_type;
const std::map<std::string, std::string>& _hadoop_conf;
ClosedFileCallback _closed_file_callback;
+ bool _file_cleanup_deferred = false;
+ Status _terminal_close_status = Status::OK();
bool _collect_column_stats = true;
// FE's metrics policy for NaN counting; empty (including an older FE that
sends nothing) means the
// parquet writer skips the extra pass entirely. See
IcebergWriterHelper#nanCountFieldIds.
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 c041ac9e662..bdfed84ee5d 100644
--- a/be/src/exec/sink/writer/iceberg/viceberg_table_writer.cpp
+++ b/be/src/exec/sink/writer/iceberg/viceberg_table_writer.cpp
@@ -33,6 +33,7 @@
#include "core/data_type_serde/data_type_serde.h"
#include "exec/sink/writer/external_file_writer_compatibility.h"
#include "exec/sink/writer/iceberg/iceberg_partition_path.h"
+#include "exec/sink/writer/iceberg/iceberg_write_validator.h"
#include "exec/sink/writer/iceberg/partition_transformers.h"
#include "exec/sink/writer/iceberg/viceberg_partition_writer.h"
#include "exec/sink/writer/iceberg/viceberg_sort_writer.h"
@@ -402,6 +403,7 @@ 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));
+ RETURN_IF_ERROR(iceberg::validate_write_block(*_schema, output_block));
std::unordered_map<std::shared_ptr<IPartitionWriterBase>,
IColumn::Permutation>
writer_positions;
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 742e9625905..48b7d24e126 100644
--- a/be/src/exec/sink/writer/iceberg/viceberg_table_writer.h
+++ b/be/src/exec/sink/writer/iceberg/viceberg_table_writer.h
@@ -79,6 +79,8 @@ public:
private:
FRIEND_TEST(VIcebergTableWriterTest, RejectMissingPartitionSource);
FRIEND_TEST(VIcebergTableWriterTest, ResolvesNestedPartitionSource);
+ FRIEND_TEST(VIcebergTableWriterTest,
RejectsRequiredNullBeforeWriterCreation);
+ FRIEND_TEST(VIcebergTableWriterTest,
RejectsNestedRequiredNullBeforeWriterCreation);
// The lifecycle fixture inspects snapshots to verify that cross-thread
writer ownership stays stable.
friend class VIcebergTableWriterLifecycleTest;
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 d5a7e3f9ab3..9b10ff0e909 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
@@ -21,6 +21,7 @@
#include "exec/sink/writer/iceberg/viceberg_partition_writer.h"
#include "exec/sink/writer/iceberg/viceberg_sort_writer.h"
+#include "io/fs/local_file_system.h"
#include "testutil/mock/mock_descriptors.h"
#include "testutil/mock/mock_runtime_state.h"
#include "testutil/mock/mock_slot_ref.h"
@@ -32,9 +33,17 @@ namespace {
class FakeFileFormatTransformer final : public VFileFormatTransformer {
public:
explicit FakeFileFormatTransformer(const VExprContextSPtrs& output_exprs,
- Status close_status = Status::OK())
+ Status close_status = Status::OK(),
+ bool* destroyed = nullptr)
: VFileFormatTransformer(nullptr, output_exprs, false),
- _close_status(std::move(close_status)) {}
+ _close_status(std::move(close_status)),
+ _destroyed(destroyed) {}
+
+ ~FakeFileFormatTransformer() override {
+ if (_destroyed != nullptr) {
+ *_destroyed = true;
+ }
+ }
Status open() override { return Status::OK(); }
Status write(const Block&) override { return Status::OK(); }
@@ -43,6 +52,7 @@ public:
private:
Status _close_status;
+ bool* _destroyed;
};
TDataSink make_table_sink(std::optional<bool> collect_column_stats) {
@@ -74,9 +84,10 @@ protected:
static void install_fake_transformer(VIcebergPartitionWriter* writer,
const VExprContextSPtrs& output_exprs,
- Status close_status = Status::OK()) {
- writer->_file_format_transformer =
- std::make_unique<FakeFileFormatTransformer>(output_exprs,
std::move(close_status));
+ Status close_status = Status::OK(),
+ bool* destroyed = nullptr) {
+ writer->_file_format_transformer =
std::make_unique<FakeFileFormatTransformer>(
+ output_exprs, std::move(close_status), destroyed);
}
static Status build_commit_data(VIcebergPartitionWriter* writer,
@@ -133,6 +144,59 @@ TEST_F(VIcebergPartitionWriterTest,
SortWriterPropagatesUnderlyingCloseFailure)
EXPECT_NE(status.to_string().find("injected close failure"),
std::string::npos);
}
+TEST_F(VIcebergPartitionWriterTest,
FailedCloseDefersFileCleanupToOuterCallback) {
+ VExprContextSPtrs output_exprs;
+ iceberg::Schema schema(std::vector<iceberg::NestedField> {});
+ std::string schema_json;
+ std::map<std::string, std::string> hadoop_conf;
+ auto writer = std::shared_ptr<VIcebergPartitionWriter>(
+ make_writer(make_table_sink(false), output_exprs, schema,
&schema_json, hadoop_conf));
+ bool transformer_destroyed = false;
+ install_fake_transformer(writer.get(), output_exprs, Status::OK(),
&transformer_destroyed);
+ writer->_fs = io::global_local_filesystem();
+ writer->_path = "/tmp/doris-iceberg-failed-close.orc";
+ int cleanup_calls = 0;
+ writer->_closed_file_callback = [&](std::shared_ptr<io::FileSystem> fs,
+ const std::string& path) {
+ EXPECT_EQ(writer->_fs, fs);
+ EXPECT_EQ(writer->_path, path);
+ EXPECT_TRUE(transformer_destroyed);
+ ++cleanup_calls;
+ };
+
+ EXPECT_TRUE(writer->close(Status::Cancelled("injected write
failure")).ok());
+ EXPECT_EQ(1, cleanup_calls);
+ EXPECT_EQ(0, writer->written_len());
+
+ EXPECT_TRUE(writer->close(Status::Cancelled("injected write
failure")).ok());
+ EXPECT_EQ(1, cleanup_calls);
+}
+
+TEST_F(VIcebergPartitionWriterTest, RepeatedClosePreservesTerminalError) {
+ VExprContextSPtrs output_exprs;
+ iceberg::Schema schema(std::vector<iceberg::NestedField> {});
+ std::string schema_json;
+ std::map<std::string, std::string> hadoop_conf;
+ auto writer = std::shared_ptr<VIcebergPartitionWriter>(
+ make_writer(make_table_sink(false), output_exprs, schema,
&schema_json, hadoop_conf));
+ install_fake_transformer(writer.get(), output_exprs,
Status::IOError("injected close failure"));
+ writer->_fs = io::global_local_filesystem();
+ writer->_path = "/tmp/doris-iceberg-close-failure.orc";
+ int cleanup_calls = 0;
+ writer->_closed_file_callback = [&](std::shared_ptr<io::FileSystem>, const
std::string&) {
+ ++cleanup_calls;
+ };
+
+ Status first_status = writer->close(Status::OK());
+ Status second_status = writer->close(Status::OK());
+
+ EXPECT_FALSE(first_status.ok());
+ EXPECT_FALSE(second_status.ok());
+ EXPECT_NE(first_status.to_string().find("injected close failure"),
std::string::npos);
+ EXPECT_EQ(first_status, second_status);
+ EXPECT_EQ(1, cleanup_calls);
+}
+
TEST_F(VIcebergPartitionWriterTest, EosReservationIncludesActualSpillFanIn) {
VExprContextSPtrs output_exprs;
iceberg::Schema schema(std::vector<iceberg::NestedField> {});
diff --git a/be/test/exec/sink/writer/iceberg/iceberg_write_validator_test.cpp
b/be/test/exec/sink/writer/iceberg/iceberg_write_validator_test.cpp
new file mode 100644
index 00000000000..a547321fa5a
--- /dev/null
+++ b/be/test/exec/sink/writer/iceberg/iceberg_write_validator_test.cpp
@@ -0,0 +1,235 @@
+// 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/sink/writer/iceberg/iceberg_write_validator.h"
+
+#include <gtest/gtest.h>
+
+#include <memory>
+#include <optional>
+#include <vector>
+
+#include "core/block/block.h"
+#include "core/column/column_array.h"
+#include "core/column/column_map.h"
+#include "core/column/column_nullable.h"
+#include "core/column/column_struct.h"
+#include "core/column/column_vector.h"
+#include "core/data_type/data_type_array.h"
+#include "core/data_type/data_type_map.h"
+#include "core/data_type/data_type_nullable.h"
+#include "core/data_type/data_type_number.h"
+#include "core/data_type/data_type_string.h"
+#include "core/data_type/data_type_struct.h"
+#include "format/table/iceberg/schema.h"
+#include "format/table/iceberg/types.h"
+
+namespace doris::iceberg {
+namespace {
+
+DataTypePtr nullable_int() {
+ return make_nullable(std::make_shared<DataTypeInt32>());
+}
+
+ColumnPtr nullable_int_column(std::vector<int32_t> values,
std::vector<uint8_t> nulls) {
+ auto data = ColumnInt32::create();
+ data->get_data().assign(values.begin(), values.end());
+ auto null_map = ColumnUInt8::create();
+ null_map->get_data().assign(nulls.begin(), nulls.end());
+ return ColumnNullable::create(std::move(data), std::move(null_map));
+}
+
+void expect_data_quality_error(const Status& status, std::string_view path,
+ std::string_view location) {
+ EXPECT_FALSE(status.ok());
+ EXPECT_NE(status.to_string().find(path), std::string::npos);
+ EXPECT_NE(status.to_string().find(location), std::string::npos);
+}
+
+} // namespace
+
+TEST(IcebergWriteValidatorTest, RejectsTopLevelRequiredNullWithLocation) {
+ std::vector<NestedField> fields;
+ fields.emplace_back(false, 1, "payload", std::make_unique<StringType>(),
std::nullopt);
+ Schema schema(std::move(fields));
+ auto type = make_nullable(std::make_shared<DataTypeString>());
+ auto data = ColumnString::create();
+ data->insert_data("A", 1);
+ data->insert_default();
+ auto null_map = ColumnUInt8::create();
+ null_map->get_data().assign({0, 1});
+ Block block({ColumnWithTypeAndName(ColumnNullable::create(std::move(data),
std::move(null_map)),
+ type, "payload")});
+
+ expect_data_quality_error(validate_write_block(schema, block), "payload",
"row 1");
+}
+
+TEST(IcebergWriteValidatorTest, SkipsRequiredChildWhenOptionalParentIsNull) {
+ std::vector<NestedField> child_fields;
+ child_fields.emplace_back(false, 2, "id", std::make_unique<IntegerType>(),
std::nullopt);
+ std::vector<NestedField> fields;
+ fields.emplace_back(true, 1, "payload",
std::make_unique<StructType>(std::move(child_fields)),
+ std::nullopt);
+ Schema schema(std::move(fields));
+ auto child_type = nullable_int();
+ auto struct_type = std::make_shared<DataTypeStruct>(DataTypes
{child_type}, Strings {"id"});
+ auto parent_type = make_nullable(struct_type);
+ Columns children {nullable_int_column({7}, {1})};
+ auto parent_null_map = ColumnUInt8::create();
+ parent_null_map->get_data().push_back(1);
+ Block block(
+
{ColumnWithTypeAndName(ColumnNullable::create(ColumnStruct::create(std::move(children)),
+
std::move(parent_null_map)),
+ parent_type, "payload")});
+
+ EXPECT_TRUE(validate_write_block(schema, block).ok());
+}
+
+TEST(IcebergWriteValidatorTest, RejectsRequiredStructChildNull) {
+ std::vector<NestedField> child_fields;
+ child_fields.emplace_back(false, 2, "id", std::make_unique<IntegerType>(),
std::nullopt);
+ std::vector<NestedField> fields;
+ fields.emplace_back(false, 1, "payload",
std::make_unique<StructType>(std::move(child_fields)),
+ std::nullopt);
+ Schema schema(std::move(fields));
+ auto child_type = nullable_int();
+ auto struct_type = std::make_shared<DataTypeStruct>(DataTypes
{child_type}, Strings {"id"});
+ auto parent_type = make_nullable(struct_type);
+ Columns children {nullable_int_column({7, 8}, {0, 1})};
+ auto parent_null_map = ColumnUInt8::create();
+ parent_null_map->get_data().assign({0, 0});
+ Block block(
+
{ColumnWithTypeAndName(ColumnNullable::create(ColumnStruct::create(std::move(children)),
+
std::move(parent_null_map)),
+ parent_type, "payload")});
+
+ expect_data_quality_error(validate_write_block(schema, block),
"payload.id", "row 1");
+}
+
+TEST(IcebergWriteValidatorTest, RejectsRequiredListElementNull) {
+ std::vector<NestedField> fields;
+ fields.emplace_back(true, 1, "items", ListType::of_required(2,
std::make_unique<IntegerType>()),
+ std::nullopt);
+ Schema schema(std::move(fields));
+ auto element_type = nullable_int();
+ auto array_type = std::make_shared<DataTypeArray>(element_type);
+ auto offsets = ColumnArray::ColumnOffsets::create();
+ offsets->get_data().push_back(3);
+ auto array = ColumnArray::create(nullable_int_column({1, 2, 3}, {0, 1,
0}), std::move(offsets));
+ auto null_map = ColumnUInt8::create();
+ null_map->get_data().push_back(0);
+ Block block(
+ {ColumnWithTypeAndName(ColumnNullable::create(std::move(array),
std::move(null_map)),
+ make_nullable(array_type), "items")});
+
+ expect_data_quality_error(validate_write_block(schema, block),
"items.element", "element 1");
+}
+
+TEST(IcebergWriteValidatorTest, RejectsNullMapKey) {
+ std::vector<NestedField> fields;
+ fields.emplace_back(true, 1, "attrs",
+ MapType::of_required(2, 3,
std::make_unique<IntegerType>(),
+ std::make_unique<IntegerType>()),
+ std::nullopt);
+ Schema schema(std::move(fields));
+ auto key_type = std::make_shared<DataTypeInt32>();
+ auto value_type = nullable_int();
+ auto map_type = std::make_shared<DataTypeMap>(key_type, value_type);
+ auto offsets = ColumnMap::COffsets::create();
+ offsets->get_data().push_back(2);
+ auto map = ColumnMap::create(nullable_int_column({1, 2}, {0, 1}),
+ nullable_int_column({10, 20}, {0, 0}),
std::move(offsets));
+ auto null_map = ColumnUInt8::create();
+ null_map->get_data().push_back(0);
+ Block block({ColumnWithTypeAndName(ColumnNullable::create(std::move(map),
std::move(null_map)),
+ make_nullable(map_type), "attrs")});
+
+ expect_data_quality_error(validate_write_block(schema, block),
"attrs.key", "map entry 1");
+}
+
+TEST(IcebergWriteValidatorTest, SkipsMapKeyWhenOptionalMapIsNull) {
+ std::vector<NestedField> fields;
+ fields.emplace_back(true, 1, "attrs",
+ MapType::of_required(2, 3,
std::make_unique<IntegerType>(),
+ std::make_unique<IntegerType>()),
+ std::nullopt);
+ Schema schema(std::move(fields));
+ auto key_type = std::make_shared<DataTypeInt32>();
+ auto value_type = nullable_int();
+ auto map_type = std::make_shared<DataTypeMap>(key_type, value_type);
+ auto offsets = ColumnMap::COffsets::create();
+ offsets->get_data().push_back(1);
+ // Keep a physically materialized NULL key under the NULL parent to prove
that the parent mask,
+ // rather than an empty map, is what suppresses the map-key check.
+ auto map = ColumnMap::create(nullable_int_column({1}, {1}),
nullable_int_column({10}, {0}),
+ std::move(offsets));
+ auto null_map = ColumnUInt8::create();
+ null_map->get_data().push_back(1);
+ Block block({ColumnWithTypeAndName(ColumnNullable::create(std::move(map),
std::move(null_map)),
+ make_nullable(map_type), "attrs")});
+
+ EXPECT_TRUE(validate_write_block(schema, block).ok());
+}
+
+TEST(IcebergWriteValidatorTest, RejectsRequiredMapValueNull) {
+ std::vector<NestedField> fields;
+ fields.emplace_back(true, 1, "attrs",
+ MapType::of_required(2, 3,
std::make_unique<IntegerType>(),
+ std::make_unique<IntegerType>()),
+ std::nullopt);
+ Schema schema(std::move(fields));
+ auto key_type = std::make_shared<DataTypeInt32>();
+ auto value_type = nullable_int();
+ auto map_type = std::make_shared<DataTypeMap>(key_type, value_type);
+ auto offsets = ColumnMap::COffsets::create();
+ offsets->get_data().push_back(2);
+ auto map = ColumnMap::create(nullable_int_column({1, 2}, {0, 0}),
+ nullable_int_column({10, 20}, {0, 1}),
std::move(offsets));
+ auto null_map = ColumnUInt8::create();
+ null_map->get_data().push_back(0);
+ Block block({ColumnWithTypeAndName(ColumnNullable::create(std::move(map),
std::move(null_map)),
+ make_nullable(map_type), "attrs")});
+
+ expect_data_quality_error(validate_write_block(schema, block),
"attrs.value", "map entry 1");
+}
+
+TEST(IcebergWriteValidatorTest, AcceptsValidRequiredNestedValues) {
+ std::vector<NestedField> fields;
+ fields.emplace_back(false, 1, "id", std::make_unique<IntegerType>(),
std::nullopt);
+ fields.emplace_back(true, 2, "items", ListType::of_required(3,
std::make_unique<IntegerType>()),
+ std::nullopt);
+ Schema schema(std::move(fields));
+ auto id_type = nullable_int();
+ auto id_column = nullable_int_column({1, 2}, {0, 0});
+ auto element_type = nullable_int();
+ auto array_type =
make_nullable(std::make_shared<DataTypeArray>(element_type));
+ auto offsets = ColumnArray::ColumnOffsets::create();
+ offsets->get_data().assign({2, 3});
+ auto array =
+ ColumnArray::create(nullable_int_column({10, 20, 30}, {0, 0, 0}),
std::move(offsets));
+ auto array_nulls = ColumnUInt8::create();
+ array_nulls->get_data().assign({0, 0});
+ Block block({
+ ColumnWithTypeAndName(std::move(id_column), id_type, "id"),
+ ColumnWithTypeAndName(ColumnNullable::create(std::move(array),
std::move(array_nulls)),
+ array_type, "items"),
+ });
+
+ EXPECT_TRUE(validate_write_block(schema, block).ok());
+}
+
+} // namespace doris::iceberg
diff --git a/be/test/exec/sink/writer/iceberg/viceberg_table_writer_test.cpp
b/be/test/exec/sink/writer/iceberg/viceberg_table_writer_test.cpp
index 21397daa141..c16ba9a2164 100644
--- a/be/test/exec/sink/writer/iceberg/viceberg_table_writer_test.cpp
+++ b/be/test/exec/sink/writer/iceberg/viceberg_table_writer_test.cpp
@@ -64,6 +64,73 @@ public:
};
} // namespace
+TEST(VIcebergTableWriterTest, RejectsRequiredNullBeforeWriterCreation) {
+ std::vector<iceberg::NestedField> fields;
+ fields.emplace_back(false, 1, "id",
std::make_unique<iceberg::IntegerType>(), std::nullopt);
+ auto schema = std::make_shared<iceberg::Schema>(std::move(fields));
+ TIcebergTableSink sink;
+ TDataSink data_sink;
+ data_sink.__set_iceberg_table_sink(sink);
+ VIcebergTableWriter writer(data_sink, {}, nullptr, nullptr);
+ writer._schema = schema;
+
+ auto type = make_nullable(std::make_shared<DataTypeInt32>());
+ auto data = ColumnInt32::create();
+ data->insert_value(1);
+ auto null_map = ColumnUInt8::create();
+ null_map->get_data().push_back(1);
+ Block block({ColumnWithTypeAndName(ColumnNullable::create(std::move(data),
std::move(null_map)),
+ type, "id")});
+
+ Status status = writer._write_prepared_block(block);
+
+ EXPECT_FALSE(status.ok());
+ EXPECT_NE(status.to_string().find("required field 'id'"),
std::string::npos);
+ EXPECT_TRUE(writer._partitions_to_writers.empty());
+ EXPECT_EQ(0, writer._row_count);
+}
+
+TEST(VIcebergTableWriterTest, RejectsNestedRequiredNullBeforeWriterCreation) {
+ std::vector<iceberg::NestedField> child_fields;
+ child_fields.emplace_back(false, 2, "required_metric",
std::make_unique<iceberg::IntegerType>(),
+ std::nullopt);
+ std::vector<iceberg::NestedField> fields;
+ fields.emplace_back(true, 1, "info",
+
std::make_unique<iceberg::StructType>(std::move(child_fields)),
+ std::nullopt);
+ auto schema = std::make_shared<iceberg::Schema>(std::move(fields));
+ TIcebergTableSink sink;
+ TDataSink data_sink;
+ data_sink.__set_iceberg_table_sink(sink);
+ VIcebergTableWriter writer(data_sink, {}, nullptr, nullptr);
+ writer._schema = schema;
+
+ auto child_type = make_nullable(std::make_shared<DataTypeInt32>());
+ auto child_data = ColumnInt32::create();
+ child_data->insert_value(7);
+ auto child_nulls = ColumnUInt8::create();
+ child_nulls->get_data().push_back(1);
+ Columns children {
+ ColumnNullable::create(std::move(child_data),
std::move(child_nulls)),
+ };
+ auto struct_type =
+ std::make_shared<DataTypeStruct>(DataTypes {child_type}, Strings
{"required_metric"});
+ auto parent_nulls = ColumnUInt8::create();
+ parent_nulls->get_data().push_back(0);
+ Block block({
+
ColumnWithTypeAndName(ColumnNullable::create(ColumnStruct::create(std::move(children)),
+
std::move(parent_nulls)),
+ make_nullable(struct_type), "info"),
+ });
+
+ Status status = writer._write_prepared_block(block);
+
+ EXPECT_FALSE(status.ok());
+ EXPECT_NE(status.to_string().find("required field
'info.required_metric'"), std::string::npos);
+ EXPECT_TRUE(writer._partitions_to_writers.empty());
+ EXPECT_EQ(0, writer._row_count);
+}
+
TEST(VIcebergTableWriterTest, TimestampIdentityPreservesUtcCommitAndNull) {
std::vector<iceberg::NestedField> fields;
fields.emplace_back(false, 1, "event_time",
std::make_unique<iceberg::TimestampTzType>(),
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 42c3118a9bd..ddfa9d9fae1 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
@@ -1469,11 +1469,51 @@ public class IcebergConnectorTransaction implements
ConnectorTransaction, Rewrit
@Override
public void rollback() {
- // Insert-mode: nothing to undo on the FE side — an uncommitted SDK
transaction simply discards
- // its pending manifests (legacy IcebergTransaction.rollback no-ops
the insert path). The rewrite
- // path's file-list cleanup is a P6.4 procedure concern, out of scope
here.
- LOG.info("Iceberg transaction {} rollback called; uncommitted
manifests will be discarded.",
- transactionId);
+ List<TIcebergCommitData> pending;
+ synchronized (this) {
+ pending = new ArrayList<>(commitDataList);
+ commitDataList.clear();
+ }
+ Table rollbackTable = table;
+ if (rollbackTable == null || pending.isEmpty()) {
+ LOG.info("Iceberg transaction {} rollback called; no external
files were reported.",
+ transactionId);
+ return;
+ }
+
+ Set<String> paths = new HashSet<>();
+ for (TIcebergCommitData data : pending) {
+ if (data != null && data.isSetFilePath() && data.getFilePath() !=
null
+ && !data.getFilePath().isEmpty()) {
+ paths.add(data.getFilePath());
+ }
+ }
+ if (paths.isEmpty()) {
+ LOG.info("Iceberg transaction {} rollback called; no external
files were reported.",
+ transactionId);
+ return;
+ }
+
+ try {
+ context.executeAuthenticated(() -> {
+ for (String path : paths) {
+ try {
+ rollbackTable.io().deleteFile(path);
+ } catch (Exception e) {
+ // Rollback is best-effort. A missing file is already
clean, while an IO failure
+ // must be logged for orphan-file maintenance rather
than masking the query error.
+ LOG.warn("Failed to delete uncommitted Iceberg file {}
for transaction {}: {}",
+ path, transactionId, e.getMessage());
+ }
+ }
+ return null;
+ });
+ LOG.info("Iceberg transaction {} rollback attempted to delete {}
reported external file(s).",
+ transactionId, paths.size());
+ } catch (Exception e) {
+ LOG.warn("Failed to clean reported Iceberg files for transaction
{}: {}", transactionId,
+ e.getMessage());
+ }
}
@Override
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 6489c1d317e..ddd1ac2c7bf 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
@@ -51,10 +51,13 @@ import org.apache.iceberg.Table;
import org.apache.iceberg.Transaction;
import org.apache.iceberg.catalog.Namespace;
import org.apache.iceberg.catalog.TableIdentifier;
+import org.apache.iceberg.exceptions.NotFoundException;
import org.apache.iceberg.expressions.Expression;
import org.apache.iceberg.expressions.Expressions;
import org.apache.iceberg.inmemory.InMemoryCatalog;
import org.apache.iceberg.io.CloseableIterable;
+import org.apache.iceberg.io.OutputFile;
+import org.apache.iceberg.io.PositionOutputStream;
import org.apache.iceberg.types.Types;
import org.apache.thrift.TException;
import org.apache.thrift.TSerializer;
@@ -1274,7 +1277,7 @@ public class IcebergConnectorTransactionTest {
}
@Test
- public void rollbackAndCloseAreNoOps() {
+ public void rollbackWithoutBeginAndCloseAreNoOps() {
IcebergConnectorTransaction txn = txnFor(opsReturning(null), new
RecordingConnectorContext());
Assertions.assertDoesNotThrow(() -> {
txn.rollback();
@@ -1282,6 +1285,33 @@ public class IcebergConnectorTransactionTest {
});
}
+ @Test
+ public void rollbackDeletesReportedFilesAndClearsCommitData() throws
IOException {
+ InMemoryCatalog catalog = freshCatalog();
+ TableIdentifier id = TableIdentifier.of("db1", "t1");
+ Table table = catalog.createTable(id, SCHEMA,
PartitionSpec.unpartitioned());
+ String path = "s3://b/db1/t1/uncommitted.parquet";
+ OutputFile output = table.io().newOutputFile(path);
+ try (PositionOutputStream stream = output.create()) {
+ stream.write(1);
+ }
+ Assertions.assertTrue(table.io().newInputFile(path).exists());
+
+ IcebergConnectorTransaction txn =
+ txnFor(opsReturning(table), new RecordingConnectorContext());
+ txn.beginWrite(SESSION, "db1", "t1", insertCtx());
+ txn.addCommitData(commitBytes(dataFileItem(path, 1L, 1L)));
+
+ txn.rollback();
+
+ Assertions.assertThrows(NotFoundException.class,
+ () -> table.io().newInputFile(path).exists(),
+ "rollback must delete data files reported by completed BE
fragments");
+ Assertions.assertTrue(txn.getCommitDataList().isEmpty(),
+ "rollback must not leave fragments that a later lifecycle call
could commit");
+ txn.close();
+ }
+
@Test
public void beginWriteRejectsReplacementExposedByTransactionRefresh() {
InMemoryCatalog catalog = freshCatalog();
diff --git
a/regression-test/suites/external_table_p0/iceberg/write/test_iceberg_write_required_null_select_negative.groovy
b/regression-test/suites/external_table_p0/iceberg/write/test_iceberg_write_required_null_select_negative.groovy
index 765e5c58345..cf1441369f0 100644
---
a/regression-test/suites/external_table_p0/iceberg/write/test_iceberg_write_required_null_select_negative.groovy
+++
b/regression-test/suites/external_table_p0/iceberg/write/test_iceberg_write_required_null_select_negative.groovy
@@ -23,13 +23,6 @@ suite("test_iceberg_write_required_null_select_negative",
return
}
- // This opt-in switch isolates a write that can publish an unreadable
Iceberg data file.
- String knownBugTestEnabled =
context.config.otherConfigs.get("enableIcebergKnownBugTest")
- if (knownBugTestEnabled == null ||
!knownBugTestEnabled.equalsIgnoreCase("true")) {
- logger.info("skip isolated Iceberg known-bug regression")
- return
- }
-
String restPort = context.config.otherConfigs.get("iceberg_rest_uri_port")
String minioPort = context.config.otherConfigs.get("iceberg_minio_port")
String externalEnvIp = context.config.otherConfigs.get("externalEnvIp")
@@ -71,33 +64,48 @@ suite("test_iceberg_write_required_null_select_negative",
sql """create database ${dbName}"""
sql """use ${dbName}"""
- sql """
- create table required_select (
- id int not null,
- required_text string not null
- )
- partition by list (bucket(8, id)) ()
- properties ("format-version" = "2")
- """
-
- long snapshotsBefore = (sql """select count(*) from
required_select\$snapshots""")[0][0] as long
- long filesBefore = (sql """select count(*) from
required_select\$files""")[0][0] as long
- long rowsBefore = (sql """select count(*) from required_select""")[0][0]
as long
-
- // W07-S02: The metadata and row baselines distinguish an early rejection
- // from a late failure that has already published a snapshot or data file.
- test {
+ for (String fileFormat : ["orc", "parquet"]) {
+ String tableName = "required_select_${fileFormat}"
sql """
- insert into required_select
- select id, required_text
- from internal.${internalDb}.nullable_source
+ create table ${tableName} (
+ id int not null,
+ required_text string not null
+ )
+ partition by list (bucket(8, id)) ()
+ properties (
+ "format-version" = "2",
+ "write.format.default" = "${fileFormat}"
+ )
"""
- exception "null"
+
+ long snapshotsBefore = (sql """
+ select count(*) from ${tableName}\$snapshots
+ """)[0][0] as long
+ long filesBefore = (sql """
+ select count(*) from ${tableName}\$files
+ """)[0][0] as long
+ long rowsBefore = (sql """
+ select count(*) from ${tableName}
+ """)[0][0] as long
+
+ // W07-S02: The metadata and row baselines distinguish an early
rejection
+ // from a late failure that has already published a snapshot or data
file.
+ test {
+ sql """
+ insert into ${tableName}
+ select id, required_text
+ from internal.${internalDb}.nullable_source
+ """
+ exception "required field 'required_text' contains NULL"
+ }
+ assertEquals(snapshotsBefore, (sql """
+ select count(*) from ${tableName}\$snapshots
+ """)[0][0] as long)
+ assertEquals(filesBefore, (sql """
+ select count(*) from ${tableName}\$files
+ """)[0][0] as long)
+ assertEquals(rowsBefore, (sql """
+ select count(*) from ${tableName}
+ """)[0][0] as long)
}
- assertEquals(snapshotsBefore,
- (sql """select count(*) from required_select\$snapshots""")[0][0]
as long)
- assertEquals(filesBefore,
- (sql """select count(*) from required_select\$files""")[0][0] as
long)
- assertEquals(rowsBefore,
- (sql """select count(*) from required_select""")[0][0] as long)
}
diff --git
a/regression-test/suites/external_table_p0/iceberg/write/test_iceberg_write_required_null_values_negative.groovy
b/regression-test/suites/external_table_p0/iceberg/write/test_iceberg_write_required_null_values_negative.groovy
index 86c433dc634..2842e3d8f63 100644
---
a/regression-test/suites/external_table_p0/iceberg/write/test_iceberg_write_required_null_values_negative.groovy
+++
b/regression-test/suites/external_table_p0/iceberg/write/test_iceberg_write_required_null_values_negative.groovy
@@ -23,13 +23,6 @@ suite("test_iceberg_write_required_null_values_negative",
return
}
- // This opt-in switch isolates a write that can publish an unreadable
Iceberg data file.
- String knownBugTestEnabled =
context.config.otherConfigs.get("enableIcebergKnownBugTest")
- if (knownBugTestEnabled == null ||
!knownBugTestEnabled.equalsIgnoreCase("true")) {
- logger.info("skip isolated Iceberg known-bug regression")
- return
- }
-
String restPort = context.config.otherConfigs.get("iceberg_rest_uri_port")
String minioPort = context.config.otherConfigs.get("iceberg_minio_port")
String externalEnvIp = context.config.otherConfigs.get("externalEnvIp")
@@ -53,29 +46,151 @@ suite("test_iceberg_write_required_null_values_negative",
sql """create database ${dbName}"""
sql """use ${dbName}"""
- sql """
- create table required_values (
- id int not null,
- required_text string not null
- )
- partition by list (bucket(8, id)) ()
- properties ("format-version" = "2")
- """
+ for (String fileFormat : ["orc", "parquet"]) {
+ String tableName = "required_values_${fileFormat}"
+ sql """
+ create table ${tableName} (
+ id int not null,
+ required_text string not null
+ )
+ partition by list (bucket(8, id)) ()
+ properties (
+ "format-version" = "2",
+ "write.format.default" = "${fileFormat}"
+ )
+ """
+
+ long snapshotsBefore = (sql """
+ select count(*) from ${tableName}\$snapshots
+ """)[0][0] as long
+ long filesBefore = (sql """
+ select count(*) from ${tableName}\$files
+ """)[0][0] as long
+ long rowsBefore = (sql """
+ select count(*) from ${tableName}
+ """)[0][0] as long
+
+ // W07-S01: A thrown NULL error is insufficient proof of atomicity; all
+ // visible and metadata state must remain at the pre-statement
baseline.
+ test {
+ sql """insert into ${tableName} values (1, null)"""
+ exception "required field 'required_text' contains NULL"
+ }
+ assertEquals(snapshotsBefore, (sql """
+ select count(*) from ${tableName}\$snapshots
+ """)[0][0] as long)
+ assertEquals(filesBefore, (sql """
+ select count(*) from ${tableName}\$files
+ """)[0][0] as long)
+ assertEquals(rowsBefore, (sql """
+ select count(*) from ${tableName}
+ """)[0][0] as long)
+ }
+
+ // Spark can express required nested fields that Doris DDL cannot declare
directly.
+ for (String fileFormat : ["orc", "parquet"]) {
+ String tableName = "required_nested_${fileFormat}"
+ spark_iceberg """
+ drop table if exists demo.${dbName}.${tableName}
+ """
+ spark_iceberg """
+ create table demo.${dbName}.${tableName} (
+ id int,
+ info struct<required_metric: int not null, required_label:
string not null>
+ )
+ using iceberg
+ tblproperties (
+ 'format-version' = '2',
+ 'write.format.default' = '${fileFormat}'
+ )
+ """
+ spark_iceberg """
+ insert into demo.${dbName}.${tableName}
+ values (1, named_struct('required_metric', 10, 'required_label',
'old-label'))
+ """
+ sql """refresh table ${tableName}"""
+
+ // The STRUCT itself is optional. A NULL parent masks its required
children and is a valid row.
+ sql """insert into ${tableName} values (2, null)"""
+ sql """refresh table ${tableName}"""
+
+ long snapshotsBefore = (sql """
+ select count(*) from ${tableName}\$snapshots
+ """)[0][0] as long
+ long filesBefore = (sql """
+ select count(*) from ${tableName}\$files
+ """)[0][0] as long
+ long rowsBefore = (sql """
+ select count(*) from ${tableName}
+ """)[0][0] as long
+
+ test {
+ sql """
+ insert into ${tableName}
+ values (
+ 3,
+ named_struct(
+ 'required_metric', cast(null as int),
+ 'required_label', 'new-label'
+ )
+ )
+ """
+ // Current FE versions may reject the nullable-to-required cast
during planning; later
+ // versions may let the block reach BE. Both paths must identify
the required child.
+ exception "required_metric"
+ }
+ assertEquals(snapshotsBefore, (sql """
+ select count(*) from ${tableName}\$snapshots
+ """)[0][0] as long)
+ assertEquals(filesBefore, (sql """
+ select count(*) from ${tableName}\$files
+ """)[0][0] as long)
+ assertEquals(rowsBefore, (sql """
+ select count(*) from ${tableName}
+ """)[0][0] as long)
+ }
+
+ // Iceberg MAP keys are always required, including when the MAP parent is
optional.
+ for (String fileFormat : ["orc", "parquet"]) {
+ String tableName = "required_map_key_${fileFormat}"
+ sql """
+ create table ${tableName} (
+ id int not null,
+ attrs map<int, int>
+ )
+ partition by list (bucket(8, id)) ()
+ properties (
+ "format-version" = "2",
+ "write.format.default" = "${fileFormat}"
+ )
+ """
+
+ // A NULL MAP parent masks all entries, so it must remain writable.
+ sql """insert into ${tableName} values (1, null)"""
+ sql """refresh table ${tableName}"""
- long snapshotsBefore = (sql """select count(*) from
required_values\$snapshots""")[0][0] as long
- long filesBefore = (sql """select count(*) from
required_values\$files""")[0][0] as long
- long rowsBefore = (sql """select count(*) from required_values""")[0][0]
as long
+ long snapshotsBefore = (sql """
+ select count(*) from ${tableName}\$snapshots
+ """)[0][0] as long
+ long filesBefore = (sql """
+ select count(*) from ${tableName}\$files
+ """)[0][0] as long
+ long rowsBefore = (sql """
+ select count(*) from ${tableName}
+ """)[0][0] as long
- // W07-S01: A thrown NULL error is insufficient proof of atomicity; all
- // visible and metadata state must remain at the pre-statement baseline.
- test {
- sql """insert into required_values values (1, null)"""
- exception "null"
+ test {
+ sql """insert into ${tableName} values (2, map(cast(null as int),
2))"""
+ exception "map key 'attrs.key' contains NULL"
+ }
+ assertEquals(snapshotsBefore, (sql """
+ select count(*) from ${tableName}\$snapshots
+ """)[0][0] as long)
+ assertEquals(filesBefore, (sql """
+ select count(*) from ${tableName}\$files
+ """)[0][0] as long)
+ assertEquals(rowsBefore, (sql """
+ select count(*) from ${tableName}
+ """)[0][0] as long)
}
- assertEquals(snapshotsBefore,
- (sql """select count(*) from required_values\$snapshots""")[0][0]
as long)
- assertEquals(filesBefore,
- (sql """select count(*) from required_values\$files""")[0][0] as
long)
- assertEquals(rowsBefore,
- (sql """select count(*) from required_values""")[0][0] as long)
}
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]