Skip to content

Commit 4475fa8

Browse files
authored
fix: make table updates retryable and cleanup-safe (#868)
1 parent 525d60f commit 4475fa8

33 files changed

Lines changed: 1650 additions & 448 deletions

‎src/iceberg/test/expire_snapshots_test.cc‎

Lines changed: 197 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -37,9 +37,14 @@
3737
#include "iceberg/snapshot.h"
3838
#include "iceberg/statistics_file.h"
3939
#include "iceberg/table_metadata.h"
40+
#include "iceberg/table_properties.h"
4041
#include "iceberg/test/executor.h"
4142
#include "iceberg/test/matchers.h"
43+
#include "iceberg/test/mock_catalog.h"
4244
#include "iceberg/test/update_test_base.h"
45+
#include "iceberg/transaction.h"
46+
#include "iceberg/update/fast_append.h"
47+
#include "iceberg/update/set_snapshot.h"
4348

4449
namespace iceberg {
4550

@@ -291,21 +296,15 @@ TEST_F(ExpireSnapshotsCleanupTest, RetainsUnreferencedSnapshotAtExpireThreshold)
291296
testing::Not(testing::Contains(unreferenced_snapshot_id)));
292297
}
293298

294-
TEST_F(ExpireSnapshotsTest, FinalizeRequiresCommittedMetadata) {
299+
TEST_F(ExpireSnapshotsTest, ApplyDoesNotDeleteBeforeCommit) {
295300
std::vector<std::string> deleted_files;
296-
ICEBERG_UNWRAP_OR_FAIL(auto update, table_->NewExpireSnapshots());
301+
ICEBERG_UNWRAP_OR_FAIL(auto txn, table_->NewTransaction());
302+
ICEBERG_UNWRAP_OR_FAIL(auto update, txn->NewExpireSnapshots());
297303
update->DeleteWith(
298304
[&deleted_files](const std::string& path) { deleted_files.push_back(path); });
299-
300-
// Apply first so apply_result_ is cached
301305
ICEBERG_UNWRAP_OR_FAIL(auto result, update->Apply());
302306
EXPECT_EQ(result.snapshot_ids_to_remove.size(), 1);
303-
304-
// A successful finalize now requires the committed metadata from the catalog.
305-
auto finalize_status = update->Finalize(static_cast<const TableMetadata*>(nullptr));
306-
EXPECT_THAT(finalize_status, IsError(ErrorKind::kInvalidArgument));
307-
EXPECT_THAT(finalize_status,
308-
HasErrorMessage("Missing committed table metadata for cleanup"));
307+
EXPECT_THAT(txn->Abort(), IsOk());
309308
EXPECT_TRUE(deleted_files.empty());
310309
}
311310

@@ -319,29 +318,103 @@ TEST_F(ExpireSnapshotsTest, CleanupNoneSkipsDeletion) {
319318
ICEBERG_UNWRAP_OR_FAIL(auto result, update->Apply());
320319
EXPECT_EQ(result.snapshot_ids_to_remove.size(), 1);
321320

322-
// With kNone cleanup level, Finalize should skip all file deletion
323-
auto finalize_status = update->Finalize(static_cast<const TableMetadata*>(nullptr));
324-
EXPECT_THAT(finalize_status, IsOk());
321+
// With kNone cleanup level, Commit should skip all file deletion
322+
auto commit_status = update->Commit();
323+
EXPECT_THAT(commit_status, IsOk());
325324
EXPECT_TRUE(deleted_files.empty());
326325
}
327326

328-
TEST_F(ExpireSnapshotsTest, FinalizeSkippedOnCommitError) {
327+
TEST_F(ExpireSnapshotsTest, AbortSkipsExpirationDeletion) {
329328
std::vector<std::string> deleted_files;
330-
ICEBERG_UNWRAP_OR_FAIL(auto update, table_->NewExpireSnapshots());
329+
ICEBERG_UNWRAP_OR_FAIL(auto txn, table_->NewTransaction());
330+
ICEBERG_UNWRAP_OR_FAIL(auto update, txn->NewExpireSnapshots());
331331
update->DeleteWith(
332332
[&deleted_files](const std::string& path) { deleted_files.push_back(path); });
333+
EXPECT_THAT(update->Commit(), IsOk());
334+
EXPECT_THAT(txn->Abort(), IsOk());
335+
EXPECT_THAT(txn->Abort(), IsOk());
336+
EXPECT_TRUE(deleted_files.empty());
337+
}
333338

334-
ICEBERG_UNWRAP_OR_FAIL(auto result, update->Apply());
335-
EXPECT_EQ(result.snapshot_ids_to_remove.size(), 1);
339+
TEST_F(ExpireSnapshotsCleanupTest, CommitFailureSkipsExpirationDeletion) {
340+
const auto expired_list = table_location_ + "/metadata/expired-list.avro";
341+
const auto current_list = table_location_ + "/metadata/current-list.avro";
342+
WriteManifestList(expired_list, kExpiredSnapshotId, 0, kExpiredSequenceNumber, {});
343+
WriteManifestList(current_list, kCurrentSnapshotId, kExpiredSnapshotId,
344+
kCurrentSequenceNumber, {});
345+
RewriteTableWithManifestLists(expired_list, current_list);
346+
347+
auto mock = std::make_shared<::testing::NiceMock<MockCatalog>>();
348+
EXPECT_CALL(*mock, UpdateTable(::testing::_, ::testing::_, ::testing::_))
349+
.Times(1)
350+
.WillOnce(::testing::Return(CommitFailed("injected commit failure")));
351+
auto metadata = std::make_shared<TableMetadata>(*table_->metadata());
352+
metadata->properties.Set(TableProperties::kCommitNumRetries, 0);
353+
ICEBERG_UNWRAP_OR_FAIL(
354+
auto table,
355+
Table::Make(table_->name(), metadata, std::string(table_->metadata_file_location()),
356+
file_io_, mock));
357+
ICEBERG_UNWRAP_OR_FAIL(auto txn, table->NewTransaction());
358+
ICEBERG_UNWRAP_OR_FAIL(auto update, txn->NewExpireSnapshots());
359+
std::vector<std::string> deleted_files;
360+
update->ExpireSnapshotId(kExpiredSnapshotId).DeleteWith([&](const std::string& path) {
361+
deleted_files.push_back(path);
362+
});
363+
ASSERT_THAT(update->Commit(), IsOk());
364+
EXPECT_FALSE(txn->current().SnapshotById(kExpiredSnapshotId).has_value());
365+
EXPECT_TRUE(deleted_files.empty());
336366

337-
// Simulate a commit failure - Finalize should not delete any files
338-
auto finalize_status = update->Finalize(Result<const TableMetadata*>(std::unexpected(
339-
Error{.kind = ErrorKind::kCommitFailed, .message = "simulated failure"})));
340-
EXPECT_THAT(finalize_status, IsOk());
367+
EXPECT_THAT(txn->Commit(),
368+
::testing::AllOf(IsError(ErrorKind::kCommitFailed),
369+
HasErrorMessage("injected commit failure")));
370+
EXPECT_EQ(txn->state(), TransactionState::kFailed);
371+
EXPECT_THAT(txn->Abort(), IsOk());
341372
EXPECT_TRUE(deleted_files.empty());
373+
EXPECT_TRUE(ReloadMetadata()->SnapshotById(kExpiredSnapshotId).has_value());
374+
EXPECT_THAT(file_io_->ReadFile(expired_list, std::nullopt), IsOk());
375+
EXPECT_THAT(file_io_->ReadFile(current_list, std::nullopt), IsOk());
342376
}
343377

344-
TEST_F(ExpireSnapshotsTest, FinalizeSkipsWhenNothingExpired) {
378+
TEST_F(ExpireSnapshotsCleanupTest, CommitStateUnknownPreservesExpiredFiles) {
379+
const auto data_path = table_location_ + "/data/unknown-expired.parquet";
380+
const auto manifest_path = table_location_ + "/metadata/unknown-expired.avro";
381+
const auto expired_list = table_location_ + "/metadata/unknown-expired-list.avro";
382+
const auto current_list = table_location_ + "/metadata/unknown-current-list.avro";
383+
ASSERT_THAT(file_io_->WriteFile(data_path, "data"), IsOk());
384+
auto data_file = MakeDataFile(data_path);
385+
data_file->partition_spec_id = DefaultSpec()->spec_id();
386+
auto manifest = WriteDataManifest(manifest_path, kExpiredSnapshotId,
387+
{MakeEntry(ManifestStatus::kAdded, kExpiredSnapshotId,
388+
kExpiredSequenceNumber, data_file)});
389+
WriteManifestList(expired_list, kExpiredSnapshotId, 0, kExpiredSequenceNumber,
390+
{manifest});
391+
WriteManifestList(current_list, kCurrentSnapshotId, kExpiredSnapshotId,
392+
kCurrentSequenceNumber, {});
393+
RewriteTableWithManifestLists(expired_list, current_list);
394+
395+
auto mock = std::make_shared<::testing::NiceMock<MockCatalog>>();
396+
EXPECT_CALL(*mock, UpdateTable(::testing::_, ::testing::_, ::testing::_))
397+
.WillOnce(::testing::Return(CommitStateUnknown("unknown commit state")));
398+
ICEBERG_UNWRAP_OR_FAIL(
399+
auto table,
400+
Table::Make(table_->name(), table_->metadata(),
401+
std::string(table_->metadata_file_location()), file_io_, mock));
402+
ICEBERG_UNWRAP_OR_FAIL(auto update, table->NewExpireSnapshots());
403+
std::vector<std::string> deleted_files;
404+
update->ExpireSnapshotId(kExpiredSnapshotId).DeleteWith([&](const std::string& path) {
405+
deleted_files.push_back(path);
406+
return file_io_->DeleteFile(path);
407+
});
408+
409+
EXPECT_THAT(update->Commit(), IsError(ErrorKind::kCommitStateUnknown));
410+
EXPECT_TRUE(deleted_files.empty());
411+
EXPECT_TRUE(ReloadMetadata()->SnapshotById(kExpiredSnapshotId).has_value());
412+
for (const auto& path : {data_path, manifest_path, expired_list}) {
413+
EXPECT_THAT(file_io_->ReadFile(path, std::nullopt), IsOk());
414+
}
415+
}
416+
417+
TEST_F(ExpireSnapshotsTest, CommitSkipsWhenNothingExpired) {
345418
std::vector<std::string> deleted_files;
346419
ICEBERG_UNWRAP_OR_FAIL(auto update, table_->NewExpireSnapshots());
347420
update->RetainLast(2);
@@ -351,17 +424,17 @@ TEST_F(ExpireSnapshotsTest, FinalizeSkipsWhenNothingExpired) {
351424
ICEBERG_UNWRAP_OR_FAIL(auto result, update->Apply());
352425
EXPECT_TRUE(result.snapshot_ids_to_remove.empty());
353426

354-
// No snapshots expired, so Finalize should not delete any files
355-
auto finalize_status = update->Finalize(static_cast<const TableMetadata*>(nullptr));
356-
EXPECT_THAT(finalize_status, IsOk());
427+
// No snapshots expired, so Commit should not delete any files
428+
auto commit_status = update->Commit();
429+
EXPECT_THAT(commit_status, IsOk());
357430
EXPECT_TRUE(deleted_files.empty());
358431
}
359432

360433
TEST_F(ExpireSnapshotsTest, CommitWithCleanupNone) {
361434
ICEBERG_UNWRAP_OR_FAIL(auto update, table_->NewExpireSnapshots());
362435
update->CleanupLevel(CleanupLevel::kNone);
363436

364-
// Commit should succeed - Finalize is called internally but skips cleanup
437+
// Commit should succeed and skip cleanup
365438
EXPECT_THAT(update->Commit(), IsOk());
366439

367440
// Verify snapshot was removed from metadata
@@ -1016,4 +1089,102 @@ TEST_F(ExpireSnapshotsCleanupTest, CommitIgnoresMalformedSourceSnapshotIdCleanup
10161089
EXPECT_EQ(committed_metadata->snapshots.at(0)->snapshot_id, kCurrentSnapshotId);
10171090
}
10181091

1092+
class ExpirationLifecycleTest
1093+
: public ExpireSnapshotsCleanupTest,
1094+
public ::testing::WithParamInterface<std::tuple<CleanupLevel, bool, bool, bool>> {};
1095+
1096+
TEST_P(ExpirationLifecycleTest, LaterReferencesSuppressPhysicalDeletion) {
1097+
auto [cleanup_level, custom_delete, reattach, retry] = GetParam();
1098+
const auto data_path = table_location_ + "/data/reattached.parquet";
1099+
const auto manifest_path = table_location_ + "/metadata/expired.avro";
1100+
const auto expired_list = table_location_ + "/metadata/expired-list.avro";
1101+
const auto current_list = table_location_ + "/metadata/current-list.avro";
1102+
auto data_file = MakeDataFile(data_path);
1103+
data_file->partition_spec_id = DefaultSpec()->spec_id();
1104+
ASSERT_THAT(file_io_->WriteFile(data_path, "data"), IsOk());
1105+
auto manifest = WriteDataManifest(manifest_path, kExpiredSnapshotId,
1106+
{MakeEntry(ManifestStatus::kAdded, kExpiredSnapshotId,
1107+
kExpiredSequenceNumber, data_file)});
1108+
WriteManifestList(expired_list, kExpiredSnapshotId, 0, kExpiredSequenceNumber,
1109+
{manifest});
1110+
WriteManifestList(current_list, kCurrentSnapshotId, kExpiredSnapshotId,
1111+
kCurrentSequenceNumber, {});
1112+
RewriteTableWithManifestLists(expired_list, current_list);
1113+
if (retry) {
1114+
FailCommits(1);
1115+
}
1116+
ICEBERG_UNWRAP_OR_FAIL(auto txn, table_->NewTransaction());
1117+
ICEBERG_UNWRAP_OR_FAIL(auto expire, txn->NewExpireSnapshots());
1118+
expire->ExpireSnapshotId(kExpiredSnapshotId).CleanupLevel(cleanup_level);
1119+
int deletes = 0;
1120+
if (custom_delete) {
1121+
expire->DeleteWith([&](const std::string& path) {
1122+
++deletes;
1123+
std::ignore = file_io_->DeleteFile(path);
1124+
});
1125+
}
1126+
ASSERT_THAT(expire->Commit(), IsOk());
1127+
if (reattach) {
1128+
// A later update reuses the expired data file, so final cleanup must preserve it.
1129+
ICEBERG_UNWRAP_OR_FAIL(auto append, txn->NewFastAppend());
1130+
append->AppendFile(data_file);
1131+
ASSERT_THAT(append->Commit(), IsOk());
1132+
} else {
1133+
ICEBERG_UNWRAP_OR_FAIL(auto noop, txn->NewSetSnapshot());
1134+
noop->SetCurrentSnapshot(kCurrentSnapshotId);
1135+
ASSERT_THAT(noop->Commit(), IsOk());
1136+
}
1137+
ASSERT_THAT(txn->Commit(), IsOk());
1138+
EXPECT_FALSE(ReloadMetadata()->SnapshotById(kExpiredSnapshotId).has_value());
1139+
EXPECT_EQ(file_io_->ReadFile(data_path, std::nullopt).has_value(),
1140+
reattach || cleanup_level == CleanupLevel::kMetadataOnly);
1141+
EXPECT_FALSE(file_io_->ReadFile(manifest_path, std::nullopt).has_value());
1142+
EXPECT_FALSE(file_io_->ReadFile(expired_list, std::nullopt).has_value());
1143+
if (custom_delete) {
1144+
EXPECT_GT(deletes, 0);
1145+
}
1146+
EXPECT_THAT(txn->Commit(), IsError(ErrorKind::kValidationFailed));
1147+
EXPECT_THAT(txn->Abort(), IsError(ErrorKind::kValidationFailed));
1148+
}
1149+
1150+
INSTANTIATE_TEST_SUITE_P(
1151+
CleanupPolicies, ExpirationLifecycleTest,
1152+
::testing::Combine(::testing::Values(CleanupLevel::kAll, CleanupLevel::kMetadataOnly),
1153+
::testing::Bool(), ::testing::Bool(), ::testing::Bool()));
1154+
1155+
TEST_F(ExpireSnapshotsCleanupTest, RetryRecomputesExpirationAgainstRefreshedMetadata) {
1156+
const auto expired_list = table_location_ + "/metadata/expire-before-retry.avro";
1157+
const auto current_list = table_location_ + "/metadata/current-before-retry.avro";
1158+
WriteManifestList(expired_list, kExpiredSnapshotId, 0, kExpiredSequenceNumber, {});
1159+
WriteManifestList(current_list, kCurrentSnapshotId, kExpiredSnapshotId,
1160+
kCurrentSequenceNumber, {});
1161+
RewriteTableWithManifestLists(expired_list, current_list);
1162+
auto data_file = MakeDataFile(table_location_ + "/data/concurrent.parquet");
1163+
data_file->partition_spec_id = DefaultSpec()->spec_id();
1164+
FailCommits(1, [&](int attempt) {
1165+
if (attempt == 0) {
1166+
ICEBERG_UNWRAP_OR_FAIL(auto latest, catalog_->LoadTable(table_ident_));
1167+
ICEBERG_UNWRAP_OR_FAIL(auto append, latest->NewFastAppend());
1168+
append->AppendFile(data_file);
1169+
ASSERT_THAT(append->Commit(), IsOk());
1170+
}
1171+
});
1172+
ICEBERG_UNWRAP_OR_FAIL(auto expire, table_->NewExpireSnapshots());
1173+
expire
1174+
->ExpireOlderThan(
1175+
(CurrentTimePointMs() + std::chrono::hours(1)).time_since_epoch().count())
1176+
.RetainLast(1);
1177+
std::vector<std::string> deleted;
1178+
expire->DeleteWith([&](const std::string& path) { deleted.push_back(path); });
1179+
ICEBERG_UNWRAP_OR_FAIL(auto preview, expire->Apply());
1180+
EXPECT_THAT(preview.snapshot_ids_to_remove, ::testing::ElementsAre(kExpiredSnapshotId));
1181+
ASSERT_THAT(expire->Commit(), IsOk());
1182+
auto metadata = ReloadMetadata();
1183+
EXPECT_FALSE(metadata->SnapshotById(kExpiredSnapshotId).has_value());
1184+
EXPECT_FALSE(metadata->SnapshotById(kCurrentSnapshotId).has_value());
1185+
EXPECT_EQ(metadata->snapshots.size(), 1U);
1186+
EXPECT_THAT(deleted, ::testing::Contains(expired_list));
1187+
EXPECT_THAT(deleted, ::testing::Contains(current_list));
1188+
}
1189+
10191190
} // namespace iceberg

0 commit comments

Comments
 (0)