blob: cb55de9d0b02a4752ba22e7765ac84ebe40e2ff0 [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/cherry_pick_operation.h"
#include <format>
#include <memory>
#include <optional>
#include <string>
#include <vector>
#include <gmock/gmock.h>
#include <gtest/gtest.h>
#include "iceberg/avro/avro_register.h"
#include "iceberg/manifest/manifest_entry.h"
#include "iceberg/manifest/manifest_reader.h"
#include "iceberg/partition_spec.h"
#include "iceberg/row/partition_values.h"
#include "iceberg/schema.h"
#include "iceberg/snapshot.h"
#include "iceberg/table.h"
#include "iceberg/table_metadata.h"
#include "iceberg/table_properties.h"
#include "iceberg/test/matchers.h"
#include "iceberg/test/update_test_base.h"
#include "iceberg/transaction.h"
#include "iceberg/update/delete_files.h"
#include "iceberg/update/fast_append.h"
#include "iceberg/update/overwrite_files.h"
#include "iceberg/update/replace_partitions.h"
#include "iceberg/update/snapshot_manager.h"
#include "iceberg/update/update_partition_spec.h"
#include "iceberg/update/update_properties.h"
#include "iceberg/util/macros.h"
namespace iceberg {
class CherryPickOperationTest : public UpdateTestBase {
public:
static void SetUpTestSuite() { avro::RegisterAll(); }
protected:
/// Most cherry-pick behavior is version independent; only tests that assert
/// version-dependent metadata derive from CherryPickFormatVersionTest.
virtual int8_t format_version() const { return 2; }
std::string MetadataResource() const override {
return format_version() == 3 ? "TableMetadataV3ValidMinimal.json"
: "TableMetadataV2ValidMinimal.json";
}
void SetUp() override {
UpdateTestBase::SetUp();
if (format_version() == 1) {
auto metadata = std::make_shared<TableMetadata>(*table_->metadata());
metadata->format_version = 1;
metadata->last_sequence_number = 0;
const auto metadata_location =
std::format("{}/metadata/v1.metadata.json", table_location_);
ASSERT_THAT(TableMetadataUtil::Write(*file_io_, metadata_location, *metadata),
IsOk());
ASSERT_THAT(catalog_->DropTable(table_ident_, /*purge=*/false), IsOk());
ICEBERG_UNWRAP_OR_FAIL(table_,
catalog_->RegisterTable(table_ident_, metadata_location));
}
ICEBERG_UNWRAP_OR_FAIL(spec_, table_->spec());
ICEBERG_UNWRAP_OR_FAIL(schema_, table_->schema());
file_a_ = MakeDataFile("/data/file_a.parquet", /*partition_x=*/1L);
file_b_ = MakeDataFile("/data/file_b.parquet", /*partition_x=*/2L);
replacement_a_ = MakeDataFile("/data/file_a_replacement.parquet", /*partition_x=*/1L);
conflict_a_ = MakeDataFile("/data/file_a_conflict.parquet", /*partition_x=*/1L);
}
std::shared_ptr<DataFile> MakeDataFile(const std::string& path, int64_t partition_x) {
auto f = std::make_shared<DataFile>();
f->content = DataFile::Content::kData;
f->file_path = table_location_ + path;
f->file_format = FileFormatType::kParquet;
f->partition = PartitionValues(std::vector<Literal>{Literal::Long(partition_x)});
f->file_size_in_bytes = 1024;
f->record_count = 100;
f->partition_spec_id = spec_->spec_id();
return f;
}
Result<std::vector<ManifestEntry>> LiveDataEntries(const Snapshot& snapshot) {
std::vector<ManifestEntry> entries;
SnapshotReader snapshot_reader(&snapshot);
ICEBERG_ASSIGN_OR_RAISE(auto manifests, snapshot_reader.DataManifests(file_io_));
for (const auto& manifest : manifests) {
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));
ICEBERG_ASSIGN_OR_RAISE(auto live, reader->LiveEntries());
entries.insert(entries.end(), live.begin(), live.end());
}
return entries;
}
Result<std::vector<std::string>> LiveDataFilePaths() {
ICEBERG_ASSIGN_OR_RAISE(auto snapshot, table_->current_snapshot());
ICEBERG_ASSIGN_OR_RAISE(auto entries, LiveDataEntries(*snapshot));
std::vector<std::string> paths;
for (const auto& entry : entries) {
paths.push_back(entry.data_file->file_path);
}
return paths;
}
Result<int64_t> CommitAppend(const std::shared_ptr<DataFile>& file) {
ICEBERG_ASSIGN_OR_RAISE(auto append, table_->NewFastAppend());
append->AppendFile(file);
ICEBERG_RETURN_UNEXPECTED(append->Commit());
ICEBERG_RETURN_UNEXPECTED(table_->Refresh());
ICEBERG_ASSIGN_OR_RAISE(auto snapshot, table_->current_snapshot());
return snapshot->snapshot_id;
}
Result<int64_t> CommitAppendToBranch(const std::string& branch,
const std::shared_ptr<DataFile>& file) {
ICEBERG_ASSIGN_OR_RAISE(auto append, table_->NewFastAppend());
// ToBranch() returns SnapshotUpdate& in C++20 compat builds, so keep the
// FastAppend-only call separate.
append->ToBranch(branch);
append->AppendFile(file);
ICEBERG_RETURN_UNEXPECTED(append->Commit());
ICEBERG_RETURN_UNEXPECTED(table_->Refresh());
return table_->metadata()->refs.at(branch)->snapshot_id;
}
Result<int64_t> StageAppend(const std::shared_ptr<DataFile>& file,
const std::optional<std::string>& wap_id = std::nullopt) {
ICEBERG_ASSIGN_OR_RAISE(auto append, table_->NewFastAppend());
append->StageOnly();
if (wap_id.has_value()) {
append->Set(SnapshotSummaryFields::kWAPId, *wap_id);
}
append->AppendFile(file);
ICEBERG_RETURN_UNEXPECTED(append->Commit());
ICEBERG_RETURN_UNEXPECTED(table_->Refresh());
return table_->metadata()->snapshots.back()->snapshot_id;
}
Result<int64_t> StageReplacePartitions(const std::shared_ptr<DataFile>& file) {
ICEBERG_ASSIGN_OR_RAISE(auto transaction, table_->NewTransaction());
ICEBERG_ASSIGN_OR_RAISE(auto replace, transaction->NewReplacePartitions());
replace->StageOnly();
replace->Set(SnapshotSummaryFields::kReplacePartitions, "true");
replace->AddFile(file);
ICEBERG_RETURN_UNEXPECTED(replace->Commit());
ICEBERG_RETURN_UNEXPECTED(transaction->Commit());
ICEBERG_RETURN_UNEXPECTED(table_->Refresh());
return table_->metadata()->snapshots.back()->snapshot_id;
}
Result<int64_t> StageOverwrite(const std::shared_ptr<DataFile>& added,
const std::shared_ptr<DataFile>& removed) {
ICEBERG_ASSIGN_OR_RAISE(auto transaction, table_->NewTransaction());
ICEBERG_ASSIGN_OR_RAISE(auto overwrite, transaction->NewOverwrite());
overwrite->StageOnly();
overwrite->AddFile(added).DeleteFile(removed);
ICEBERG_RETURN_UNEXPECTED(overwrite->Commit());
ICEBERG_RETURN_UNEXPECTED(transaction->Commit());
ICEBERG_RETURN_UNEXPECTED(table_->Refresh());
return table_->metadata()->snapshots.back()->snapshot_id;
}
Status RollbackTo(int64_t snapshot_id) {
ICEBERG_ASSIGN_OR_RAISE(auto manager, table_->NewSnapshotManager());
manager->RollbackTo(snapshot_id);
ICEBERG_RETURN_UNEXPECTED(manager->Commit());
return table_->Refresh();
}
Status CommitDelete(const std::string& path) {
ICEBERG_ASSIGN_OR_RAISE(auto delete_files, table_->NewDeleteFiles());
delete_files->DeleteFile(path);
ICEBERG_RETURN_UNEXPECTED(delete_files->Commit());
return table_->Refresh();
}
Result<std::shared_ptr<Transaction>> StageCherrypick(int64_t snapshot_id) {
ICEBERG_ASSIGN_OR_RAISE(auto transaction, table_->NewTransaction());
ICEBERG_ASSIGN_OR_RAISE(auto manager, transaction->NewSnapshotManager());
manager->Cherrypick(snapshot_id);
ICEBERG_RETURN_UNEXPECTED(manager->Commit());
return transaction;
}
Status Cherrypick(int64_t snapshot_id) {
ICEBERG_ASSIGN_OR_RAISE(auto manager, table_->NewSnapshotManager());
manager->Cherrypick(snapshot_id);
ICEBERG_RETURN_UNEXPECTED(manager->Commit());
return table_->Refresh();
}
/// Commit an append through a second table handle while another transaction is still
/// open, so that transaction must rebase and replay at commit time.
Result<int64_t> CommitFromOtherHandle(const std::shared_ptr<DataFile>& file) {
ICEBERG_ASSIGN_OR_RAISE(auto other_table, catalog_->LoadTable(table_ident_));
ICEBERG_ASSIGN_OR_RAISE(auto append, other_table->NewFastAppend());
append->AppendFile(file);
ICEBERG_RETURN_UNEXPECTED(append->Commit());
ICEBERG_RETURN_UNEXPECTED(other_table->Refresh());
ICEBERG_ASSIGN_OR_RAISE(auto snapshot, other_table->current_snapshot());
return snapshot->snapshot_id;
}
/// A fast-forward publishes the staged snapshot itself: no new snapshot, no new
/// sequence number or row ids, and one new snapshot-log entry for it.
void ExpectFastForward(const std::shared_ptr<TableMetadata>& before,
int64_t staged_id) {
ICEBERG_UNWRAP_OR_FAIL(auto snapshot, table_->current_snapshot());
EXPECT_EQ(snapshot->snapshot_id, staged_id);
EXPECT_EQ(table_->metadata()->snapshots.size(), before->snapshots.size());
EXPECT_EQ(table_->metadata()->last_sequence_number, before->last_sequence_number);
EXPECT_EQ(table_->metadata()->next_row_id, before->next_row_id);
ASSERT_EQ(table_->metadata()->snapshot_log.size(), before->snapshot_log.size() + 1);
EXPECT_EQ(table_->metadata()->snapshot_log.back().snapshot_id, staged_id);
EXPECT_FALSE(snapshot->summary.contains(SnapshotSummaryFields::kSourceSnapshotId));
EXPECT_FALSE(snapshot->summary.contains(SnapshotSummaryFields::kPublishedWAPId));
}
/// A pick replayed on top of a moved state writes a new snapshot that is linked to the
/// picked one and parented on the state it was applied to.
void ExpectReplayedPick(int64_t staged_id, int64_t parent_snapshot_id) {
ICEBERG_UNWRAP_OR_FAIL(auto snapshot, table_->current_snapshot());
EXPECT_NE(snapshot->snapshot_id, staged_id);
EXPECT_EQ(snapshot->parent_snapshot_id, parent_snapshot_id);
EXPECT_EQ(snapshot->summary.at(SnapshotSummaryFields::kSourceSnapshotId),
std::to_string(staged_id));
}
std::shared_ptr<PartitionSpec> spec_;
std::shared_ptr<Schema> schema_;
std::shared_ptr<DataFile> file_a_;
std::shared_ptr<DataFile> file_b_;
std::shared_ptr<DataFile> replacement_a_;
std::shared_ptr<DataFile> conflict_a_;
};
class CherryPickFormatVersionTest : public CherryPickOperationTest,
public ::testing::WithParamInterface<int8_t> {
protected:
int8_t format_version() const override { return GetParam(); }
};
// A staged dynamic overwrite is re-applied onto a state that moved on, so a new
// snapshot is produced rather than a fast-forward.
TEST_F(CherryPickOperationTest, CherryPickDynamicOverwrite) {
ASSERT_THAT(CommitAppend(file_a_), IsOk());
ICEBERG_UNWRAP_OR_FAIL(auto staged_id, StageReplacePartitions(replacement_a_));
ASSERT_THAT(CommitAppend(file_b_), IsOk());
ASSERT_THAT(Cherrypick(staged_id), IsOk());
ICEBERG_UNWRAP_OR_FAIL(auto snapshot, table_->current_snapshot());
EXPECT_NE(snapshot->snapshot_id, staged_id);
EXPECT_EQ(snapshot->summary.at(SnapshotSummaryFields::kSourceSnapshotId),
std::to_string(staged_id));
ICEBERG_UNWRAP_OR_FAIL(auto live, LiveDataFilePaths());
EXPECT_THAT(live, ::testing::UnorderedElementsAre(file_b_->file_path,
replacement_a_->file_path));
}
// The same pick works when the staged overwrite has no parent snapshot.
TEST_F(CherryPickOperationTest, CherryPickDynamicOverwriteWithoutParent) {
ICEBERG_UNWRAP_OR_FAIL(auto staged_id, StageReplacePartitions(replacement_a_));
ASSERT_THAT(CommitAppend(file_b_), IsOk());
ASSERT_THAT(Cherrypick(staged_id), IsOk());
ICEBERG_UNWRAP_OR_FAIL(auto snapshot, table_->current_snapshot());
EXPECT_NE(snapshot->snapshot_id, staged_id);
ICEBERG_UNWRAP_OR_FAIL(auto live, LiveDataFilePaths());
EXPECT_THAT(live, ::testing::UnorderedElementsAre(file_b_->file_path,
replacement_a_->file_path));
}
// A file added concurrently into a replaced partition blocks the pick.
TEST_F(CherryPickOperationTest, CherryPickDynamicOverwriteConflict) {
ASSERT_THAT(CommitAppend(file_a_), IsOk());
ICEBERG_UNWRAP_OR_FAIL(auto staged_id, StageReplacePartitions(replacement_a_));
ICEBERG_UNWRAP_OR_FAIL(auto last_snapshot_id, CommitAppend(conflict_a_));
EXPECT_THAT(
Cherrypick(staged_id),
::testing::AllOf(
IsError(ErrorKind::kValidationFailed),
HasErrorMessage("Cannot cherry-pick replace partitions with changed partition: "
"x=1")));
ICEBERG_UNWRAP_OR_FAIL(auto snapshot, table_->current_snapshot());
EXPECT_EQ(snapshot->snapshot_id, last_snapshot_id);
ICEBERG_UNWRAP_OR_FAIL(auto live, LiveDataFilePaths());
EXPECT_THAT(
live, ::testing::UnorderedElementsAre(file_a_->file_path, conflict_a_->file_path));
}
// A file the staged overwrite removed must still be present at pick time.
TEST_F(CherryPickOperationTest, CherryPickDynamicOverwriteDeleteConflict) {
ASSERT_THAT(CommitAppend(file_a_), IsOk());
ICEBERG_UNWRAP_OR_FAIL(auto staged_id, StageReplacePartitions(replacement_a_));
ASSERT_THAT(CommitAppend(file_b_), IsOk());
ASSERT_THAT(CommitDelete(file_a_->file_path), IsOk());
ICEBERG_UNWRAP_OR_FAIL(auto before, table_->current_snapshot());
int64_t last_snapshot_id = before->snapshot_id;
EXPECT_THAT(Cherrypick(staged_id), IsError(ErrorKind::kValidationFailed));
ASSERT_THAT(table_->Refresh(), IsOk());
ICEBERG_UNWRAP_OR_FAIL(auto snapshot, table_->current_snapshot());
EXPECT_EQ(snapshot->snapshot_id, last_snapshot_id);
ICEBERG_UNWRAP_OR_FAIL(auto live, LiveDataFilePaths());
EXPECT_THAT(live, ::testing::UnorderedElementsAre(file_b_->file_path));
}
TEST_P(CherryPickFormatVersionTest, CherryPickAppend) {
ASSERT_THAT(CommitAppend(file_a_), IsOk());
ICEBERG_UNWRAP_OR_FAIL(auto staged_id, StageAppend(file_b_));
ICEBERG_UNWRAP_OR_FAIL(auto staged, table_->SnapshotById(staged_id));
ASSERT_THAT(CommitAppend(conflict_a_), IsOk());
const auto before = table_->metadata();
ICEBERG_UNWRAP_OR_FAIL(auto parent, table_->current_snapshot());
ASSERT_THAT(Cherrypick(staged_id), IsOk());
ICEBERG_UNWRAP_OR_FAIL(auto snapshot, table_->current_snapshot());
EXPECT_NE(snapshot->snapshot_id, staged_id);
EXPECT_EQ(snapshot->summary.at(SnapshotSummaryFields::kSourceSnapshotId),
std::to_string(staged_id));
EXPECT_FALSE(snapshot->summary.contains(SnapshotSummaryFields::kPublishedWAPId));
EXPECT_EQ(snapshot->parent_snapshot_id, parent->snapshot_id);
const int64_t expected_sequence =
GetParam() == 1 ? 0 : before->last_sequence_number + 1;
EXPECT_EQ(snapshot->sequence_number, expected_sequence);
EXPECT_EQ(table_->metadata()->last_sequence_number, expected_sequence);
EXPECT_EQ(table_->metadata()->snapshots.size(), before->snapshots.size() + 1);
ASSERT_EQ(table_->metadata()->snapshot_log.size(), before->snapshot_log.size() + 1);
EXPECT_EQ(table_->metadata()->snapshot_log.back().snapshot_id, snapshot->snapshot_id);
ExpectBranch(std::string(SnapshotRef::kMainBranch), snapshot->snapshot_id);
EXPECT_EQ(snapshot->summary.at(SnapshotSummaryFields::kAddedDataFiles), "1");
EXPECT_EQ(snapshot->summary.at(SnapshotSummaryFields::kTotalDataFiles), "3");
EXPECT_EQ(snapshot->summary.at(SnapshotSummaryFields::kTotalRecords), "300");
ICEBERG_UNWRAP_OR_FAIL(auto entries, LiveDataEntries(*snapshot));
ASSERT_EQ(entries.size(), 3);
int added_files = 0;
for (const auto& entry : entries) {
if (entry.data_file->file_path == file_b_->file_path) {
++added_files;
EXPECT_EQ(entry.status, ManifestStatus::kAdded);
EXPECT_EQ(entry.snapshot_id, snapshot->snapshot_id);
EXPECT_EQ(entry.sequence_number, expected_sequence);
EXPECT_EQ(entry.file_sequence_number, expected_sequence);
if (GetParam() == 3) {
EXPECT_EQ(entry.data_file->first_row_id, before->next_row_id);
EXPECT_NE(entry.data_file->first_row_id, staged->first_row_id);
} else {
EXPECT_FALSE(entry.data_file->first_row_id.has_value());
}
}
}
EXPECT_EQ(added_files, 1);
ICEBERG_UNWRAP_OR_FAIL(auto live, LiveDataFilePaths());
EXPECT_THAT(live, ::testing::UnorderedElementsAre(
file_a_->file_path, file_b_->file_path, conflict_a_->file_path));
if (GetParam() == 3) {
EXPECT_EQ(snapshot->first_row_id, before->next_row_id);
EXPECT_EQ(snapshot->added_rows, file_b_->record_count);
EXPECT_EQ(table_->metadata()->next_row_id,
before->next_row_id + file_b_->record_count);
}
}
// Main is empty, but the source snapshot has a parent on another branch.
TEST_F(CherryPickOperationTest, CherryPickAppendOntoEmptyMain) {
ASSERT_THAT(CommitAppendToBranch("b1", file_a_), IsOk());
ICEBERG_UNWRAP_OR_FAIL(auto staged_id, CommitAppendToBranch("b1", file_b_));
ASSERT_THAT(Cherrypick(staged_id), IsOk());
ICEBERG_UNWRAP_OR_FAIL(auto snapshot, table_->current_snapshot());
EXPECT_NE(snapshot->snapshot_id, staged_id);
EXPECT_EQ(snapshot->summary.at(SnapshotSummaryFields::kSourceSnapshotId),
std::to_string(staged_id));
ICEBERG_UNWRAP_OR_FAIL(auto live, LiveDataFilePaths());
EXPECT_THAT(live, ::testing::UnorderedElementsAre(file_b_->file_path));
}
// When the picked snapshot's parent is the current snapshot, the pick moves the
// current snapshot instead of creating one.
// When the picked snapshot's parent is the current snapshot, the pick moves the
// current snapshot instead of creating one.
TEST_F(CherryPickOperationTest, FastForwardSetsCurrentSnapshot) {
ASSERT_THAT(CommitAppend(file_a_), IsOk());
ICEBERG_UNWRAP_OR_FAIL(auto staged_id, StageAppend(file_b_));
const auto before = table_->metadata();
ASSERT_THAT(Cherrypick(staged_id), IsOk());
ExpectFastForward(before, staged_id);
ICEBERG_UNWRAP_OR_FAIL(auto live, LiveDataFilePaths());
EXPECT_THAT(live,
::testing::UnorderedElementsAre(file_a_->file_path, file_b_->file_path));
}
// An overwrite without "replace-partitions" is not pickable, but it is still
// fast-forwarded to when it is a child of the current snapshot.
// An overwrite without "replace-partitions" is not pickable, but it is still
// fast-forwarded to when it is a child of the current snapshot.
TEST_F(CherryPickOperationTest, FastForwardOverwriteSetsCurrentSnapshot) {
ASSERT_THAT(CommitAppend(file_a_), IsOk());
ICEBERG_UNWRAP_OR_FAIL(auto staged_id,
StageOverwrite(/*added=*/replacement_a_, /*removed=*/file_a_));
const auto before = table_->metadata();
ASSERT_THAT(Cherrypick(staged_id), IsOk());
ExpectFastForward(before, staged_id);
ICEBERG_UNWRAP_OR_FAIL(auto snapshot, table_->current_snapshot());
EXPECT_EQ(snapshot->summary.at(SnapshotSummaryFields::kOperation),
DataOperation::kOverwrite);
ICEBERG_UNWRAP_OR_FAIL(auto live, LiveDataFilePaths());
EXPECT_THAT(live, ::testing::UnorderedElementsAre(replacement_a_->file_path));
}
// A dynamic overwrite whose parent has been rolled off the current history
// cannot be picked, because the partitions it replaced cannot be checked.
TEST_F(CherryPickOperationTest, CherryPickDynamicOverwriteParentNotAncestor) {
ICEBERG_UNWRAP_OR_FAIL(auto first_id, CommitAppend(file_a_));
ASSERT_THAT(CommitAppend(file_b_), IsOk());
ICEBERG_UNWRAP_OR_FAIL(auto staged_id, StageReplacePartitions(replacement_a_));
ASSERT_THAT(RollbackTo(first_id), IsOk());
EXPECT_THAT(Cherrypick(staged_id),
::testing::AllOf(
IsError(ErrorKind::kValidationFailed),
HasErrorMessage(std::format("Cannot cherry-pick overwrite not based on "
"an ancestor of the current state: {}",
staged_id))));
ICEBERG_UNWRAP_OR_FAIL(auto snapshot, table_->current_snapshot());
EXPECT_EQ(snapshot->snapshot_id, first_id);
}
// A WAP id already published by an ancestor is rejected even when the staged
// snapshot is a child of the current one and would otherwise fast-forward.
TEST_F(CherryPickOperationTest, DuplicateWapPublishOnFastForwardRejected) {
ASSERT_THAT(CommitAppend(file_a_), IsOk());
ICEBERG_UNWRAP_OR_FAIL(auto first_staged_id,
StageAppend(file_b_, /*wap_id=*/"wap-123"));
ASSERT_THAT(Cherrypick(first_staged_id), IsOk());
ICEBERG_UNWRAP_OR_FAIL(auto picked, table_->current_snapshot());
EXPECT_EQ(picked->snapshot_id, first_staged_id);
// Staged on top of the snapshot just published, so this is a fast-forward.
ICEBERG_UNWRAP_OR_FAIL(auto second_staged_id,
StageAppend(conflict_a_, /*wap_id=*/"wap-123"));
EXPECT_THAT(
Cherrypick(second_staged_id),
::testing::AllOf(IsError(ErrorKind::kValidationFailed),
HasErrorMessage("Duplicate request to cherry pick wap id that "
"was published already: wap-123")));
ASSERT_THAT(table_->Refresh(), IsOk());
ICEBERG_UNWRAP_OR_FAIL(auto snapshot, table_->current_snapshot());
EXPECT_EQ(snapshot->snapshot_id, first_staged_id);
}
TEST_F(CherryPickOperationTest, NonPickableFastForwardInvalidatedByConcurrentCommit) {
ASSERT_THAT(CommitAppend(file_a_), IsOk());
ICEBERG_UNWRAP_OR_FAIL(auto staged_id, StageOverwrite(replacement_a_, file_a_));
ICEBERG_UNWRAP_OR_FAIL(auto transaction, StageCherrypick(staged_id));
ICEBERG_UNWRAP_OR_FAIL(auto concurrent_id, CommitFromOtherHandle(file_b_));
EXPECT_THAT(transaction->Commit(),
::testing::AllOf(
IsError(ErrorKind::kValidationFailed),
HasErrorMessage(std::format(
"Cannot cherry-pick snapshot {}: not append, dynamic overwrite, "
"or fast-forward",
staged_id))));
ASSERT_THAT(table_->Refresh(), IsOk());
ICEBERG_UNWRAP_OR_FAIL(auto snapshot, table_->current_snapshot());
EXPECT_EQ(snapshot->snapshot_id, concurrent_id);
ICEBERG_UNWRAP_OR_FAIL(auto live, LiveDataFilePaths());
EXPECT_THAT(live,
::testing::UnorderedElementsAre(file_a_->file_path, file_b_->file_path));
}
// A snapshot already in the current history cannot be picked again.
TEST_F(CherryPickOperationTest, CherryPickAncestorRejected) {
ICEBERG_UNWRAP_OR_FAIL(auto first_id, CommitAppend(file_a_));
ASSERT_THAT(CommitAppend(file_b_), IsOk());
EXPECT_THAT(Cherrypick(first_id),
::testing::AllOf(
IsError(ErrorKind::kValidationFailed),
HasErrorMessage(std::format(
"Cannot cherrypick snapshot {}: already an ancestor", first_id))));
ICEBERG_UNWRAP_OR_FAIL(auto live, LiveDataFilePaths());
EXPECT_THAT(live,
::testing::UnorderedElementsAre(file_a_->file_path, file_b_->file_path));
}
// The same WAP id cannot be picked twice.
TEST_F(CherryPickOperationTest, DuplicateWapPublishRejected) {
ASSERT_THAT(CommitAppend(file_a_), IsOk());
ICEBERG_UNWRAP_OR_FAIL(auto first_staged_id,
StageAppend(file_b_, /*wap_id=*/"wap-123"));
ICEBERG_UNWRAP_OR_FAIL(auto second_staged_id,
StageAppend(conflict_a_, /*wap_id=*/"wap-123"));
ASSERT_THAT(Cherrypick(first_staged_id), IsOk());
ICEBERG_UNWRAP_OR_FAIL(auto picked, table_->current_snapshot());
EXPECT_EQ(picked->snapshot_id, first_staged_id);
EXPECT_THAT(
Cherrypick(second_staged_id),
::testing::AllOf(IsError(ErrorKind::kValidationFailed),
HasErrorMessage("Duplicate request to cherry pick wap id that "
"was published already: wap-123")));
}
// A snapshot that is neither an append nor a dynamic overwrite can only be
// fast-forwarded.
TEST_F(CherryPickOperationTest, NonPickableOperationRejected) {
ASSERT_THAT(CommitAppend(file_a_), IsOk());
ASSERT_THAT(CommitAppend(file_b_), IsOk());
auto delete_files = table_->NewDeleteFiles();
ASSERT_TRUE(delete_files.has_value());
delete_files.value()->StageOnly();
delete_files.value()->DeleteFile(file_a_->file_path);
ASSERT_THAT(delete_files.value()->Commit(), IsOk());
ASSERT_THAT(table_->Refresh(), IsOk());
int64_t staged_id = table_->metadata()->snapshots.back()->snapshot_id;
ASSERT_THAT(CommitAppend(conflict_a_), IsOk());
EXPECT_THAT(Cherrypick(staged_id),
::testing::AllOf(
IsError(ErrorKind::kValidationFailed),
HasErrorMessage(std::format(
"Cannot cherry-pick snapshot {}: not append, dynamic overwrite, "
"or fast-forward",
staged_id))));
}
TEST_F(CherryPickOperationTest, UnknownSnapshotRejected) {
ASSERT_THAT(CommitAppend(file_a_), IsOk());
EXPECT_THAT(
Cherrypick(/*snapshot_id=*/-99),
::testing::AllOf(IsError(ErrorKind::kValidationFailed),
HasErrorMessage("Cannot cherry-pick unknown snapshot ID: -99")));
}
TEST_F(CherryPickOperationTest, FastForwardOntoEmptyMain) {
ICEBERG_UNWRAP_OR_FAIL(auto staged_id, StageAppend(file_a_, "wap-123"));
const auto before = table_->metadata();
ASSERT_THAT(Cherrypick(staged_id), IsOk());
ExpectFastForward(before, staged_id);
ICEBERG_UNWRAP_OR_FAIL(auto snapshot, table_->current_snapshot());
EXPECT_EQ(snapshot->summary.at(SnapshotSummaryFields::kWAPId), "wap-123");
ICEBERG_UNWRAP_OR_FAIL(auto live, LiveDataFilePaths());
EXPECT_THAT(live, ::testing::ElementsAre(file_a_->file_path));
}
TEST_F(CherryPickOperationTest, FastForwardAppendReplayedAfterConcurrentAppend) {
ASSERT_THAT(CommitAppend(file_a_), IsOk());
ICEBERG_UNWRAP_OR_FAIL(auto staged_id, StageAppend(file_b_, "wap-123"));
ICEBERG_UNWRAP_OR_FAIL(auto transaction, StageCherrypick(staged_id));
ICEBERG_UNWRAP_OR_FAIL(auto concurrent_id, CommitFromOtherHandle(conflict_a_));
ASSERT_THAT(transaction->Commit(), IsOk());
ASSERT_THAT(table_->Refresh(), IsOk());
ExpectReplayedPick(staged_id, concurrent_id);
ICEBERG_UNWRAP_OR_FAIL(auto snapshot, table_->current_snapshot());
EXPECT_EQ(snapshot->summary.at(SnapshotSummaryFields::kPublishedWAPId), "wap-123");
ICEBERG_UNWRAP_OR_FAIL(auto live, LiveDataFilePaths());
EXPECT_THAT(live, ::testing::UnorderedElementsAre(
file_a_->file_path, file_b_->file_path, conflict_a_->file_path));
}
TEST_F(CherryPickOperationTest, FastForwardDynamicOverwriteReplayedWithoutConflict) {
ASSERT_THAT(CommitAppend(file_a_), IsOk());
ICEBERG_UNWRAP_OR_FAIL(auto staged_id, StageReplacePartitions(replacement_a_));
ICEBERG_UNWRAP_OR_FAIL(auto transaction, StageCherrypick(staged_id));
ICEBERG_UNWRAP_OR_FAIL(auto concurrent_id, CommitFromOtherHandle(file_b_));
ASSERT_THAT(transaction->Commit(), IsOk());
ASSERT_THAT(table_->Refresh(), IsOk());
ExpectReplayedPick(staged_id, concurrent_id);
ICEBERG_UNWRAP_OR_FAIL(auto snapshot, table_->current_snapshot());
EXPECT_EQ(snapshot->Operation(), DataOperation::kOverwrite);
ICEBERG_UNWRAP_OR_FAIL(auto live, LiveDataFilePaths());
EXPECT_THAT(live, ::testing::UnorderedElementsAre(file_b_->file_path,
replacement_a_->file_path));
}
TEST_F(CherryPickOperationTest, FastForwardDynamicOverwriteReplayedWithConflict) {
ASSERT_THAT(CommitAppend(file_a_), IsOk());
ICEBERG_UNWRAP_OR_FAIL(auto staged_id, StageReplacePartitions(replacement_a_));
ICEBERG_UNWRAP_OR_FAIL(auto transaction, StageCherrypick(staged_id));
ICEBERG_UNWRAP_OR_FAIL(auto concurrent_id, CommitFromOtherHandle(conflict_a_));
EXPECT_THAT(transaction->Commit(),
::testing::AllOf(
IsError(ErrorKind::kValidationFailed),
HasErrorMessage("Cannot cherry-pick replace partitions with changed "
"partition")));
ASSERT_THAT(table_->Refresh(), IsOk());
ICEBERG_UNWRAP_OR_FAIL(auto snapshot, table_->current_snapshot());
EXPECT_EQ(snapshot->snapshot_id, concurrent_id);
ICEBERG_UNWRAP_OR_FAIL(auto live, LiveDataFilePaths());
EXPECT_THAT(
live, ::testing::UnorderedElementsAre(file_a_->file_path, conflict_a_->file_path));
}
TEST_F(CherryPickOperationTest, CherryPickReplayedAsFastForwardAfterRollback) {
ICEBERG_UNWRAP_OR_FAIL(auto parent_id, CommitAppend(file_a_));
ICEBERG_UNWRAP_OR_FAIL(auto staged_id, StageAppend(file_b_));
ASSERT_THAT(CommitAppend(conflict_a_), IsOk());
ICEBERG_UNWRAP_OR_FAIL(auto transaction, StageCherrypick(staged_id));
ICEBERG_UNWRAP_OR_FAIL(auto draft, transaction->current().Snapshot());
ASSERT_NE(draft->snapshot_id, staged_id);
ASSERT_THAT(file_io_->ReadFile(draft->manifest_list, std::nullopt), IsOk());
ICEBERG_UNWRAP_OR_FAIL(auto other_table, catalog_->LoadTable(table_ident_));
ICEBERG_UNWRAP_OR_FAIL(auto rollback, other_table->NewSnapshotManager());
rollback->RollbackTo(parent_id);
ASSERT_THAT(rollback->Commit(), IsOk());
ASSERT_THAT(other_table->Refresh(), IsOk());
const auto before = other_table->metadata();
ASSERT_THAT(transaction->Commit(), IsOk());
ASSERT_THAT(table_->Refresh(), IsOk());
ExpectFastForward(before, staged_id);
ICEBERG_UNWRAP_OR_FAIL(auto snapshot, table_->current_snapshot());
EXPECT_THAT(file_io_->ReadFile(draft->manifest_list, std::nullopt),
IsError(ErrorKind::kIOError));
EXPECT_THAT(file_io_->ReadFile(snapshot->manifest_list, std::nullopt), IsOk());
ICEBERG_UNWRAP_OR_FAIL(auto live, LiveDataFilePaths());
EXPECT_THAT(live,
::testing::UnorderedElementsAre(file_a_->file_path, file_b_->file_path));
}
TEST_F(CherryPickOperationTest, FastForwardSucceedsAfterCommitRetries) {
ASSERT_THAT(CommitAppend(file_a_), IsOk());
ICEBERG_UNWRAP_OR_FAIL(auto staged_id, StageAppend(file_b_, "wap-123"));
const auto before = table_->metadata();
FailCommits(2);
ASSERT_THAT(Cherrypick(staged_id), IsOk());
ExpectFastForward(before, staged_id);
ICEBERG_UNWRAP_OR_FAIL(auto live, LiveDataFilePaths());
EXPECT_THAT(live,
::testing::UnorderedElementsAre(file_a_->file_path, file_b_->file_path));
}
TEST_F(CherryPickOperationTest, CherryPickRecordsPublishedWapId) {
ASSERT_THAT(CommitAppend(file_a_), IsOk());
ICEBERG_UNWRAP_OR_FAIL(auto staged_id, StageAppend(file_b_, "wap-123"));
ICEBERG_UNWRAP_OR_FAIL(auto second_staged_id, StageAppend(replacement_a_, "wap-123"));
ASSERT_THAT(CommitAppend(conflict_a_), IsOk());
ASSERT_THAT(Cherrypick(staged_id), IsOk());
ICEBERG_UNWRAP_OR_FAIL(auto snapshot, table_->current_snapshot());
EXPECT_NE(snapshot->snapshot_id, staged_id);
EXPECT_EQ(snapshot->summary.at(SnapshotSummaryFields::kSourceSnapshotId),
std::to_string(staged_id));
EXPECT_EQ(snapshot->summary.at(SnapshotSummaryFields::kPublishedWAPId), "wap-123");
EXPECT_FALSE(snapshot->summary.contains(SnapshotSummaryFields::kWAPId));
EXPECT_THAT(Cherrypick(second_staged_id),
HasErrorMessage("Duplicate request to cherry pick wap id"));
ASSERT_THAT(table_->Refresh(), IsOk());
ICEBERG_UNWRAP_OR_FAIL(auto after, table_->current_snapshot());
EXPECT_EQ(after->snapshot_id, snapshot->snapshot_id);
ICEBERG_UNWRAP_OR_FAIL(auto live, LiveDataFilePaths());
EXPECT_THAT(live, ::testing::UnorderedElementsAre(
file_a_->file_path, file_b_->file_path, conflict_a_->file_path));
// An explicitly empty wap id is still recorded as published-wap-id.
auto extra = MakeDataFile("/data/file_extra.parquet", /*partition_x=*/4L);
auto bridge = MakeDataFile("/data/file_bridge.parquet", /*partition_x=*/5L);
ICEBERG_UNWRAP_OR_FAIL(auto empty_wap_id, StageAppend(extra, /*wap_id=*/""));
ASSERT_THAT(CommitAppend(bridge), IsOk());
ASSERT_THAT(Cherrypick(empty_wap_id), IsOk());
ICEBERG_UNWRAP_OR_FAIL(auto empty_wap_snapshot, table_->current_snapshot());
EXPECT_EQ(empty_wap_snapshot->summary.at(SnapshotSummaryFields::kPublishedWAPId), "");
EXPECT_EQ(empty_wap_snapshot->summary.at(SnapshotSummaryFields::kSourceSnapshotId),
std::to_string(empty_wap_id));
}
TEST_F(CherryPickOperationTest, DuplicateSourceSnapshotRejected) {
ASSERT_THAT(CommitAppend(file_a_), IsOk());
ICEBERG_UNWRAP_OR_FAIL(auto staged_id, StageAppend(file_b_));
ASSERT_THAT(CommitAppend(conflict_a_), IsOk());
ASSERT_THAT(Cherrypick(staged_id), IsOk());
ICEBERG_UNWRAP_OR_FAIL(auto picked, table_->current_snapshot());
EXPECT_THAT(Cherrypick(staged_id),
::testing::AllOf(
IsError(ErrorKind::kValidationFailed),
HasErrorMessage(std::format(
"Cannot cherrypick snapshot {}: already picked to create ancestor "
"{}",
staged_id, picked->snapshot_id))));
ASSERT_THAT(table_->Refresh(), IsOk());
ICEBERG_UNWRAP_OR_FAIL(auto snapshot, table_->current_snapshot());
EXPECT_EQ(snapshot->snapshot_id, picked->snapshot_id);
ICEBERG_UNWRAP_OR_FAIL(auto live, LiveDataFilePaths());
EXPECT_THAT(live, ::testing::UnorderedElementsAre(
file_a_->file_path, file_b_->file_path, conflict_a_->file_path));
}
TEST_F(CherryPickOperationTest, DuplicateWapPublishedDuringReplayRejected) {
ASSERT_THAT(CommitAppend(file_a_), IsOk());
ICEBERG_UNWRAP_OR_FAIL(auto first_id, StageAppend(file_b_, "wap-123"));
ICEBERG_UNWRAP_OR_FAIL(auto second_id, StageAppend(replacement_a_, "wap-123"));
ICEBERG_UNWRAP_OR_FAIL(auto transaction, StageCherrypick(second_id));
ICEBERG_UNWRAP_OR_FAIL(auto other_table, catalog_->LoadTable(table_ident_));
ICEBERG_UNWRAP_OR_FAIL(auto publish, other_table->NewSnapshotManager());
publish->Cherrypick(first_id);
ASSERT_THAT(publish->Commit(), IsOk());
EXPECT_THAT(
transaction->Commit(),
::testing::AllOf(IsError(ErrorKind::kValidationFailed),
HasErrorMessage("Duplicate request to cherry pick wap id")));
ASSERT_THAT(table_->Refresh(), IsOk());
ICEBERG_UNWRAP_OR_FAIL(auto snapshot, table_->current_snapshot());
EXPECT_EQ(snapshot->snapshot_id, first_id);
ICEBERG_UNWRAP_OR_FAIL(auto live, LiveDataFilePaths());
EXPECT_THAT(live,
::testing::UnorderedElementsAre(file_a_->file_path, file_b_->file_path));
}
TEST_F(CherryPickOperationTest, CherryPickDynamicOverwriteFromMergedManifest) {
ICEBERG_UNWRAP_OR_FAIL(auto properties, table_->NewUpdateProperties());
properties->Set(TableProperties::kManifestMinMergeCount.key(), "1");
ASSERT_THAT(properties->Commit(), IsOk());
ASSERT_THAT(CommitAppend(file_a_), IsOk());
ASSERT_THAT(CommitAppend(file_b_), IsOk());
ICEBERG_UNWRAP_OR_FAIL(auto staged_id, StageReplacePartitions(replacement_a_));
ICEBERG_UNWRAP_OR_FAIL(auto staged, table_->SnapshotById(staged_id));
ICEBERG_UNWRAP_OR_FAIL(auto staged_entries, LiveDataEntries(*staged));
ASSERT_THAT(staged_entries, ::testing::Contains(::testing::Field(
&ManifestEntry::status, ManifestStatus::kExisting)));
auto new_file = MakeDataFile("/data/another_partition.parquet", 3L);
ASSERT_THAT(CommitAppend(new_file), IsOk());
ASSERT_THAT(Cherrypick(staged_id), IsOk());
ICEBERG_UNWRAP_OR_FAIL(auto live, LiveDataFilePaths());
EXPECT_THAT(
live, ::testing::UnorderedElementsAre(file_b_->file_path, replacement_a_->file_path,
new_file->file_path));
}
INSTANTIATE_TEST_SUITE_P(FormatVersions, CherryPickFormatVersionTest,
::testing::Values(1, 2, 3),
[](const ::testing::TestParamInfo<int8_t>& info) {
return std::format("V{}", info.param);
});
} // namespace iceberg