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
4449namespace 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
360433TEST_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