blob: d75e1ef233e06aea9f59a0e772cc8c154d89dab1 [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/update/fast_append.h"
#include <format>
#include <functional>
#include <limits>
#include <mutex>
#include <optional>
#include <stdexcept>
#include <string>
#include <thread>
#include <unordered_set>
#include <variant>
#include <vector>
#include <gmock/gmock.h>
#include <gtest/gtest.h>
#include "iceberg/avro/avro_register.h"
#include "iceberg/constants.h"
#include "iceberg/exception.h"
#include "iceberg/expression/expressions.h"
#include "iceberg/manifest/manifest_entry.h"
#include "iceberg/manifest/manifest_reader.h"
#include "iceberg/manifest/manifest_writer.h"
#include "iceberg/metrics/commit_report.h"
#include "iceberg/metrics/metrics_reporter.h"
#include "iceberg/metrics/metrics_reporters.h"
#include "iceberg/partition_spec.h"
#include "iceberg/schema.h"
#include "iceberg/snapshot.h"
#include "iceberg/table_metadata.h"
#include "iceberg/table_properties.h"
#include "iceberg/table_update.h"
#include "iceberg/test/executor.h"
#include "iceberg/test/matchers.h"
#include "iceberg/test/mock_catalog.h"
#include "iceberg/test/update_test_base.h"
#include "iceberg/transaction.h"
#include "iceberg/update/delete_files.h"
#include "iceberg/update/merge_append.h"
#include "iceberg/update/overwrite_files.h"
#include "iceberg/update/rewrite_files.h"
#include "iceberg/update/update_properties.h"
#include "iceberg/util/uuid.h"
namespace iceberg {
namespace {
class TestSnapshotUpdate : public SnapshotUpdate {
public:
explicit TestSnapshotUpdate(std::shared_ptr<TransactionContext> ctx)
: SnapshotUpdate(std::move(ctx)) {}
using SnapshotUpdate::ManifestPath;
using SnapshotUpdate::SnapshotId;
std::function<Status(int)> apply_callback;
std::function<Status()> cleanup_callback;
int applies = 0;
int cleanups = 0;
bool write_partial = false;
std::vector<std::string> partial_paths;
protected:
Status CleanUncommitted(const std::unordered_set<std::string>&) override {
++cleanups;
return cleanup_callback ? cleanup_callback() : Status{};
}
std::string operation() override { return "append"; }
Result<std::vector<ManifestFile>> Apply(const TableMetadata&,
const std::shared_ptr<Snapshot>&) override {
++applies;
if (write_partial) {
auto path = ManifestPath();
partial_paths.push_back(path);
ICEBERG_RETURN_UNEXPECTED(ctx_->table->io()->WriteFile(path, "partial manifest"));
}
if (apply_callback) {
ICEBERG_RETURN_UNEXPECTED(apply_callback(applies));
}
return std::vector<ManifestFile>{};
}
std::unordered_map<std::string, std::string> Summary() override { return {}; }
};
} // namespace
class FastAppendTest : public UpdateTestBase {
protected:
static void SetUpTestSuite() { avro::RegisterAll(); }
std::string MetadataResource() const override {
return "TableMetadataV2ValidMinimal.json";
}
void SetUp() override {
UpdateTestBase::SetUp();
// Get partition spec and schema from the base table
ICEBERG_UNWRAP_OR_FAIL(spec_, table_->spec());
ICEBERG_UNWRAP_OR_FAIL(schema_, table_->schema());
// Create test data files
file_a_ =
CreateDataFile("/data/file_a.parquet", /*size=*/100, /*partition_value=*/1024);
file_b_ =
CreateDataFile("/data/file_b.parquet", /*size=*/200, /*partition_value=*/2048);
}
std::shared_ptr<DataFile> CreateDataFile(const std::string& path, int64_t record_count,
int64_t size, int64_t partition_value = 0) {
auto data_file = std::make_shared<DataFile>();
data_file->content = DataFile::Content::kData;
data_file->file_path = table_location_ + path;
data_file->file_format = FileFormatType::kParquet;
// The base table has partition spec with identity(x), so we need 1 partition value
data_file->partition =
PartitionValues(std::vector<Literal>{Literal::Long(partition_value)});
data_file->file_size_in_bytes = size;
data_file->record_count = record_count;
data_file->partition_spec_id = spec_->spec_id();
return data_file;
}
Result<ManifestFile> WriteManifest(const std::string& path,
const std::vector<std::shared_ptr<DataFile>>& files,
std::optional<int64_t> snapshot_id = std::nullopt) {
ICEBERG_ASSIGN_OR_RAISE(
auto writer,
ManifestWriter::MakeWriter(table_->metadata()->format_version, snapshot_id, path,
file_io_, spec_, schema_, ManifestContent::kData));
for (const auto& file : files) {
ManifestEntry entry;
entry.status = ManifestStatus::kAdded;
entry.snapshot_id = std::nullopt;
entry.data_file = file;
ICEBERG_RETURN_UNEXPECTED(writer->WriteAddedEntry(entry));
}
ICEBERG_RETURN_UNEXPECTED(writer->Close());
return writer->ToManifestFile();
}
void SetManifestTargetSizeBytes(int64_t size_bytes) {
ICEBERG_UNWRAP_OR_FAIL(auto props, table_->NewUpdateProperties());
props->Set(std::string(TableProperties::kManifestTargetSizeBytes.key()),
std::to_string(size_bytes));
EXPECT_THAT(props->Commit(), IsOk());
EXPECT_THAT(table_->Refresh(), IsOk());
}
Result<std::vector<ManifestFile>> CurrentDataManifests() {
ICEBERG_ASSIGN_OR_RAISE(auto snapshot, table_->current_snapshot());
SnapshotReader snapshot_reader(snapshot.get());
ICEBERG_ASSIGN_OR_RAISE(auto manifests, snapshot_reader.DataManifests(file_io_));
return std::vector<ManifestFile>(manifests.begin(), manifests.end());
}
Result<std::vector<ManifestEntry>> ReadEntries(const ManifestFile& manifest) {
ICEBERG_ASSIGN_OR_RAISE(
auto spec, table_->metadata()->PartitionSpecById(manifest.partition_spec_id));
ICEBERG_ASSIGN_OR_RAISE(auto reader,
ManifestReader::Make(manifest, file_io_, schema_, spec));
return reader->Entries();
}
std::shared_ptr<PartitionSpec> spec_;
std::shared_ptr<Schema> schema_;
std::shared_ptr<DataFile> file_a_;
std::shared_ptr<DataFile> file_b_;
};
class SnapshotUpdateTest : public UpdateTestBase {};
TEST_F(SnapshotUpdateTest, StandaloneCommitRequiresSharedOwnership) {
ICEBERG_UNWRAP_OR_FAIL(auto ctx,
TransactionContext::Make(table_, TransactionKind::kUpdate));
auto update = std::make_unique<TestSnapshotUpdate>(ctx);
EXPECT_THAT(update->Commit(),
::testing::AllOf(
IsError(ErrorKind::kInvalidArgument),
HasErrorMessage("PendingUpdate must be owned by std::shared_ptr")));
EXPECT_FALSE(ctx->transaction.has_value());
}
TEST_F(FastAppendTest, AppendDataFile) {
std::shared_ptr<FastAppend> fast_append;
ICEBERG_UNWRAP_OR_FAIL(fast_append, table_->NewFastAppend());
fast_append->AppendFile(file_a_);
EXPECT_THAT(fast_append->Commit(), IsOk());
EXPECT_THAT(table_->Refresh(), IsOk());
ICEBERG_UNWRAP_OR_FAIL(auto snapshot, table_->current_snapshot());
EXPECT_EQ(snapshot->summary.at("added-data-files"), "1");
EXPECT_EQ(snapshot->summary.at("added-records"), "100");
EXPECT_EQ(snapshot->summary.at("added-files-size"), "1024");
EXPECT_EQ(snapshot->summary.at(SnapshotSummaryFields::kManifestsCreated), "1");
EXPECT_EQ(snapshot->summary.at(SnapshotSummaryFields::kManifestsKept), "0");
EXPECT_EQ(snapshot->summary.at(SnapshotSummaryFields::kManifestsReplaced), "0");
}
TEST_F(FastAppendTest, AppendMultipleDataFiles) {
std::shared_ptr<FastAppend> fast_append;
ICEBERG_UNWRAP_OR_FAIL(fast_append, table_->NewFastAppend());
fast_append->AppendFile(file_a_);
fast_append->AppendFile(file_b_);
EXPECT_THAT(fast_append->Commit(), IsOk());
EXPECT_THAT(table_->Refresh(), IsOk());
ICEBERG_UNWRAP_OR_FAIL(auto snapshot, table_->current_snapshot());
EXPECT_EQ(snapshot->summary.at("added-data-files"), "2");
EXPECT_EQ(snapshot->summary.at("added-records"), "300");
EXPECT_EQ(snapshot->summary.at("added-files-size"), "3072");
}
TEST_F(FastAppendTest, AppendManyFiles) {
std::shared_ptr<FastAppend> fast_append;
ICEBERG_UNWRAP_OR_FAIL(fast_append, table_->NewFastAppend());
int64_t total_records = 0;
int64_t total_size = 0;
constexpr int kFileCount = 10;
for (int index = 0; index < kFileCount; ++index) {
auto data_file = CreateDataFile(std::format("/data/file_{}.parquet", index),
/*record_count=*/10 + index,
/*size=*/100 + index * 10,
/*partition_value=*/index % 2);
total_records += data_file->record_count;
total_size += data_file->file_size_in_bytes;
fast_append->AppendFile(std::move(data_file));
}
EXPECT_THAT(fast_append->Commit(), IsOk());
EXPECT_THAT(table_->Refresh(), IsOk());
ICEBERG_UNWRAP_OR_FAIL(auto snapshot, table_->current_snapshot());
EXPECT_EQ(snapshot->summary.at("added-data-files"), std::to_string(kFileCount));
EXPECT_EQ(snapshot->summary.at("added-records"), std::to_string(total_records));
EXPECT_EQ(snapshot->summary.at("added-files-size"), std::to_string(total_size));
}
TEST_F(FastAppendTest, WriteManifestGroups) {
SetManifestTargetSizeBytes(std::numeric_limits<int64_t>::max());
test::ThreadExecutor executor;
std::shared_ptr<FastAppend> fast_append;
ICEBERG_UNWRAP_OR_FAIL(fast_append, table_->NewFastAppend());
fast_append->WriteManifestsWith(executor, 3);
constexpr size_t kFileCount = 15'000;
constexpr size_t kGroupSize = 7'500;
std::vector<std::shared_ptr<DataFile>> files;
files.reserve(kFileCount);
for (size_t index = 0; index < kFileCount; ++index) {
auto data_file =
CreateDataFile(std::format("/data/group_{}.parquet", index),
/*record_count=*/1, /*size=*/1, static_cast<int64_t>(index % 2));
fast_append->AppendFile(data_file);
files.push_back(std::move(data_file));
}
EXPECT_THAT(fast_append->Commit(), IsOk());
EXPECT_THAT(table_->Refresh(), IsOk());
EXPECT_EQ(executor.submit_count(), 2);
ICEBERG_UNWRAP_OR_FAIL(auto manifests, CurrentDataManifests());
ASSERT_EQ(manifests.size(), 2U);
for (size_t group_index = 0; group_index < manifests.size(); ++group_index) {
ASSERT_TRUE(manifests[group_index].added_files_count.has_value());
EXPECT_EQ(manifests[group_index].added_files_count.value(), kGroupSize);
ICEBERG_UNWRAP_OR_FAIL(auto entries, ReadEntries(manifests[group_index]));
ASSERT_EQ(entries.size(), kGroupSize);
const size_t offset = group_index * kGroupSize;
for (size_t entry_index = 0; entry_index < entries.size(); ++entry_index) {
ASSERT_NE(entries[entry_index].data_file, nullptr);
EXPECT_EQ(entries[entry_index].data_file->file_path,
files[offset + entry_index]->file_path);
}
}
}
TEST_F(FastAppendTest, InvalidManifestParallelism) {
test::ThreadExecutor executor;
std::shared_ptr<FastAppend> fast_append;
ICEBERG_UNWRAP_OR_FAIL(fast_append, table_->NewFastAppend());
fast_append->WriteManifestsWith(executor, 0);
fast_append->AppendFile(file_a_);
auto result = fast_append->Commit();
EXPECT_THAT(result, IsError(ErrorKind::kValidationFailed));
EXPECT_THAT(
result,
HasErrorMessage("Manifest write parallelism must be greater than 0, but was: 0"));
EXPECT_EQ(executor.submit_count(), 0);
}
TEST_F(FastAppendTest, EmptyTableAppendUpdatesSequenceNumbers) {
EXPECT_THAT(table_->current_snapshot(), HasErrorMessage("No current snapshot"));
const int64_t base_sequence_number = table_->metadata()->last_sequence_number;
std::shared_ptr<FastAppend> fast_append;
ICEBERG_UNWRAP_OR_FAIL(fast_append, table_->NewFastAppend());
fast_append->AppendFile(file_a_);
EXPECT_THAT(fast_append->Commit(), IsOk());
EXPECT_THAT(table_->Refresh(), IsOk());
ICEBERG_UNWRAP_OR_FAIL(auto snapshot, table_->current_snapshot());
EXPECT_EQ(snapshot->sequence_number, base_sequence_number + 1);
EXPECT_EQ(table_->metadata()->last_sequence_number, base_sequence_number + 1);
}
TEST_F(FastAppendTest, AppendNullFile) {
std::shared_ptr<FastAppend> fast_append;
ICEBERG_UNWRAP_OR_FAIL(fast_append, table_->NewFastAppend());
fast_append->AppendFile(nullptr);
auto result = fast_append->Commit();
EXPECT_FALSE(result.has_value());
EXPECT_THAT(result, HasErrorMessage("Invalid data file: null"));
EXPECT_THAT(table_->current_snapshot(), HasErrorMessage("No current snapshot"));
}
TEST_F(FastAppendTest, AbortIgnoresCleanupDeleteFailure) {
ICEBERG_UNWRAP_OR_FAIL(auto txn, table_->NewTransaction());
ICEBERG_UNWRAP_OR_FAIL(auto fast_append, txn->NewFastAppend());
fast_append->AppendFile(file_a_);
std::vector<std::string> deleted_paths;
fast_append->DeleteWith([&](const std::string& path) {
deleted_paths.push_back(path);
return IOError("delete failed");
});
EXPECT_THAT(fast_append->Commit(), IsOk());
EXPECT_THAT(txn->Abort(), IsOk());
std::unordered_set<std::string> unique_deleted_paths(deleted_paths.begin(),
deleted_paths.end());
EXPECT_THAT(unique_deleted_paths, ::testing::SizeIs(2));
const auto delete_attempts = deleted_paths.size();
EXPECT_THAT(txn->Abort(), IsOk());
EXPECT_EQ(deleted_paths.size(), delete_attempts);
}
TEST_F(FastAppendTest, CommitFailureIgnoresCleanupDeleteFailure) {
auto mock = std::make_shared<::testing::NiceMock<MockCatalog>>();
EXPECT_CALL(*mock, UpdateTable(::testing::_, ::testing::_, ::testing::_))
.Times(1)
.WillOnce(::testing::Return(CommitFailed("injected commit failure")));
auto metadata = std::make_shared<TableMetadata>(*table_->metadata());
metadata->properties.Set(TableProperties::kCommitNumRetries, 0);
ICEBERG_UNWRAP_OR_FAIL(
auto table,
Table::Make(table_->name(), metadata, std::string(table_->metadata_file_location()),
file_io_, mock));
ICEBERG_UNWRAP_OR_FAIL(auto txn, table->NewTransaction());
ICEBERG_UNWRAP_OR_FAIL(auto append, txn->NewFastAppend());
std::vector<std::string> deleted_paths;
append->AppendFile(file_a_).DeleteWith([&](const std::string& path) {
deleted_paths.push_back(path);
return IOError("delete failed");
});
ASSERT_THAT(append->Commit(), IsOk());
ICEBERG_UNWRAP_OR_FAIL(auto snapshot, txn->current().Snapshot());
SnapshotReader snapshot_reader(snapshot.get());
ICEBERG_UNWRAP_OR_FAIL(auto manifests, snapshot_reader.Manifests(file_io_));
ASSERT_THAT(manifests, ::testing::SizeIs(1));
EXPECT_TRUE(deleted_paths.empty());
EXPECT_THAT(txn->Commit(),
::testing::AllOf(IsError(ErrorKind::kCommitFailed),
HasErrorMessage("injected commit failure")));
EXPECT_EQ(txn->state(), TransactionState::kFailed);
std::unordered_set<std::string> unique_deleted_paths(deleted_paths.begin(),
deleted_paths.end());
EXPECT_THAT(unique_deleted_paths,
::testing::UnorderedElementsAre(manifests[0].manifest_path,
snapshot->manifest_list));
const auto cleanup_attempts = deleted_paths.size();
EXPECT_THAT(txn->Abort(), IsOk());
EXPECT_GT(deleted_paths.size(), cleanup_attempts);
unique_deleted_paths = {deleted_paths.begin(), deleted_paths.end()};
EXPECT_THAT(unique_deleted_paths,
::testing::UnorderedElementsAre(manifests[0].manifest_path,
snapshot->manifest_list));
EXPECT_FALSE(ReloadMetadata()->Snapshot().has_value());
}
TEST_F(FastAppendTest, TransactionApplyFailureCleansUpStagedFiles) {
ICEBERG_UNWRAP_OR_FAIL(auto txn, table_->NewTransaction());
ICEBERG_UNWRAP_OR_FAIL(auto fast_append, txn->NewFastAppend());
std::vector<std::string> deleted_paths;
fast_append->DeleteWith([&](const std::string& path) {
deleted_paths.push_back(path);
return file_io_->DeleteFile(path);
});
fast_append->AppendFile(file_a_);
EXPECT_THAT(fast_append->Commit(), IsOk());
ICEBERG_UNWRAP_OR_FAIL(auto failed_append, txn->NewFastAppend());
failed_append->AppendFile(nullptr);
EXPECT_THAT(failed_append->Commit(), IsError(ErrorKind::kValidationFailed));
EXPECT_THAT(deleted_paths, ::testing::SizeIs(2U));
EXPECT_EQ(txn->state(), TransactionState::kFailed);
EXPECT_THAT(txn->Commit(), IsError(ErrorKind::kValidationFailed));
EXPECT_THAT(txn->Abort(), IsOk());
EXPECT_THAT(deleted_paths, ::testing::SizeIs(2U));
}
TEST_F(FastAppendTest, RebaseCopiesAppendManifestAgain) {
table_->metadata()->format_version = 1;
ASSERT_THAT(
TableMetadataUtil::Write(*file_io_, std::string(table_->metadata_file_location()),
*table_->metadata()),
IsOk());
const auto path = table_location_ + "/metadata/input.avro";
ICEBERG_UNWRAP_OR_FAIL(auto manifest, WriteManifest(path, {file_a_}));
std::shared_ptr<Transaction> txn;
std::vector<std::string> attempt_manifests;
std::vector<std::string> attempt_lists;
FailCommits(2, [&](int attempt) {
SCOPED_TRACE(attempt);
if (attempt < 2) {
ICEBERG_UNWRAP_OR_FAIL(auto concurrent, catalog_->LoadTable(table_ident_));
ICEBERG_UNWRAP_OR_FAIL(auto properties, concurrent->NewUpdateProperties());
properties->Set(std::format("conflict.{}", attempt), "true");
ASSERT_THAT(properties->Commit(), IsOk());
}
ICEBERG_UNWRAP_OR_FAIL(auto snapshot, txn->current().Snapshot());
SnapshotReader snapshot_reader(snapshot.get());
ICEBERG_UNWRAP_OR_FAIL(auto manifests, snapshot_reader.DataManifests(file_io_));
ASSERT_THAT(manifests, ::testing::SizeIs(1));
const auto& manifest_path = manifests[0].manifest_path;
EXPECT_NE(manifest_path, path);
EXPECT_THAT(attempt_manifests, ::testing::Not(::testing::Contains(manifest_path)));
attempt_manifests.push_back(manifest_path);
attempt_lists.push_back(snapshot->manifest_list);
ICEBERG_UNWRAP_OR_FAIL(auto entries, ReadEntries(manifests[0]));
ASSERT_THAT(entries, ::testing::SizeIs(1));
ASSERT_NE(entries[0].data_file, nullptr);
EXPECT_EQ(entries[0].data_file->file_path, file_a_->file_path);
});
ICEBERG_UNWRAP_OR_FAIL(txn, table_->NewTransaction());
ICEBERG_UNWRAP_OR_FAIL(auto fast_append, txn->NewFastAppend());
std::vector<std::string> deleted_paths;
fast_append->DeleteWith([&](const std::string& deleted_path) {
deleted_paths.push_back(deleted_path);
if (deleted_paths.size() == 1) {
throw std::runtime_error("cleanup callback threw");
}
if (deleted_paths.size() == 2) {
return Status(IOError("cleanup returned an error"));
}
return file_io_->DeleteFile(deleted_path);
});
fast_append->AppendManifest(manifest);
ASSERT_THAT(fast_append->Commit(), IsOk());
ASSERT_THAT(txn->Commit(), IsOk());
ASSERT_THAT(table_->Refresh(), IsOk());
ICEBERG_UNWRAP_OR_FAIL(auto manifests, CurrentDataManifests());
ASSERT_EQ(manifests.size(), 1U);
ASSERT_THAT(attempt_manifests, ::testing::SizeIs(3));
ASSERT_THAT(attempt_lists, ::testing::SizeIs(3));
EXPECT_EQ(manifests[0].manifest_path, attempt_manifests[2]);
std::unordered_set<std::string> unique_deleted_paths(deleted_paths.begin(),
deleted_paths.end());
EXPECT_THAT(unique_deleted_paths,
::testing::UnorderedElementsAre(attempt_manifests[0], attempt_lists[0],
attempt_manifests[1], attempt_lists[1]));
EXPECT_THAT(deleted_paths, ::testing::Not(::testing::Contains(path)));
EXPECT_THAT(deleted_paths,
::testing::Not(::testing::Contains(manifests[0].manifest_path)));
EXPECT_THAT(file_io_->ReadFile(path, std::nullopt), IsOk());
EXPECT_THAT(file_io_->ReadFile(attempt_lists[2], std::nullopt), IsOk());
}
TEST_F(FastAppendTest, AppendManifestWithSnapshotIdInheritance) {
const auto path = table_location_ + "/metadata/inherited.avro";
ICEBERG_UNWRAP_OR_FAIL(auto manifest, WriteManifest(path, {file_a_, file_b_}));
ASSERT_FALSE(manifest.added_snapshot_id.has_value());
ICEBERG_UNWRAP_OR_FAIL(auto append, table_->NewFastAppend());
append->AppendManifest(manifest);
EXPECT_THAT(append->Commit(), IsOk());
EXPECT_THAT(table_->Refresh(), IsOk());
ICEBERG_UNWRAP_OR_FAIL(auto snapshot, table_->current_snapshot());
ICEBERG_UNWRAP_OR_FAIL(auto manifests, CurrentDataManifests());
ASSERT_THAT(manifests, ::testing::SizeIs(1));
EXPECT_EQ(manifests[0].manifest_path, path);
EXPECT_EQ(manifests[0].added_snapshot_id, snapshot->snapshot_id);
ICEBERG_UNWRAP_OR_FAIL(auto entries, ReadEntries(manifests[0]));
ASSERT_THAT(entries, ::testing::SizeIs(2));
EXPECT_EQ(entries[0].snapshot_id, snapshot->snapshot_id);
EXPECT_EQ(entries[1].snapshot_id, snapshot->snapshot_id);
}
TEST_F(FastAppendTest, AppendManifestWithExplicitInvalidSnapshotIdIsCopied) {
const auto path = table_location_ + "/metadata/invalid.avro";
ICEBERG_UNWRAP_OR_FAIL(auto manifest,
WriteManifest(path, {file_a_, file_b_}, kInvalidSnapshotId));
ASSERT_EQ(manifest.added_snapshot_id, kInvalidSnapshotId);
ICEBERG_UNWRAP_OR_FAIL(auto append, table_->NewFastAppend());
append->AppendManifest(manifest);
EXPECT_THAT(append->Commit(), IsOk());
EXPECT_THAT(table_->Refresh(), IsOk());
ICEBERG_UNWRAP_OR_FAIL(auto snapshot, table_->current_snapshot());
ICEBERG_UNWRAP_OR_FAIL(auto manifests, CurrentDataManifests());
ASSERT_THAT(manifests, ::testing::SizeIs(1));
EXPECT_NE(manifests[0].manifest_path, path);
EXPECT_EQ(manifests[0].added_snapshot_id, snapshot->snapshot_id);
ICEBERG_UNWRAP_OR_FAIL(auto entries, ReadEntries(manifests[0]));
ASSERT_THAT(entries, ::testing::SizeIs(2));
EXPECT_EQ(entries[0].snapshot_id, snapshot->snapshot_id);
EXPECT_EQ(entries[1].snapshot_id, snapshot->snapshot_id);
}
TEST_F(FastAppendTest, AppendDuplicateFile) {
std::shared_ptr<FastAppend> fast_append;
ICEBERG_UNWRAP_OR_FAIL(fast_append, table_->NewFastAppend());
fast_append->AppendFile(file_a_);
fast_append->AppendFile(file_a_); // Add same file twice
EXPECT_THAT(fast_append->Commit(), IsOk());
EXPECT_THAT(table_->Refresh(), IsOk());
ICEBERG_UNWRAP_OR_FAIL(auto snapshot, table_->current_snapshot());
EXPECT_EQ(snapshot->summary.at("added-data-files"), "1");
EXPECT_EQ(snapshot->summary.at("added-records"), "100");
}
TEST_F(FastAppendTest, SetSnapshotProperty) {
std::shared_ptr<FastAppend> fast_append;
ICEBERG_UNWRAP_OR_FAIL(fast_append, table_->NewFastAppend());
fast_append->Set("custom-property", "custom-value");
fast_append->AppendFile(file_a_);
EXPECT_THAT(fast_append->Commit(), IsOk());
EXPECT_THAT(table_->Refresh(), IsOk());
ICEBERG_UNWRAP_OR_FAIL(auto snapshot, table_->current_snapshot());
EXPECT_EQ(snapshot->summary.at("custom-property"), "custom-value");
}
TEST_F(SnapshotUpdateTest, ConcurrentManifestPaths) {
ICEBERG_UNWRAP_OR_FAIL(auto ctx,
TransactionContext::Make(table_, TransactionKind::kUpdate));
TestSnapshotUpdate update(std::move(ctx));
constexpr int kThreadCount = 8;
constexpr int kPathsPerThread = 32;
std::vector<std::string> paths(kThreadCount * kPathsPerThread);
std::vector<std::thread> threads;
threads.reserve(kThreadCount);
for (int thread_index = 0; thread_index < kThreadCount; ++thread_index) {
threads.emplace_back([&, thread_index] {
for (int path_index = 0; path_index < kPathsPerThread; ++path_index) {
paths[thread_index * kPathsPerThread + path_index] = update.ManifestPath();
}
});
}
for (auto& thread : threads) {
thread.join();
}
std::unordered_set<std::string> unique_paths(paths.begin(), paths.end());
ASSERT_EQ(unique_paths.size(), paths.size());
for (const auto& path : paths) {
EXPECT_THAT(path, ::testing::HasSubstr("/metadata/"));
EXPECT_THAT(path, ::testing::HasSubstr("-m"));
}
}
namespace {
class CapturingReporter final : public MetricsReporter {
public:
Status Report(const MetricsReport& report) override {
reports_.push_back(report);
return {};
}
const std::vector<MetricsReport>& reports() const { return reports_; }
void clear() { reports_.clear(); }
private:
std::vector<MetricsReport> reports_;
};
void RegisterCapturingReporter() {
static std::once_flag flag;
std::call_once(flag, [] {
(void)MetricsReporters::Register(
"fast.append.test.reporter",
[](const auto&) -> Result<std::unique_ptr<MetricsReporter>> {
return std::make_unique<CapturingReporter>();
});
});
}
} // namespace
class FastAppendMetricsTest : public ::testing::Test {
protected:
static void SetUpTestSuite() {
avro::RegisterAll();
RegisterCapturingReporter();
}
void SetUp() override {
table_ident_ = TableIdentifier{.name = "metrics_test_table"};
table_location_ = "/warehouse/metrics_test_table";
file_io_ = arrow::ArrowFileSystemFileIO::MakeMockFileIO();
ICEBERG_UNWRAP_OR_FAIL(
catalog_, InMemoryCatalog::Make("metrics_test_catalog", file_io_, "/warehouse/",
{{std::string(kMetricsReporterImpl),
"fast.append.test.reporter"}}));
auto arrow_fs = std::dynamic_pointer_cast<::arrow::fs::internal::MockFileSystem>(
static_cast<arrow::ArrowFileSystemFileIO&>(*file_io_).fs());
ASSERT_TRUE(arrow_fs != nullptr);
ASSERT_TRUE(arrow_fs->CreateDir(table_location_ + "/metadata").ok());
auto metadata_location = std::format("{}/metadata/00001-{}.metadata.json",
table_location_, Uuid::GenerateV7().ToString());
ICEBERG_UNWRAP_OR_FAIL(
auto metadata, ReadTableMetadataFromResource("TableMetadataV2ValidMinimal.json"));
metadata->location = table_location_;
ASSERT_THAT(TableMetadataUtil::Write(*file_io_, metadata_location, *metadata),
IsOk());
ICEBERG_UNWRAP_OR_FAIL(table_,
catalog_->RegisterTable(table_ident_, metadata_location));
reporter_ = std::dynamic_pointer_cast<CapturingReporter>(table_->reporter());
ASSERT_NE(reporter_, nullptr);
ICEBERG_UNWRAP_OR_FAIL(spec_, table_->spec());
ICEBERG_UNWRAP_OR_FAIL(schema_, table_->schema());
}
std::shared_ptr<DataFile> MakeDataFile(const std::string& path,
int64_t partition_value = 1024) {
auto data_file = std::make_shared<DataFile>();
data_file->content = DataFile::Content::kData;
data_file->file_path = table_location_ + path;
data_file->file_format = FileFormatType::kParquet;
data_file->partition =
PartitionValues(std::vector<Literal>{Literal::Long(partition_value)});
data_file->file_size_in_bytes = 1024;
data_file->record_count = 100;
data_file->partition_spec_id = spec_->spec_id();
return data_file;
}
TableIdentifier table_ident_;
std::string table_location_;
std::shared_ptr<FileIO> file_io_;
std::shared_ptr<InMemoryCatalog> catalog_;
std::shared_ptr<Table> table_;
std::shared_ptr<PartitionSpec> spec_;
std::shared_ptr<Schema> schema_;
std::shared_ptr<CapturingReporter> reporter_;
};
TEST_F(FastAppendMetricsTest, CommitReportFiredAfterFastAppend) {
std::shared_ptr<FastAppend> fast_append;
ICEBERG_UNWRAP_OR_FAIL(fast_append, table_->NewFastAppend());
fast_append->AppendFile(MakeDataFile("/data/file_a.parquet"));
ASSERT_THAT(fast_append->Commit(), IsOk());
ASSERT_THAT(table_->Refresh(), IsOk());
ICEBERG_UNWRAP_OR_FAIL(auto snapshot, table_->current_snapshot());
const auto& reports = reporter_->reports();
ASSERT_EQ(reports.size(), 1u);
ASSERT_TRUE(std::holds_alternative<CommitReport>(reports[0]));
const auto& report = std::get<CommitReport>(reports[0]);
EXPECT_EQ(report.table_name, table_->full_name());
EXPECT_EQ(report.table_name, "metrics_test_catalog." + table_ident_.ToString());
EXPECT_EQ(report.snapshot_id, snapshot->snapshot_id);
EXPECT_EQ(report.sequence_number, snapshot->sequence_number);
EXPECT_EQ(report.operation, "append");
const auto& metrics = report.commit_metrics;
ASSERT_TRUE(metrics.attempts.has_value());
EXPECT_EQ(metrics.attempts->value, 1);
ASSERT_TRUE(metrics.added_data_files.has_value());
EXPECT_EQ(metrics.added_data_files->value, 1);
ASSERT_TRUE(metrics.total_data_files.has_value());
EXPECT_EQ(metrics.total_data_files->value, 1);
ASSERT_TRUE(metrics.added_records.has_value());
EXPECT_EQ(metrics.added_records->value, 100);
ASSERT_TRUE(metrics.total_records.has_value());
EXPECT_EQ(metrics.total_records->value, 100);
ASSERT_TRUE(metrics.added_files_size_bytes.has_value());
EXPECT_EQ(metrics.added_files_size_bytes->value, 1024);
ASSERT_TRUE(metrics.total_files_size_bytes.has_value());
EXPECT_EQ(metrics.total_files_size_bytes->value, 1024);
ASSERT_TRUE(metrics.created_manifest_count.has_value());
EXPECT_EQ(metrics.created_manifest_count->value, 1);
}
TEST_F(FastAppendMetricsTest, ReportWithOverridesTableReporter) {
auto override_reporter = std::make_shared<CapturingReporter>();
std::shared_ptr<FastAppend> fast_append;
ICEBERG_UNWRAP_OR_FAIL(fast_append, table_->NewFastAppend());
fast_append->ReportWith(override_reporter);
fast_append->AppendFile(MakeDataFile("/data/file_a.parquet"));
ASSERT_THAT(fast_append->Commit(), IsOk());
ASSERT_EQ(override_reporter->reports().size(), 1u);
EXPECT_TRUE(std::holds_alternative<CommitReport>(override_reporter->reports()[0]));
EXPECT_TRUE(reporter_->reports().empty());
}
TEST_F(FastAppendMetricsTest, CapturesTableReporterWhenUpdateIsCreated) {
auto replacement_reporter = std::make_shared<CapturingReporter>();
auto mock_catalog = std::make_shared<::testing::NiceMock<MockCatalog>>();
ON_CALL(*mock_catalog, UpdateTable(::testing::_, ::testing::_, ::testing::_))
.WillByDefault([this, &mock_catalog, &replacement_reporter](
const TableIdentifier&,
const std::vector<std::unique_ptr<TableRequirement>>&,
const std::vector<std::unique_ptr<TableUpdate>>&)
-> Result<std::shared_ptr<Table>> {
return Table::Make(table_->name(), table_->metadata(),
std::string(table_->metadata_file_location()), table_->io(),
mock_catalog, table_->full_name(), replacement_reporter);
});
ICEBERG_UNWRAP_OR_FAIL(
auto mock_table,
Table::Make(table_->name(), table_->metadata(),
std::string(table_->metadata_file_location()), table_->io(),
mock_catalog, table_->full_name(), reporter_));
ICEBERG_UNWRAP_OR_FAIL(auto fast_append, mock_table->NewFastAppend());
fast_append->AppendFile(MakeDataFile("/data/file_a.parquet"));
ASSERT_THAT(fast_append->Commit(), IsOk());
ASSERT_EQ(reporter_->reports().size(), 1u);
EXPECT_TRUE(replacement_reporter->reports().empty());
}
// An existing snapshot must not be reused as the report for a non-snapshot update.
TEST_F(FastAppendMetricsTest, PropertyOnlyCommitOnTableWithSnapshotDoesNotReport) {
std::shared_ptr<FastAppend> fast_append;
ICEBERG_UNWRAP_OR_FAIL(fast_append, table_->NewFastAppend());
fast_append->AppendFile(MakeDataFile("/data/file_a.parquet"));
ASSERT_THAT(fast_append->Commit(), IsOk());
ASSERT_EQ(reporter_->reports().size(), 1u);
reporter_->clear();
ASSERT_THAT(table_->Refresh(), IsOk());
std::shared_ptr<UpdateProperties> update_props;
ICEBERG_UNWRAP_OR_FAIL(update_props, table_->NewUpdateProperties());
update_props->Set("test-key", "test-value");
ASSERT_THAT(update_props->Commit(), IsOk());
EXPECT_TRUE(reporter_->reports().empty());
}
// StageOnly() adds a snapshot to table metadata without making it current. The
// CommitReport must still be fired for the staged snapshot itself.
TEST_F(FastAppendMetricsTest, CommitReportFiredForStageOnlyCommit) {
std::shared_ptr<FastAppend> fast_append;
ICEBERG_UNWRAP_OR_FAIL(fast_append, table_->NewFastAppend());
fast_append->StageOnly();
fast_append->AppendFile(MakeDataFile("/data/file_a.parquet"));
ASSERT_THAT(fast_append->Commit(), IsOk());
ASSERT_THAT(table_->Refresh(), IsOk());
// The staged snapshot never became current.
EXPECT_EQ(table_->metadata()->current_snapshot_id, kInvalidSnapshotId);
const auto& reports = reporter_->reports();
ASSERT_EQ(reports.size(), 1u);
ASSERT_TRUE(std::holds_alternative<CommitReport>(reports[0]));
const auto& report = std::get<CommitReport>(reports[0]);
EXPECT_NE(report.snapshot_id, kInvalidSnapshotId);
EXPECT_TRUE(table_->metadata()->SnapshotById(report.snapshot_id).has_value());
}
TEST_F(FastAppendMetricsTest, ReporterOverrideAppliesOnlyToItsOwnUpdate) {
auto override_reporter = std::make_shared<CapturingReporter>();
ICEBERG_UNWRAP_OR_FAIL(auto txn, table_->NewTransaction());
ICEBERG_UNWRAP_OR_FAIL(auto first_append, txn->NewFastAppend());
first_append->ReportWith(override_reporter);
first_append->AppendFile(MakeDataFile("/data/file_a.parquet"));
ASSERT_THAT(first_append->Commit(), IsOk());
EXPECT_TRUE(override_reporter->reports().empty());
EXPECT_TRUE(reporter_->reports().empty());
ICEBERG_UNWRAP_OR_FAIL(auto second_append, txn->NewFastAppend());
second_append->AppendFile(MakeDataFile("/data/file_b.parquet", 2048));
ASSERT_THAT(second_append->Commit(), IsOk());
EXPECT_TRUE(override_reporter->reports().empty());
EXPECT_TRUE(reporter_->reports().empty());
ASSERT_THAT(txn->Commit(), IsOk());
ASSERT_THAT(table_->Refresh(), IsOk());
ASSERT_EQ(override_reporter->reports().size(), 1u);
ASSERT_TRUE(std::holds_alternative<CommitReport>(override_reporter->reports()[0]));
ASSERT_EQ(reporter_->reports().size(), 1u);
ASSERT_TRUE(std::holds_alternative<CommitReport>(reporter_->reports()[0]));
const auto& first_report = std::get<CommitReport>(override_reporter->reports()[0]);
const auto& second_report = std::get<CommitReport>(reporter_->reports()[0]);
ICEBERG_UNWRAP_OR_FAIL(auto current_snapshot, table_->current_snapshot());
EXPECT_EQ(second_report.snapshot_id, current_snapshot->snapshot_id);
ASSERT_TRUE(current_snapshot->parent_snapshot_id.has_value());
EXPECT_EQ(first_report.snapshot_id, current_snapshot->parent_snapshot_id.value());
EXPECT_EQ(second_report.table_name, table_->full_name());
ASSERT_TRUE(first_report.commit_metrics.attempts.has_value());
EXPECT_EQ(first_report.commit_metrics.attempts->value, 1);
ASSERT_TRUE(second_report.commit_metrics.attempts.has_value());
EXPECT_EQ(second_report.commit_metrics.attempts->value, 1);
}
TEST_F(FastAppendMetricsTest, ExplicitRetryWithoutMetadataChangeDoesNotReplay) {
auto mock_catalog = std::make_shared<::testing::NiceMock<MockCatalog>>();
ON_CALL(*mock_catalog, LoadTable(::testing::_))
.WillByDefault([this, &mock_catalog](
const TableIdentifier&) -> Result<std::shared_ptr<Table>> {
ICEBERG_ASSIGN_OR_RAISE(
auto metadata,
TableMetadataUtil::Read(*table_->io(),
std::string(table_->metadata_file_location())));
return Table::Make(table_->name(), std::move(metadata),
std::string(table_->metadata_file_location()), table_->io(),
mock_catalog, table_->full_name(), reporter_);
});
int update_call_count = 0;
ON_CALL(*mock_catalog, UpdateTable(::testing::_, ::testing::_, ::testing::_))
.WillByDefault([this, &mock_catalog, &update_call_count](
const TableIdentifier&,
const std::vector<std::unique_ptr<TableRequirement>>&,
const std::vector<std::unique_ptr<TableUpdate>>& updates)
-> Result<std::shared_ptr<Table>> {
++update_call_count;
EXPECT_TRUE(reporter_->reports().empty());
if (update_call_count == 1) {
return CommitFailed("conflict on first attempt");
}
EXPECT_FALSE(updates.empty());
return Table::Make(table_->name(), table_->metadata(),
std::string(table_->metadata_file_location()), table_->io(),
mock_catalog, table_->full_name(), reporter_);
});
ICEBERG_UNWRAP_OR_FAIL(
auto mock_table,
Table::Make(table_->name(), table_->metadata(),
std::string(table_->metadata_file_location()), table_->io(),
mock_catalog, table_->full_name(), reporter_));
ICEBERG_UNWRAP_OR_FAIL(auto txn, mock_table->NewTransaction());
ICEBERG_UNWRAP_OR_FAIL(auto fast_append, txn->NewFastAppend());
fast_append->AppendFile(MakeDataFile("/data/file_a.parquet"));
ASSERT_THAT(fast_append->Commit(), IsOk());
ASSERT_TRUE(reporter_->reports().empty());
ASSERT_THAT(txn->Commit(), IsOk());
EXPECT_EQ(update_call_count, 2);
ASSERT_EQ(reporter_->reports().size(), 1u);
ASSERT_TRUE(std::holds_alternative<CommitReport>(reporter_->reports()[0]));
const auto& report = std::get<CommitReport>(reporter_->reports()[0]);
ASSERT_TRUE(report.commit_metrics.attempts.has_value());
EXPECT_EQ(report.commit_metrics.attempts->value, 1);
}
TEST_F(FastAppendTest, StandaloneRetryReplaysWithoutMetadataChange) {
FailCommits(1);
ICEBERG_UNWRAP_OR_FAIL(auto ctx,
TransactionContext::Make(table_, TransactionKind::kUpdate));
auto update = std::make_shared<TestSnapshotUpdate>(ctx);
ASSERT_THAT(update->Commit(), IsOk());
EXPECT_EQ(update->applies, 2);
}
TEST_F(FastAppendMetricsTest, CommitStateUnknownDoesNotReport) {
auto mock_catalog = std::make_shared<::testing::NiceMock<MockCatalog>>();
ON_CALL(*mock_catalog, UpdateTable(::testing::_, ::testing::_, ::testing::_))
.WillByDefault([](const TableIdentifier&,
const std::vector<std::unique_ptr<TableRequirement>>&,
const std::vector<std::unique_ptr<TableUpdate>>&)
-> Result<std::shared_ptr<Table>> {
return CommitStateUnknown("unknown commit state");
});
ICEBERG_UNWRAP_OR_FAIL(
auto mock_table,
Table::Make(table_->name(), table_->metadata(),
std::string(table_->metadata_file_location()), table_->io(),
mock_catalog, table_->full_name(), reporter_));
ICEBERG_UNWRAP_OR_FAIL(auto fast_append, mock_table->NewFastAppend());
fast_append->AppendFile(MakeDataFile("/data/file_a.parquet"));
EXPECT_THAT(fast_append->Commit(), IsError(ErrorKind::kCommitStateUnknown));
EXPECT_TRUE(reporter_->reports().empty());
}
TEST_F(FastAppendTest, StandaloneReplayFailureStopsRetryAndCleansPartialFiles) {
struct ReplayFailure {
const char* name;
std::function<Status()> callback;
ErrorKind kind;
const char* message;
};
const std::vector<ReplayFailure> failures{
{.name = "retryable status",
.callback = []() -> Status {
return RetryableValidationFailed("replay validation failed");
},
.kind = ErrorKind::kValidationFailed,
.message = "replay validation failed"},
{.name = "unknown status",
.callback = []() -> Status {
return CommitStateUnknown("replay validation failed");
},
.kind = ErrorKind::kValidationFailed,
.message = "replay validation failed"},
{.name = "standard exception",
.callback = []() -> Status { throw std::runtime_error("replay callback threw"); },
.kind = ErrorKind::kValidationFailed,
.message = "replay callback threw"},
{.name = "unknown exception",
.callback = []() -> Status { throw 42; },
.kind = ErrorKind::kValidationFailed,
.message = "Transaction preparation threw an unknown exception"}};
for (const auto& failure : failures) {
SCOPED_TRACE(failure.name);
auto mock = std::make_shared<::testing::NiceMock<MockCatalog>>();
EXPECT_CALL(*mock, UpdateTable(::testing::_, ::testing::_, ::testing::_))
.Times(1)
.WillOnce(::testing::Return(CommitFailed("first conflict")));
EXPECT_CALL(*mock, LoadTable(::testing::_)).Times(1).WillOnce([&](const auto& name) {
return catalog_->LoadTable(name);
});
ICEBERG_UNWRAP_OR_FAIL(
auto table,
Table::Make(table_->name(), table_->metadata(),
std::string(table_->metadata_file_location()), file_io_, mock));
ICEBERG_UNWRAP_OR_FAIL(auto ctx,
TransactionContext::Make(table, TransactionKind::kUpdate));
auto update = std::make_shared<TestSnapshotUpdate>(ctx);
update->write_partial = true;
update->apply_callback = [&failure](int attempt) -> Status {
if (attempt == 2) {
return failure.callback();
}
return {};
};
std::vector<std::string> deleted;
update->DeleteWith([&](const std::string& path) {
deleted.push_back(path);
return file_io_->DeleteFile(path);
});
EXPECT_THAT(update->Commit(), ::testing::AllOf(IsError(failure.kind),
HasErrorMessage(failure.message)));
EXPECT_EQ(update->applies, 2);
EXPECT_THAT(deleted, ::testing::SizeIs(3U));
for (const auto& path : update->partial_paths) {
EXPECT_THAT(deleted, ::testing::Contains(path));
EXPECT_FALSE(file_io_->ReadFile(path, std::nullopt).has_value());
}
EXPECT_THAT(update->Commit(), IsError(ErrorKind::kValidationFailed));
EXPECT_FALSE(ctx->transaction.has_value());
EXPECT_THAT(deleted, ::testing::SizeIs(3U));
}
}
TEST_F(FastAppendTest, CleanupHookFailureDoesNotSkipStagedFiles) {
struct CleanupFailure {
const char* name;
std::function<Status()> callback;
};
const std::vector<CleanupFailure> failures{
{.name = "error status",
.callback = []() -> Status { return IOError("cleanup failed"); }},
{.name = "standard exception",
.callback = []() -> Status { throw std::runtime_error("cleanup threw"); }},
{.name = "unknown exception", .callback = []() -> Status { throw 42; }}};
for (const auto& failure : failures) {
SCOPED_TRACE(failure.name);
for (bool apply_fails : {false, true}) {
SCOPED_TRACE(apply_fails);
ICEBERG_UNWRAP_OR_FAIL(auto table, catalog_->LoadTable(table_ident_));
ICEBERG_UNWRAP_OR_FAIL(auto ctx,
TransactionContext::Make(table, TransactionKind::kUpdate));
auto update = std::make_shared<TestSnapshotUpdate>(ctx);
update->write_partial = true;
update->cleanup_callback = failure.callback;
if (apply_fails) {
update->apply_callback = [](int) -> Status {
return ValidationFailed("original apply failure");
};
}
std::vector<std::string> deleted;
update->DeleteWith([&](const std::string& path) {
deleted.push_back(path);
return file_io_->DeleteFile(path);
});
auto result = update->Commit();
if (apply_fails) {
EXPECT_THAT(result, ::testing::AllOf(IsError(ErrorKind::kValidationFailed),
HasErrorMessage("original apply failure")));
} else {
ASSERT_THAT(result, IsOk());
ICEBERG_UNWRAP_OR_FAIL(auto snapshot, ctx->table->current_snapshot());
EXPECT_THAT(file_io_->ReadFile(snapshot->manifest_list, std::nullopt), IsOk());
}
EXPECT_EQ(update->applies, 1);
EXPECT_EQ(update->cleanups, 1);
ASSERT_THAT(update->partial_paths, ::testing::SizeIs(1));
EXPECT_THAT(deleted, ::testing::ElementsAre(update->partial_paths[0]));
EXPECT_FALSE(
file_io_->ReadFile(update->partial_paths[0], std::nullopt).has_value());
EXPECT_THAT(update->Commit(), IsError(ErrorKind::kValidationFailed));
EXPECT_EQ(update->cleanups, 1);
EXPECT_THAT(deleted, ::testing::SizeIs(1));
}
}
}
class CallbackReporter : public MetricsReporter {
public:
explicit CallbackReporter(std::function<Status()> callback)
: callback_(std::move(callback)) {}
Status Report(const MetricsReport&) override { return callback_(); }
private:
std::function<Status()> callback_;
};
TEST_F(FastAppendTest, ExplicitSuccessReportsAllUpdatesAndIgnoresReporterFailures) {
ICEBERG_UNWRAP_OR_FAIL(auto txn, table_->NewTransaction());
ICEBERG_UNWRAP_OR_FAIL(auto first, txn->NewFastAppend());
std::shared_ptr<FastAppend> second;
int reports = 0;
first->AppendFile(file_a_).ReportWith(
std::make_shared<CallbackReporter>([&]() -> Status {
++reports;
throw std::runtime_error("report failure");
}));
ASSERT_THAT(first->Commit(), IsOk());
ICEBERG_UNWRAP_OR_FAIL(second, txn->NewFastAppend());
second->AppendFile(file_b_).ReportWith(
std::make_shared<CallbackReporter>([&]() -> Status {
++reports;
return IOError("another report failure");
}));
ASSERT_THAT(second->Commit(), IsOk());
ASSERT_THAT(txn->Commit(), IsOk());
EXPECT_EQ(txn->state(), TransactionState::kCommitted);
EXPECT_EQ(reports, 2);
EXPECT_THAT(txn->Commit(), IsError(ErrorKind::kValidationFailed));
EXPECT_EQ(reports, 2);
}
TEST_F(FastAppendTest, TransientConflictThenUnknownPreservesInitialAttempt) {
auto mock = std::make_shared<::testing::NiceMock<MockCatalog>>();
EXPECT_CALL(*mock, UpdateTable(::testing::_, ::testing::_, ::testing::_))
.WillOnce(::testing::Return(CommitFailed("conflict")))
.WillOnce(::testing::Return(CommitStateUnknown("unknown")));
EXPECT_CALL(*mock, LoadTable(::testing::_)).WillOnce([&](const auto& name) {
return catalog_->LoadTable(name);
});
ICEBERG_UNWRAP_OR_FAIL(
auto table,
Table::Make(table_->name(), table_->metadata(),
std::string(table_->metadata_file_location()), file_io_, mock));
ICEBERG_UNWRAP_OR_FAIL(auto txn, table->NewTransaction());
ICEBERG_UNWRAP_OR_FAIL(auto append, txn->NewFastAppend());
std::vector<std::string> deleted_paths;
append->AppendFile(file_a_).DeleteWith([&](const std::string& path) {
deleted_paths.push_back(path);
return file_io_->DeleteFile(path);
});
ASSERT_THAT(append->Commit(), IsOk());
EXPECT_THAT(txn->Commit(), IsError(ErrorKind::kCommitStateUnknown));
EXPECT_EQ(txn->state(), TransactionState::kCommitStateUnknown);
EXPECT_TRUE(deleted_paths.empty());
ICEBERG_UNWRAP_OR_FAIL(auto snapshot, txn->current().Snapshot());
EXPECT_THAT(file_io_->ReadFile(snapshot->manifest_list, std::nullopt), IsOk());
SnapshotReader snapshot_reader(snapshot.get());
ICEBERG_UNWRAP_OR_FAIL(auto manifests, snapshot_reader.Manifests(file_io_));
ASSERT_EQ(manifests.size(), 1U);
EXPECT_THAT(file_io_->ReadFile(manifests[0].manifest_path, std::nullopt), IsOk());
EXPECT_THAT(txn->Abort(), IsError(ErrorKind::kValidationFailed));
EXPECT_THAT(txn->Commit(), IsError(ErrorKind::kValidationFailed));
EXPECT_THAT(append->Commit(), IsError(ErrorKind::kValidationFailed));
EXPECT_TRUE(deleted_paths.empty());
}
TEST_F(FastAppendTest, ApplyFailureAfterWritingCleansEveryUpdate) {
ICEBERG_UNWRAP_OR_FAIL(auto txn, table_->NewTransaction());
int deletes = 0;
auto delete_file = [&](const std::string& path) -> Status {
++deletes;
if (deletes == 1) {
throw std::runtime_error("delete callback threw");
}
return IOError("delete failure");
};
ICEBERG_UNWRAP_OR_FAIL(auto first, txn->NewFastAppend());
first->AppendFile(file_a_).DeleteWith(delete_file);
ASSERT_THAT(first->Commit(), IsOk());
ICEBERG_UNWRAP_OR_FAIL(auto failed, txn->NewRewriteFiles());
failed->DeleteDataFile(file_a_).AddDataFile(file_b_).DeleteWith(delete_file);
EXPECT_THAT(failed->Commit(), HasErrorMessage("Invalid REPLACE operation"));
EXPECT_EQ(txn->state(), TransactionState::kFailed);
EXPECT_GE(deletes, 4);
const int cleanup_attempts = deletes;
EXPECT_THAT(txn->Commit(), IsError(ErrorKind::kValidationFailed));
EXPECT_THAT(txn->Abort(), IsOk());
EXPECT_GT(deletes, cleanup_attempts);
const int abort_cleanup_attempts = deletes;
EXPECT_THAT(txn->Abort(), IsOk());
EXPECT_EQ(deletes, abort_cleanup_attempts);
}
TEST_F(FastAppendTest, ReplayBecomingNoopCleansStagingWithoutReporting) {
for (bool other_update : {false, true}) {
SCOPED_TRACE(other_update);
auto mock = std::make_shared<::testing::NiceMock<MockCatalog>>();
std::shared_ptr<Table> refreshed;
std::shared_ptr<TableMetadata> refreshed_metadata;
int attempts = 0;
EXPECT_CALL(*mock, UpdateTable(::testing::_, ::testing::_, ::testing::_))
.Times(other_update ? 2 : 1)
.WillRepeatedly([&](const auto&, const auto&,
const auto& changes) -> Result<std::shared_ptr<Table>> {
const bool first_attempt = ++attempts == 1;
auto builder = TableMetadataBuilder::BuildFrom(
first_attempt ? table_->metadata().get() : refreshed_metadata.get());
for (const auto& change : changes) {
if (first_attempt && change->kind() == TableUpdate::Kind::kSetProperties) {
continue;
}
if (!first_attempt) {
EXPECT_EQ(change->kind(), TableUpdate::Kind::kSetProperties);
}
change->ApplyTo(*builder);
}
ICEBERG_ASSIGN_OR_RAISE(std::shared_ptr<TableMetadata> metadata,
builder->Build());
if (first_attempt) {
// Use independent files for the refreshed snapshot; generated paths are
// owned by the update that created them.
metadata->snapshots.back() =
std::make_shared<Snapshot>(*metadata->snapshots.back());
auto snapshot = metadata->snapshots.back();
snapshot->manifest_list = table_location_ + "/metadata/concurrent-list.avro";
ICEBERG_ASSIGN_OR_RAISE(
auto writer, ManifestListWriter::MakeWriter(
metadata->format_version, snapshot->snapshot_id,
snapshot->parent_snapshot_id, snapshot->manifest_list,
file_io_, snapshot->sequence_number));
ICEBERG_RETURN_UNEXPECTED(writer->Close());
}
// Simulate the same logical snapshot already becoming current on refresh.
refreshed_metadata = metadata;
ICEBERG_ASSIGN_OR_RAISE(
refreshed,
Table::Make(table_->name(), std::move(metadata),
std::string(table_->metadata_file_location()) + ".refreshed",
file_io_, mock));
if (first_attempt) {
return CommitFailed("conflict");
}
return refreshed;
});
EXPECT_CALL(*mock, LoadTable(::testing::_))
.WillOnce(
[&](const auto&) -> Result<std::shared_ptr<Table>> { return refreshed; });
ICEBERG_UNWRAP_OR_FAIL(
auto table,
Table::Make(table_->name(), table_->metadata(),
std::string(table_->metadata_file_location()), file_io_, mock));
std::shared_ptr<Transaction> txn;
if (other_update) {
ICEBERG_UNWRAP_OR_FAIL(txn, table->NewTransaction());
}
ICEBERG_UNWRAP_OR_FAIL(auto append,
txn ? txn->NewFastAppend() : table->NewFastAppend());
int reports = 0;
int deletes = 0;
append->AppendFile(file_a_)
.ReportWith(std::make_shared<CallbackReporter>([&]() -> Status {
++reports;
return {};
}))
.DeleteWith([&](const std::string& path) {
++deletes;
return file_io_->DeleteFile(path);
});
ASSERT_THAT(append->Commit(), IsOk());
if (txn) {
ICEBERG_UNWRAP_OR_FAIL(auto props, txn->NewUpdateProperties());
props->Set("effective", "value");
ASSERT_THAT(props->Commit(), IsOk());
ICEBERG_UNWRAP_OR_FAIL(auto committed, txn->Commit());
EXPECT_EQ(committed->properties().configs().at("effective"), "value");
EXPECT_EQ(txn->state(), TransactionState::kCommitted);
}
EXPECT_EQ(deletes, 4);
EXPECT_EQ(reports, 0);
EXPECT_THAT(append->Commit(), IsError(ErrorKind::kValidationFailed));
EXPECT_EQ(deletes, 4);
}
}
TEST_F(FastAppendTest, StagedNoopIsCleanedAndCanBecomeEffectiveOnRebase) {
for (bool rebase : {false, true}) {
SCOPED_TRACE(rebase);
auto mock = std::make_shared<::testing::NiceMock<MockCatalog>>();
ON_CALL(*mock, LoadTable(::testing::_)).WillByDefault([&](const auto& name) {
return catalog_->LoadTable(name);
});
ON_CALL(*mock, UpdateTable(::testing::_, ::testing::_, ::testing::_))
.WillByDefault(
[&](const auto& name, const auto& requirements, const auto& changes) {
return catalog_->UpdateTable(name, requirements, changes);
});
EXPECT_CALL(*mock, LoadTable(::testing::_)).Times(rebase ? 1 : 0);
EXPECT_CALL(*mock, UpdateTable(::testing::_, ::testing::_, ::testing::_))
.Times(rebase ? 1 : 0);
ICEBERG_UNWRAP_OR_FAIL(auto ctx,
TransactionContext::Make(table_, TransactionKind::kUpdate));
auto update = std::make_shared<TestSnapshotUpdate>(ctx);
update->write_partial = true;
auto builder = TableMetadataBuilder::BuildFrom(table_->metadata().get());
auto existing = std::make_shared<Snapshot>(Snapshot{
.snapshot_id = update->SnapshotId(),
.sequence_number = table_->metadata()->NextSequenceNumber(),
.timestamp_ms = CurrentTimePointMs(),
.manifest_list = table_location_ + "/metadata/existing-noop-list.avro",
.summary = {{SnapshotSummaryFields::kOperation, DataOperation::kAppend}},
.schema_id = table_->metadata()->current_schema_id,
});
ICEBERG_UNWRAP_OR_FAIL(
auto writer, ManifestListWriter::MakeWriter(table_->metadata()->format_version,
existing->snapshot_id, std::nullopt,
existing->manifest_list, file_io_,
existing->sequence_number));
ASSERT_THAT(writer->Close(), IsOk());
builder->SetBranchSnapshot(existing, std::string(SnapshotRef::kMainBranch));
ICEBERG_UNWRAP_OR_FAIL(std::shared_ptr<TableMetadata> metadata, builder->Build());
// Prepare a base whose current snapshot has this update's logical ID.
ICEBERG_UNWRAP_OR_FAIL(
ctx->table,
Table::Make(table_->name(), metadata,
std::string(table_->metadata_file_location()) + ".synthetic",
file_io_, mock));
ctx->metadata_builder = TableMetadataBuilder::BuildFrom(metadata.get());
std::vector<std::string> deleted;
update->DeleteWith([&](const std::string& path) {
deleted.push_back(path);
return file_io_->DeleteFile(path);
});
if (rebase) {
// The table refreshes before the update commits its original builder.
// Initial Apply is a no-op; internal replay sees the real empty table.
ASSERT_THAT(ctx->table->Refresh(), IsOk());
}
ASSERT_THAT(update->Commit(), IsOk());
EXPECT_EQ(update->applies, rebase ? 2 : 1);
EXPECT_THAT(deleted, ::testing::SizeIs(rebase ? 3U : 2U));
EXPECT_THAT(file_io_->ReadFile(existing->manifest_list, std::nullopt), IsOk());
EXPECT_THAT(update->Commit(), IsError(ErrorKind::kValidationFailed));
}
}
TEST_F(FastAppendTest, ReplayFailurePartwayThroughCleansAllUncommittedGenerations) {
file_a_->partition = PartitionValues({Literal::Long(1)});
file_b_->partition = PartitionValues({Literal::Long(3)});
ICEBERG_UNWRAP_OR_FAIL(auto initial, table_->NewFastAppend());
initial->AppendFile(file_a_);
ASSERT_THAT(initial->Commit(), IsOk());
ASSERT_THAT(table_->Refresh(), IsOk());
ICEBERG_UNWRAP_OR_FAIL(auto original_snapshot, table_->current_snapshot());
auto concurrent_file = CreateDataFile("/data/concurrent.parquet", 1, 10, 1);
auto replacement = CreateDataFile("/data/replacement.parquet", 100, 50, 1);
auto mock = std::make_shared<::testing::NiceMock<MockCatalog>>();
EXPECT_CALL(*mock, UpdateTable(::testing::_, ::testing::_, ::testing::_))
.Times(1)
.WillOnce(
[&](const auto&, const auto&, const auto&) -> Result<std::shared_ptr<Table>> {
ICEBERG_ASSIGN_OR_RAISE(auto latest, catalog_->LoadTable(table_ident_));
ICEBERG_ASSIGN_OR_RAISE(auto concurrent, latest->NewFastAppend());
concurrent->AppendFile(concurrent_file);
ICEBERG_RETURN_UNEXPECTED(concurrent->Commit());
return CommitFailed("concurrent append");
});
EXPECT_CALL(*mock, LoadTable(::testing::_)).Times(1).WillOnce([&](const auto& name) {
return catalog_->LoadTable(name);
});
ICEBERG_UNWRAP_OR_FAIL(
auto table,
Table::Make(table_->name(), table_->metadata(),
std::string(table_->metadata_file_location()), file_io_, mock));
ICEBERG_UNWRAP_OR_FAIL(auto txn, table->NewTransaction());
std::vector<std::string> deleted;
std::shared_ptr<FastAppend> append;
auto delete_file = [&](const std::string& path) {
deleted.push_back(path);
return file_io_->DeleteFile(path);
};
ICEBERG_UNWRAP_OR_FAIL(append, txn->NewFastAppend());
append->AppendFile(file_b_).DeleteWith(delete_file);
ASSERT_THAT(append->Commit(), IsOk());
ICEBERG_UNWRAP_OR_FAIL(auto overwrite, txn->NewOverwrite());
overwrite->DeleteFile(file_a_)
.AddFile(replacement)
.ValidateFromSnapshot(original_snapshot->snapshot_id)
.ConflictDetectionFilter(Expressions::Equal("x", Literal::Long(1)))
.ValidateNoConflictingData()
.DeleteWith(delete_file);
ASSERT_THAT(overwrite->Commit(), IsOk());
EXPECT_THAT(txn->Commit(), HasErrorMessage("Found conflicting files"));
EXPECT_EQ(txn->state(), TransactionState::kFailed);
EXPECT_GE(deleted.size(), 6U);
std::unordered_set<std::string> unique_deletes(deleted.begin(), deleted.end());
EXPECT_EQ(unique_deletes.size(), deleted.size());
EXPECT_THAT(txn->Abort(), IsOk());
EXPECT_EQ(deleted.size(), unique_deletes.size());
std::unordered_set<std::string> committed_paths;
auto metadata = ReloadMetadata();
for (const auto& snapshot : metadata->snapshots) {
committed_paths.insert(snapshot->manifest_list);
SnapshotReader snapshot_reader(snapshot.get());
ICEBERG_UNWRAP_OR_FAIL(auto manifests, snapshot_reader.Manifests(file_io_));
for (const auto& manifest : manifests) {
committed_paths.insert(manifest.manifest_path);
}
}
auto& io = static_cast<arrow::ArrowFileSystemFileIO&>(*file_io_);
::arrow::fs::FileSelector selector;
selector.base_dir = table_location_ + "/metadata";
selector.recursive = true;
auto files = io.fs()->GetFileInfo(selector);
ASSERT_TRUE(files.ok()) << files.status();
std::unordered_set<std::string> remaining;
for (const auto& file : *files) {
if (file.path().ends_with(".avro")) {
remaining.insert(file.path());
}
}
EXPECT_EQ(remaining, committed_paths);
}
} // namespace iceberg