blob: 403a2cf98248fd197731c0e1f8cb54034aa5993c [file]
/*
* 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 "iceberg/data/delete_loader.h"
#include <optional>
#include <utility>
#include <arrow/array.h>
#include <arrow/c/bridge.h>
#include <arrow/json/from_string.h>
#include <gmock/gmock.h>
#include <gtest/gtest.h>
#include "iceberg/arrow/arrow_io_internal.h"
#include "iceberg/data/equality_delete_writer.h"
#include "iceberg/data/position_delete_writer.h"
#include "iceberg/deletes/dv_writer.h"
#include "iceberg/deletes/position_delete_index.h"
#include "iceberg/file_format.h"
#include "iceberg/file_io.h"
#include "iceberg/manifest/manifest_entry.h"
#include "iceberg/parquet/parquet_register.h"
#include "iceberg/partition_spec.h"
#include "iceberg/result.h"
#include "iceberg/row/partition_values.h"
#include "iceberg/schema.h"
#include "iceberg/schema_field.h"
#include "iceberg/schema_internal.h"
#include "iceberg/test/matchers.h"
#include "iceberg/type.h"
#include "iceberg/util/macros.h"
#include "iceberg/util/struct_like_set.h"
namespace iceberg {
class DeleteLoaderTest : public ::testing::Test {
protected:
static void SetUpTestSuite() { parquet::RegisterAll(); }
void SetUp() override {
file_io_ = arrow::ArrowFileSystemFileIO::MakeMockFileIO();
schema_ = std::make_shared<Schema>(
std::vector<SchemaField>{SchemaField::MakeRequired(1, "id", int32()),
SchemaField::MakeOptional(2, "name", string())});
partition_spec_ = PartitionSpec::Unpartitioned();
loader_ = std::make_unique<DeleteLoader>(file_io_);
}
/// Write position deletes using WriteDelete API and return the DataFile metadata.
std::shared_ptr<DataFile> WritePositionDeletes(
const std::string& path,
const std::vector<std::pair<std::string, int64_t>>& deletes) {
PositionDeleteWriterOptions options{
.path = path,
.schema = schema_,
.spec = partition_spec_,
.partition = PartitionValues{},
.format = FileFormatType::kParquet,
.io = file_io_,
.flush_threshold = 10000,
.properties = {{"write.parquet.compression-codec", "uncompressed"}},
};
auto writer = PositionDeleteWriter::Make(options).value();
for (const auto& [file_path, pos] : deletes) {
ICEBERG_THROW_NOT_OK(writer->WriteDelete(file_path, pos));
}
ICEBERG_THROW_NOT_OK(writer->Close());
return writer->Metadata().value().data_files[0];
}
/// Write equality deletes and return the DataFile metadata.
std::shared_ptr<DataFile> WriteEqualityDeletes(
const std::string& path, const std::string& json_data,
std::vector<int32_t> equality_field_ids = {1, 2}) {
EqualityDeleteWriterOptions options{
.path = path,
.schema = schema_,
.spec = partition_spec_,
.partition = PartitionValues{},
.format = FileFormatType::kParquet,
.io = file_io_,
.equality_field_ids = std::move(equality_field_ids),
.properties = {{"write.parquet.compression-codec", "uncompressed"}},
};
auto writer = EqualityDeleteWriter::Make(options).value();
ArrowSchema arrow_c_schema;
ICEBERG_THROW_NOT_OK(ToArrowSchema(*schema_, &arrow_c_schema));
auto arrow_type = ::arrow::ImportType(&arrow_c_schema).ValueOrDie();
auto test_data = ::arrow::json::ArrayFromJSONString(
::arrow::struct_(arrow_type->fields()), json_data)
.ValueOrDie();
ArrowArray arrow_array;
auto export_status = ::arrow::ExportArray(*test_data, &arrow_array);
if (!export_status.ok()) throw std::runtime_error(export_status.ToString());
ICEBERG_THROW_NOT_OK(writer->Write(&arrow_array));
ICEBERG_THROW_NOT_OK(writer->Close());
return writer->Metadata().value().data_files[0];
}
/// Write a deletion vector to a Puffin file and return the DataFile metadata.
std::shared_ptr<DataFile> WriteDeletionVector(const std::string& path,
const std::string& referenced_data_file,
const std::vector<int64_t>& positions) {
auto writer =
DVWriter::Make(DVWriterOptions{.path = path,
.io = file_io_,
.load_previous_deletes = [](std::string_view)
-> Result<std::optional<PositionDeleteIndex>> {
return std::nullopt;
}})
.value();
for (int64_t pos : positions) {
ICEBERG_THROW_NOT_OK(
writer->Delete(referenced_data_file, pos, partition_spec_, PartitionValues{}));
}
ICEBERG_THROW_NOT_OK(writer->Close());
return writer->Metadata().value().data_files[0];
}
std::shared_ptr<FileIO> file_io_;
std::shared_ptr<Schema> schema_;
std::shared_ptr<PartitionSpec> partition_spec_;
std::unique_ptr<DeleteLoader> loader_;
};
// --- Position Delete Tests ---
TEST_F(DeleteLoaderTest, LoadPositionDeletesEmpty) {
std::vector<std::shared_ptr<DataFile>> empty;
auto result = loader_->LoadPositionDeletes(empty, "data.parquet");
ASSERT_THAT(result, IsOk());
ASSERT_TRUE(result->IsEmpty());
}
TEST_F(DeleteLoaderTest, LoadPositionDeletesSingleFile) {
auto delete_file = WritePositionDeletes(
"pos_deletes_1.parquet",
{{"data.parquet", 0}, {"data.parquet", 5}, {"data.parquet", 10}});
std::vector<std::shared_ptr<DataFile>> files = {delete_file};
auto result = loader_->LoadPositionDeletes(files, "data.parquet");
ASSERT_THAT(result, IsOk());
auto& index = result.value();
ASSERT_FALSE(index.IsEmpty());
ASSERT_EQ(index.Cardinality(), 3);
ASSERT_TRUE(index.IsDeleted(0));
ASSERT_TRUE(index.IsDeleted(5));
ASSERT_TRUE(index.IsDeleted(10));
ASSERT_FALSE(index.IsDeleted(1));
ASSERT_FALSE(index.IsDeleted(6));
}
TEST_F(DeleteLoaderTest, LoadPositionDeletesMultipleFiles) {
auto file1 = WritePositionDeletes("pos_deletes_a.parquet",
{{"data.parquet", 1}, {"data.parquet", 3}});
auto file2 = WritePositionDeletes("pos_deletes_b.parquet",
{{"data.parquet", 5}, {"data.parquet", 7}});
std::vector<std::shared_ptr<DataFile>> files = {file1, file2};
auto result = loader_->LoadPositionDeletes(files, "data.parquet");
ASSERT_THAT(result, IsOk());
auto& index = result.value();
ASSERT_EQ(index.Cardinality(), 4);
ASSERT_TRUE(index.IsDeleted(1));
ASSERT_TRUE(index.IsDeleted(3));
ASSERT_TRUE(index.IsDeleted(5));
ASSERT_TRUE(index.IsDeleted(7));
}
TEST_F(DeleteLoaderTest, LoadPositionDeletesFiltersByDataFilePath) {
auto delete_file =
WritePositionDeletes("pos_deletes_mixed.parquet", {{"data_a.parquet", 0},
{"data_a.parquet", 2},
{"data_b.parquet", 10},
{"data_b.parquet", 20}});
// Mixed paths keep the loader on the per-row filter path.
ASSERT_FALSE(delete_file->referenced_data_file.has_value());
std::vector<std::shared_ptr<DataFile>> files = {delete_file};
// Load only positions for data_a.parquet
auto result_a = loader_->LoadPositionDeletes(files, "data_a.parquet");
ASSERT_THAT(result_a, IsOk());
ASSERT_EQ(result_a.value().Cardinality(), 2);
ASSERT_TRUE(result_a.value().IsDeleted(0));
ASSERT_TRUE(result_a.value().IsDeleted(2));
ASSERT_FALSE(result_a.value().IsDeleted(10));
// Load only positions for data_b.parquet
auto result_b = loader_->LoadPositionDeletes(files, "data_b.parquet");
ASSERT_THAT(result_b, IsOk());
ASSERT_EQ(result_b.value().Cardinality(), 2);
ASSERT_TRUE(result_b.value().IsDeleted(10));
ASSERT_TRUE(result_b.value().IsDeleted(20));
ASSERT_FALSE(result_b.value().IsDeleted(0));
// Load for non-existent file
auto result_none = loader_->LoadPositionDeletes(files, "nonexistent.parquet");
ASSERT_THAT(result_none, IsOk());
ASSERT_TRUE(result_none.value().IsEmpty());
}
// The loaded index keeps the source Parquet position delete file, so a DV
// writer can report file-scoped deletes as rewritten.
TEST_F(DeleteLoaderTest, LoadPositionDeletesTracksSourceDeleteFile) {
// Single referenced data file -> writer sets the hint -> fast path.
auto file_scoped = WritePositionDeletes("pos_src_scoped.parquet",
{{"data.parquet", 0}, {"data.parquet", 5}});
ASSERT_TRUE(file_scoped->referenced_data_file.has_value());
std::vector<std::shared_ptr<DataFile>> files = {file_scoped};
auto result = loader_->LoadPositionDeletes(files, "data.parquet");
ASSERT_THAT(result, IsOk());
ASSERT_EQ(result.value().delete_files().size(), 1u);
EXPECT_EQ(result.value().delete_files()[0], file_scoped);
}
TEST_F(DeleteLoaderTest, LoadPositionDeletesTracksSourceForFilterPath) {
auto mixed = WritePositionDeletes("pos_src_mixed.parquet",
{{"data_a.parquet", 0}, {"data_b.parquet", 10}});
ASSERT_FALSE(mixed->referenced_data_file.has_value());
std::vector<std::shared_ptr<DataFile>> files = {mixed};
auto matched = loader_->LoadPositionDeletes(files, "data_a.parquet");
ASSERT_THAT(matched, IsOk());
ASSERT_EQ(matched.value().delete_files().size(), 1u);
EXPECT_EQ(matched.value().delete_files()[0], mixed);
auto unmatched = loader_->LoadPositionDeletes(files, "data_c.parquet");
ASSERT_THAT(unmatched, IsOk());
ASSERT_TRUE(unmatched.value().IsEmpty());
ASSERT_EQ(unmatched.value().delete_files().size(), 1u);
EXPECT_EQ(unmatched.value().delete_files()[0], mixed);
}
TEST_F(DeleteLoaderTest, LoadPositionDeletesRejectsNullFile) {
std::vector<std::shared_ptr<DataFile>> files = {nullptr};
auto result = loader_->LoadPositionDeletes(files, "data.parquet");
ASSERT_THAT(result, IsError(ErrorKind::kInvalidArgument));
}
TEST_F(DeleteLoaderTest, LoadPositionDeletesSkipsMismatchedReferencedDataFile) {
auto delete_file = std::make_shared<DataFile>(DataFile{
.content = DataFile::Content::kPositionDeletes,
.file_path = "missing-pos-delete.parquet",
.file_format = FileFormatType::kParquet,
.referenced_data_file = "other-data.parquet",
});
std::vector<std::shared_ptr<DataFile>> files = {delete_file};
auto result = loader_->LoadPositionDeletes(files, "data.parquet");
ASSERT_THAT(result, IsOk());
ASSERT_TRUE(result.value().IsEmpty());
}
TEST_F(DeleteLoaderTest, LoadPositionDeletesFastPathHonorsReferencedDataFile) {
// Large enough to exercise the bulk path.
constexpr int64_t kRowCount = 128;
std::vector<std::pair<std::string, int64_t>> deletes;
deletes.reserve(kRowCount);
for (int64_t i = 0; i < kRowCount; ++i) {
deletes.emplace_back("data.parquet", i);
}
auto delete_file = WritePositionDeletes("pos_deletes_fast_path.parquet", deletes);
ASSERT_TRUE(delete_file->referenced_data_file.has_value());
ASSERT_EQ(delete_file->referenced_data_file.value(), "data.parquet");
std::vector<std::shared_ptr<DataFile>> files = {delete_file};
auto result = loader_->LoadPositionDeletes(files, "data.parquet");
ASSERT_THAT(result, IsOk());
auto& index = result.value();
ASSERT_EQ(index.Cardinality(), kRowCount);
ASSERT_TRUE(index.IsDeleted(0));
ASSERT_TRUE(index.IsDeleted(kRowCount / 2));
ASSERT_TRUE(index.IsDeleted(kRowCount - 1));
ASSERT_FALSE(index.IsDeleted(kRowCount));
}
TEST_F(DeleteLoaderTest, LoadDeletionVector) {
auto dv_file =
WriteDeletionVector("dv-a.puffin", "data.parquet", {0, 5, 10, 4'000'000'000LL});
std::vector<std::shared_ptr<DataFile>> files = {dv_file};
auto result = loader_->LoadPositionDeletes(files, "data.parquet");
ASSERT_THAT(result, IsOk());
auto& index = result.value();
ASSERT_EQ(index.Cardinality(), 4);
ASSERT_TRUE(index.IsDeleted(0));
ASSERT_TRUE(index.IsDeleted(5));
ASSERT_TRUE(index.IsDeleted(10));
ASSERT_TRUE(index.IsDeleted(4'000'000'000LL));
ASSERT_FALSE(index.IsDeleted(1));
}
TEST_F(DeleteLoaderTest, LoadDeletionVectorRejectsMismatchedReferencedDataFile) {
// A DV referencing another data file must not be silently skipped: the single
// DV applies to its referenced file, so a mismatch is bad grouping/metadata.
auto dv_file = WriteDeletionVector("dv-b.puffin", "other-data.parquet", {1, 2, 3});
std::vector<std::shared_ptr<DataFile>> files = {dv_file};
auto result = loader_->LoadPositionDeletes(files, "data.parquet");
ASSERT_THAT(result, IsError(ErrorKind::kInvalidArgument));
}
TEST_F(DeleteLoaderTest, LoadDeletionVectorRequiresContentOffsetAndSize) {
auto dv_file = std::make_shared<DataFile>(DataFile{
.content = DataFile::Content::kPositionDeletes,
.file_path = "dv.puffin",
.file_format = FileFormatType::kPuffin,
.referenced_data_file = "data.parquet",
});
std::vector<std::shared_ptr<DataFile>> files = {dv_file};
auto result = loader_->LoadPositionDeletes(files, "data.parquet");
ASSERT_THAT(result, IsError(ErrorKind::kInvalidArgument));
}
TEST_F(DeleteLoaderTest, LoadDeletionVectorRejectsCardinalityMismatch) {
auto dv_file = WriteDeletionVector("dv-card.puffin", "data.parquet", {0, 1, 2});
// Corrupt the recorded cardinality so it no longer matches the bitmap.
dv_file->record_count = 99;
std::vector<std::shared_ptr<DataFile>> files = {dv_file};
auto result = loader_->LoadPositionDeletes(files, "data.parquet");
ASSERT_THAT(result, IsError(ErrorKind::kInvalidArgument));
}
// End to end: a previously written Parquet position delete is loaded, merged
// into a new DV, and reported in rewritten_delete_files when it is file-scoped.
TEST_F(DeleteLoaderTest, DVWriterReportsFileScopedPositionDeleteAsRewritten) {
// A file-scoped position delete (single referenced data file).
auto previous = WritePositionDeletes("prev_pos.parquet",
{{"data.parquet", 100}, {"data.parquet", 200}});
ASSERT_TRUE(previous->referenced_data_file.has_value());
auto* loader = loader_.get();
ICEBERG_UNWRAP_OR_FAIL(
auto writer,
DVWriter::Make(DVWriterOptions{
.path = "dv-replace.puffin",
.io = file_io_,
.load_previous_deletes =
[&](std::string_view path) -> Result<std::optional<PositionDeleteIndex>> {
std::vector<std::shared_ptr<DataFile>> files = {previous};
ICEBERG_ASSIGN_OR_RAISE(auto index, loader->LoadPositionDeletes(files, path));
if (index.IsEmpty()) {
return std::nullopt;
}
return std::optional<PositionDeleteIndex>(std::move(index));
},
}));
ICEBERG_THROW_NOT_OK(
writer->Delete("data.parquet", 0, partition_spec_, PartitionValues{}));
ICEBERG_THROW_NOT_OK(writer->Close());
ICEBERG_UNWRAP_OR_FAIL(auto result, writer->Metadata());
ASSERT_EQ(result.data_files.size(), 1u);
// New position plus the two previous positions.
EXPECT_EQ(result.data_files[0]->record_count, 3);
// The previous Parquet position delete is file-scoped and must be reported.
ASSERT_EQ(result.rewritten_delete_files.size(), 1u);
EXPECT_EQ(result.rewritten_delete_files[0], previous);
}
TEST_F(DeleteLoaderTest, LoadPositionDeletesRejectsWrongContent) {
auto data_file = std::make_shared<DataFile>(DataFile{
.content = DataFile::Content::kData,
.file_path = "data.parquet",
.file_format = FileFormatType::kParquet,
});
std::vector<std::shared_ptr<DataFile>> files = {data_file};
auto result = loader_->LoadPositionDeletes(files, "data.parquet");
ASSERT_THAT(result, IsError(ErrorKind::kInvalidArgument));
}
// --- Equality Delete Tests ---
TEST_F(DeleteLoaderTest, LoadEqualityDeletesEmpty) {
std::vector<std::shared_ptr<DataFile>> empty;
StructType eq_type(std::vector<SchemaField>{
SchemaField::MakeRequired(1, "id", int32()),
SchemaField::MakeOptional(2, "name", string()),
});
auto result = loader_->LoadEqualityDeletes(empty, eq_type);
ASSERT_THAT(result, IsOk());
ASSERT_TRUE(result.value()->IsEmpty());
ASSERT_EQ(result.value()->Size(), 0);
}
TEST_F(DeleteLoaderTest, LoadEqualityDeletesSingleFile) {
auto delete_file =
WriteEqualityDeletes("eq_deletes_1.parquet", R"([[1, "Alice"], [2, "Bob"]])");
StructType eq_type(std::vector<SchemaField>{
SchemaField::MakeRequired(1, "id", int32()),
SchemaField::MakeOptional(2, "name", string()),
});
std::vector<std::shared_ptr<DataFile>> files = {delete_file};
auto result = loader_->LoadEqualityDeletes(files, eq_type);
ASSERT_THAT(result, IsOk());
ASSERT_FALSE(result.value()->IsEmpty());
ASSERT_EQ(result.value()->Size(), 2);
}
TEST_F(DeleteLoaderTest, LoadEqualityDeletesMultipleFiles) {
auto file1 = WriteEqualityDeletes("eq_deletes_a.parquet", R"([[1, "Alice"]])");
auto file2 =
WriteEqualityDeletes("eq_deletes_b.parquet", R"([[2, "Bob"], [3, "Charlie"]])");
StructType eq_type(std::vector<SchemaField>{
SchemaField::MakeRequired(1, "id", int32()),
SchemaField::MakeOptional(2, "name", string()),
});
std::vector<std::shared_ptr<DataFile>> files = {file1, file2};
auto result = loader_->LoadEqualityDeletes(files, eq_type);
ASSERT_THAT(result, IsOk());
ASSERT_EQ(result.value()->Size(), 3);
}
TEST_F(DeleteLoaderTest, LoadEqualityDeletesRejectsWrongContent) {
auto data_file = std::make_shared<DataFile>(DataFile{
.content = DataFile::Content::kData,
.file_path = "data.parquet",
.file_format = FileFormatType::kParquet,
});
StructType eq_type(std::vector<SchemaField>{
SchemaField::MakeRequired(1, "id", int32()),
});
std::vector<std::shared_ptr<DataFile>> files = {data_file};
auto result = loader_->LoadEqualityDeletes(files, eq_type);
ASSERT_THAT(result, IsError(ErrorKind::kInvalidArgument));
}
} // namespace iceberg