| /* | |
| * 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/update/update_statistics.h" | |
| #include <algorithm> | |
| #include <memory> | |
| #include <string> | |
| #include <gmock/gmock.h> | |
| #include <gtest/gtest.h> | |
| #include "iceberg/result.h" | |
| #include "iceberg/statistics_file.h" | |
| #include "iceberg/test/matchers.h" | |
| #include "iceberg/test/mock_catalog.h" | |
| #include "iceberg/test/update_test_base.h" | |
| namespace iceberg { | |
| class UpdateStatisticsTest : public UpdateTestBase { | |
| protected: | |
| // Helper function to create a statistics file | |
| std::shared_ptr<StatisticsFile> MakeStatisticsFile(int64_t snapshot_id, | |
| const std::string& path, | |
| int64_t file_size = 1024, | |
| int64_t footer_size = 128) { | |
| auto stats_file = std::make_shared<StatisticsFile>(); | |
| stats_file->snapshot_id = snapshot_id; | |
| stats_file->path = path; | |
| stats_file->file_size_in_bytes = file_size; | |
| stats_file->file_footer_size_in_bytes = footer_size; | |
| BlobMetadata blob; | |
| blob.type = "apache-datasketches-theta-v1"; | |
| blob.source_snapshot_id = snapshot_id; | |
| blob.source_snapshot_sequence_number = 1; | |
| blob.fields = {1, 2}; | |
| blob.properties = {{"ndv", "100"}}; | |
| stats_file->blob_metadata.push_back(blob); | |
| return stats_file; | |
| } | |
| // Helper to find statistics file by snapshot_id in the result vector | |
| std::shared_ptr<StatisticsFile> FindStatistics( | |
| const std::vector<std::pair<int64_t, std::shared_ptr<StatisticsFile>>>& to_set, | |
| int64_t snapshot_id) { | |
| auto it = std::ranges::find_if( | |
| to_set, [snapshot_id](const auto& p) { return p.first == snapshot_id; }); | |
| return it != to_set.end() ? it->second : nullptr; | |
| } | |
| }; | |
| class UpdateStatisticsRetryTest : public UpdateStatisticsTest { | |
| protected: | |
| void SetUp() override { | |
| UpdateStatisticsTest::SetUp(); | |
| mock_catalog_ = std::make_shared<::testing::NiceMock<MockCatalog>>(); | |
| ON_CALL(*mock_catalog_, LoadTable(::testing::_)) | |
| .WillByDefault([this](const TableIdentifier&) -> Result<std::shared_ptr<Table>> { | |
| ++load_table_count_; | |
| auto refreshed_metadata = std::make_shared<TableMetadata>(*table_->metadata()); | |
| auto refreshed_location = table_location_ + "/metadata/reload-" + | |
| std::to_string(load_table_count_) + ".metadata.json"; | |
| return Table::Make(table_->name(), std::move(refreshed_metadata), | |
| std::move(refreshed_location), table_->io(), mock_catalog_); | |
| }); | |
| auto result = Table::Make(table_->name(), table_->metadata(), | |
| std::string(table_->metadata_file_location()), table_->io(), | |
| mock_catalog_); | |
| ASSERT_THAT(result, IsOk()); | |
| mock_table_ = std::move(result.value()); | |
| } | |
| int load_table_count_ = 0; | |
| std::shared_ptr<::testing::NiceMock<MockCatalog>> mock_catalog_; | |
| std::shared_ptr<Table> mock_table_; | |
| }; | |
| TEST_F(UpdateStatisticsTest, EmptyUpdate) { | |
| ICEBERG_UNWRAP_OR_FAIL(auto update, table_->NewUpdateStatistics()); | |
| ICEBERG_UNWRAP_OR_FAIL(auto result, update->Apply()); | |
| EXPECT_TRUE(result.to_set.empty()); | |
| EXPECT_TRUE(result.to_remove.empty()); | |
| } | |
| TEST_F(UpdateStatisticsTest, SetStatistics) { | |
| ICEBERG_UNWRAP_OR_FAIL(auto update, table_->NewUpdateStatistics()); | |
| auto stats_file = | |
| MakeStatisticsFile(1, "/warehouse/test_table/metadata/stats-1.puffin"); | |
| update->SetStatistics(stats_file); | |
| ICEBERG_UNWRAP_OR_FAIL(auto result, update->Apply()); | |
| EXPECT_EQ(result.to_set.size(), 1); | |
| EXPECT_TRUE(result.to_remove.empty()); | |
| EXPECT_EQ(FindStatistics(result.to_set, 1), stats_file); | |
| } | |
| TEST_F(UpdateStatisticsTest, SetMultipleStatistics) { | |
| ICEBERG_UNWRAP_OR_FAIL(auto update, table_->NewUpdateStatistics()); | |
| auto stats_file_1 = | |
| MakeStatisticsFile(1, "/warehouse/test_table/metadata/stats-1.puffin"); | |
| auto stats_file_2 = | |
| MakeStatisticsFile(2, "/warehouse/test_table/metadata/stats-2.puffin"); | |
| update->SetStatistics(stats_file_1).SetStatistics(stats_file_2); | |
| ICEBERG_UNWRAP_OR_FAIL(auto result, update->Apply()); | |
| EXPECT_EQ(result.to_set.size(), 2); | |
| EXPECT_TRUE(result.to_remove.empty()); | |
| EXPECT_EQ(FindStatistics(result.to_set, 1), stats_file_1); | |
| EXPECT_EQ(FindStatistics(result.to_set, 2), stats_file_2); | |
| } | |
| TEST_F(UpdateStatisticsTest, RemoveStatistics) { | |
| ICEBERG_UNWRAP_OR_FAIL(auto update, table_->NewUpdateStatistics()); | |
| update->RemoveStatistics(1); | |
| ICEBERG_UNWRAP_OR_FAIL(auto result, update->Apply()); | |
| EXPECT_TRUE(result.to_set.empty()); | |
| EXPECT_EQ(result.to_remove.size(), 1); | |
| EXPECT_THAT(result.to_remove, ::testing::Contains(1)); | |
| } | |
| TEST_F(UpdateStatisticsTest, RemoveMultipleStatistics) { | |
| ICEBERG_UNWRAP_OR_FAIL(auto update, table_->NewUpdateStatistics()); | |
| update->RemoveStatistics(1).RemoveStatistics(2).RemoveStatistics(3); | |
| ICEBERG_UNWRAP_OR_FAIL(auto result, update->Apply()); | |
| EXPECT_TRUE(result.to_set.empty()); | |
| EXPECT_EQ(result.to_remove.size(), 3); | |
| EXPECT_THAT(result.to_remove, ::testing::UnorderedElementsAre(1, 2, 3)); | |
| } | |
| TEST_F(UpdateStatisticsTest, SetAndRemoveDifferentSnapshots) { | |
| ICEBERG_UNWRAP_OR_FAIL(auto update, table_->NewUpdateStatistics()); | |
| auto stats_file = | |
| MakeStatisticsFile(1, "/warehouse/test_table/metadata/stats-1.puffin"); | |
| update->SetStatistics(stats_file).RemoveStatistics(2); | |
| ICEBERG_UNWRAP_OR_FAIL(auto result, update->Apply()); | |
| EXPECT_EQ(result.to_set.size(), 1); | |
| EXPECT_EQ(FindStatistics(result.to_set, 1), stats_file); | |
| EXPECT_EQ(result.to_remove.size(), 1); | |
| EXPECT_THAT(result.to_remove, ::testing::Contains(2)); | |
| } | |
| TEST_F(UpdateStatisticsTest, ReplaceStatistics) { | |
| ICEBERG_UNWRAP_OR_FAIL(auto update, table_->NewUpdateStatistics()); | |
| auto stats_file_1 = | |
| MakeStatisticsFile(1, "/warehouse/test_table/metadata/stats-1a.puffin"); | |
| auto stats_file_2 = | |
| MakeStatisticsFile(1, "/warehouse/test_table/metadata/stats-1b.puffin", 2048, 256); | |
| // Set statistics for snapshot 1, then replace it | |
| update->SetStatistics(stats_file_1).SetStatistics(stats_file_2); | |
| ICEBERG_UNWRAP_OR_FAIL(auto result, update->Apply()); | |
| EXPECT_EQ(result.to_set.size(), 1); | |
| EXPECT_TRUE(result.to_remove.empty()); | |
| // Should have the second one (replacement) | |
| EXPECT_EQ(FindStatistics(result.to_set, 1), stats_file_2); | |
| EXPECT_NE(FindStatistics(result.to_set, 1), stats_file_1); | |
| } | |
| TEST_F(UpdateStatisticsTest, SetThenRemoveSameSnapshot) { | |
| ICEBERG_UNWRAP_OR_FAIL(auto update, table_->NewUpdateStatistics()); | |
| auto stats_file = | |
| MakeStatisticsFile(1, "/warehouse/test_table/metadata/stats-1.puffin"); | |
| // Set statistics for snapshot 1, then remove it | |
| update->SetStatistics(stats_file).RemoveStatistics(1); | |
| ICEBERG_UNWRAP_OR_FAIL(auto result, update->Apply()); | |
| EXPECT_TRUE(result.to_set.empty()); | |
| EXPECT_EQ(result.to_remove.size(), 1); | |
| EXPECT_THAT(result.to_remove, ::testing::Contains(1)); | |
| } | |
| TEST_F(UpdateStatisticsTest, RemoveThenSetSameSnapshot) { | |
| ICEBERG_UNWRAP_OR_FAIL(auto update, table_->NewUpdateStatistics()); | |
| auto stats_file = | |
| MakeStatisticsFile(1, "/warehouse/test_table/metadata/stats-1.puffin"); | |
| // Remove statistics for snapshot 1, then set new ones | |
| update->RemoveStatistics(1).SetStatistics(stats_file); | |
| ICEBERG_UNWRAP_OR_FAIL(auto result, update->Apply()); | |
| EXPECT_EQ(result.to_set.size(), 1); | |
| EXPECT_TRUE(result.to_remove.empty()); | |
| EXPECT_EQ(FindStatistics(result.to_set, 1), stats_file); | |
| } | |
| TEST_F(UpdateStatisticsTest, SetNullStatistics) { | |
| ICEBERG_UNWRAP_OR_FAIL(auto update, table_->NewUpdateStatistics()); | |
| update->SetStatistics(nullptr); | |
| auto result = update->Apply(); | |
| EXPECT_THAT(result, IsError(ErrorKind::kValidationFailed)); | |
| EXPECT_THAT(result, HasErrorMessage("Statistics file cannot be null")); | |
| } | |
| TEST_F(UpdateStatisticsTest, CommitSuccess) { | |
| // Test empty commit | |
| ICEBERG_UNWRAP_OR_FAIL(auto empty_update, table_->NewUpdateStatistics()); | |
| EXPECT_THAT(empty_update->Commit(), IsOk()); | |
| // Reload table after first commit | |
| ICEBERG_UNWRAP_OR_FAIL(auto reloaded, catalog_->LoadTable(table_ident_)); | |
| // Get a valid snapshot ID from the table | |
| ICEBERG_UNWRAP_OR_FAIL(auto current_snapshot, reloaded->current_snapshot()); | |
| int64_t snapshot_id = current_snapshot->snapshot_id; | |
| // Test commit with statistics changes | |
| ICEBERG_UNWRAP_OR_FAIL(auto update, reloaded->NewUpdateStatistics()); | |
| auto stats_file = | |
| MakeStatisticsFile(snapshot_id, "/warehouse/test_table/metadata/stats-1.puffin"); | |
| update->SetStatistics(stats_file); | |
| EXPECT_THAT(update->Commit(), IsOk()); | |
| // Verify the statistics were committed and persisted | |
| ICEBERG_UNWRAP_OR_FAIL(auto final_table, catalog_->LoadTable(table_ident_)); | |
| const auto& statistics = final_table->metadata()->statistics; | |
| EXPECT_EQ(statistics.size(), 1); | |
| EXPECT_EQ(*statistics[0], *stats_file); | |
| } | |
| TEST_F(UpdateStatisticsRetryTest, StandaloneCommitRetriesAfterConflict) { | |
| ICEBERG_UNWRAP_OR_FAIL(auto current_snapshot, mock_table_->current_snapshot()); | |
| auto stats_file = MakeStatisticsFile(current_snapshot->snapshot_id, | |
| "/warehouse/test_table/metadata/stats-1.puffin"); | |
| int update_call_count = 0; | |
| ON_CALL(*mock_catalog_, UpdateTable(::testing::_, ::testing::_, ::testing::_)) | |
| .WillByDefault([this, &update_call_count, stats_file]( | |
| const TableIdentifier&, | |
| const std::vector<std::unique_ptr<TableRequirement>>&, | |
| const std::vector<std::unique_ptr<TableUpdate>>&) | |
| -> Result<std::shared_ptr<Table>> { | |
| ++update_call_count; | |
| if (update_call_count == 1) { | |
| return CommitFailed("conflict on first attempt"); | |
| } | |
| auto committed_metadata = | |
| std::make_shared<TableMetadata>(*mock_table_->metadata()); | |
| committed_metadata->statistics = {stats_file}; | |
| return Table::Make(mock_table_->name(), std::move(committed_metadata), | |
| table_location_ + "/metadata/committed.metadata.json", | |
| mock_table_->io(), mock_catalog_); | |
| }); | |
| ICEBERG_UNWRAP_OR_FAIL(auto update, mock_table_->NewUpdateStatistics()); | |
| update->SetStatistics(stats_file); | |
| EXPECT_THAT(update->Commit(), IsOk()); | |
| EXPECT_EQ(update_call_count, 2); | |
| EXPECT_EQ(load_table_count_, 1); | |
| } | |
| } // namespace iceberg |