2222import pytest
2323
2424from pyiceberg .table import CommitTableResponse , Table
25- from pyiceberg .table .update import RemoveSnapshotsUpdate , update_table_metadata
25+ from pyiceberg .table .refs import SnapshotRef , SnapshotRefType
26+ from pyiceberg .table .update import RemoveSnapshotRefUpdate , RemoveSnapshotsUpdate , update_table_metadata
2627from pyiceberg .table .update .snapshot import ExpireSnapshots
2728
2829
@@ -92,8 +93,8 @@ def test_expire_unprotected_snapshot(table_v2: Table) -> None:
9293 table_v2 .metadata = table_v2 .metadata .model_copy (
9394 update = {
9495 "refs" : {
95- "main" : MagicMock ( snapshot_id = KEEP_SNAPSHOT , snapshot_ref_type = "branch" ),
96- "tag1" : MagicMock ( snapshot_id = KEEP_SNAPSHOT , snapshot_ref_type = "tag" ),
96+ "main" : SnapshotRef ( ** { "snapshot-id" : KEEP_SNAPSHOT , "type" : SnapshotRefType . BRANCH } ),
97+ "tag1" : SnapshotRef ( ** { "snapshot-id" : KEEP_SNAPSHOT , "type" : SnapshotRefType . TAG } ),
9798 }
9899 }
99100 )
@@ -134,8 +135,8 @@ def test_expire_snapshots_by_timestamp_skips_protected(table_v2: Table) -> None:
134135 table_v2 .metadata = table_v2 .metadata .model_copy (
135136 update = {
136137 "refs" : {
137- "main" : MagicMock ( snapshot_id = HEAD_SNAPSHOT , snapshot_ref_type = "branch" ),
138- "mytag" : MagicMock ( snapshot_id = TAGGED_SNAPSHOT , snapshot_ref_type = "tag" ),
138+ "main" : SnapshotRef ( ** { "snapshot-id" : HEAD_SNAPSHOT , "type" : SnapshotRefType . BRANCH } ),
139+ "mytag" : SnapshotRef ( ** { "snapshot-id" : TAGGED_SNAPSHOT , "type" : SnapshotRefType . TAG } ),
139140 },
140141 "snapshots" : [
141142 SimpleNamespace (snapshot_id = HEAD_SNAPSHOT , timestamp_ms = 1 , parent_snapshot_id = None ),
@@ -165,13 +166,8 @@ def test_expire_snapshots_by_timestamp_skips_protected(table_v2: Table) -> None:
165166 assert HEAD_SNAPSHOT in remaining_ids
166167 assert TAGGED_SNAPSHOT in remaining_ids
167168
168- # No snapshots should have been expired (commit_table called, but with empty snapshot_ids)
169- args , kwargs = table_v2 .catalog .commit_table .call_args
170- updates = args [2 ] if len (args ) > 2 else ()
171- # Find RemoveSnapshotsUpdate in updates
172- remove_update = next ((u for u in updates if getattr (u , "action" , None ) == "remove-snapshots" ), None )
173- assert remove_update is not None
174- assert remove_update .snapshot_ids == []
169+ # No snapshots expired and no refs expired — commit_table should not be called at all
170+ table_v2 .catalog .commit_table .assert_not_called ()
175171
176172
177173def test_expire_snapshots_by_ids (table_v2 : Table ) -> None :
@@ -188,24 +184,14 @@ def test_expire_snapshots_by_ids(table_v2: Table) -> None:
188184 table_v2 .catalog = MagicMock ()
189185 table_v2 .catalog .commit_table .return_value = mock_response
190186
191- # Remove any refs that protect the snapshots to be expired
192- table_v2 .metadata = table_v2 .metadata .model_copy (
193- update = {
194- "refs" : {
195- "main" : MagicMock (snapshot_id = KEEP_SNAPSHOT , snapshot_ref_type = "branch" ),
196- "tag1" : MagicMock (snapshot_id = KEEP_SNAPSHOT , snapshot_ref_type = "tag" ),
197- }
198- }
199- )
200-
201187 # Add snapshots to metadata for multi-id test
202188 from types import SimpleNamespace
203189
204190 table_v2 .metadata = table_v2 .metadata .model_copy (
205191 update = {
206192 "refs" : {
207- "main" : MagicMock ( snapshot_id = KEEP_SNAPSHOT , snapshot_ref_type = "branch" ),
208- "tag1" : MagicMock ( snapshot_id = KEEP_SNAPSHOT , snapshot_ref_type = "tag" ),
193+ "main" : SnapshotRef ( ** { "snapshot-id" : KEEP_SNAPSHOT , "type" : SnapshotRefType . BRANCH } ),
194+ "tag1" : SnapshotRef ( ** { "snapshot-id" : KEEP_SNAPSHOT , "type" : SnapshotRefType . TAG } ),
209195 },
210196 "snapshots" : [
211197 SimpleNamespace (snapshot_id = EXPIRE_SNAPSHOT_1 , timestamp_ms = 1 , parent_snapshot_id = None ),
@@ -316,3 +302,119 @@ def test_update_remove_snapshots_with_statistics(table_v2_with_statistics: Table
316302 assert not any (stat .snapshot_id == REMOVE_SNAPSHOT for stat in new_metadata .statistics ), (
317303 "Statistics for removed snapshot should be gone"
318304 )
305+
306+
307+ OLD_SNAPSHOT = 3051729675574597004
308+ CURRENT_SNAPSHOT = 3055729675574597004
309+
310+
311+ def _make_commit_response (table : Table ) -> CommitTableResponse :
312+ return CommitTableResponse (
313+ metadata = table .metadata ,
314+ metadata_location = "mock://metadata/location" ,
315+ uuid = uuid4 (),
316+ )
317+
318+
319+ def test_ref_expiration_removes_old_tag_and_snapshot (table_v2 : Table ) -> None :
320+ """A tag whose snapshot age exceeds max_ref_age_ms is removed; its orphaned snapshot
321+ is also expired when older_than() is combined."""
322+ table_v2 .catalog = MagicMock ()
323+ table_v2 .catalog .commit_table .return_value = _make_commit_response (table_v2 )
324+
325+ # "test" tag (fixture) points to OLD_SNAPSHOT with max-ref-age-ms=10000000 (~2.7 h).
326+ # OLD_SNAPSHOT timestamp is from 2018 — definitely older than 2.7 h.
327+ assert "test" in table_v2 .metadata .refs
328+ assert table_v2 .metadata .refs ["test" ].snapshot_id == OLD_SNAPSHOT
329+
330+ future = datetime .now () + timedelta (days = 1 )
331+ table_v2 .maintenance .expire_snapshots ().remove_expired_refs (default_max_ref_age_ms = 1 ).older_than (future ).commit ()
332+
333+ args , _ = table_v2 .catalog .commit_table .call_args
334+ updates = args [2 ]
335+
336+ ref_updates = [u for u in updates if isinstance (u , RemoveSnapshotRefUpdate )]
337+ snap_updates = [u for u in updates if isinstance (u , RemoveSnapshotsUpdate )]
338+
339+ assert any (u .ref_name == "test" for u in ref_updates ), "Expected 'test' tag to be removed"
340+ assert any (OLD_SNAPSHOT in u .snapshot_ids for u in snap_updates ), (
341+ "Expected OLD_SNAPSHOT to be removed since it is no longer referenced"
342+ )
343+
344+
345+ def test_ref_expiration_removes_old_branch (table_v2 : Table ) -> None :
346+ """A non-main branch whose snapshot age exceeds max_ref_age_ms is removed."""
347+ table_v2 .catalog = MagicMock ()
348+ table_v2 .catalog .commit_table .return_value = _make_commit_response (table_v2 )
349+
350+ table_v2 .metadata = table_v2 .metadata .model_copy (
351+ update = {
352+ "refs" : {
353+ "main" : SnapshotRef (** {"snapshot-id" : CURRENT_SNAPSHOT , "type" : SnapshotRefType .BRANCH }),
354+ "stale-branch" : SnapshotRef (
355+ ** {"snapshot-id" : OLD_SNAPSHOT , "type" : SnapshotRefType .BRANCH , "max-ref-age-ms" : 1 }
356+ ),
357+ }
358+ }
359+ )
360+
361+ table_v2 .maintenance .expire_snapshots ().remove_expired_refs (default_max_ref_age_ms = 1 ).commit ()
362+
363+ args , _ = table_v2 .catalog .commit_table .call_args
364+ updates = args [2 ]
365+ ref_updates = [u for u in updates if isinstance (u , RemoveSnapshotRefUpdate )]
366+ assert any (u .ref_name == "stale-branch" for u in ref_updates )
367+ assert not any (u .ref_name == "main" for u in ref_updates )
368+
369+
370+ def test_main_branch_never_expires (table_v2 : Table ) -> None :
371+ """main branch is never removed regardless of age or max_ref_age_ms."""
372+ table_v2 .catalog = MagicMock ()
373+
374+ table_v2 .metadata = table_v2 .metadata .model_copy (
375+ update = {
376+ "refs" : {
377+ "main" : SnapshotRef (
378+ ** {"snapshot-id" : CURRENT_SNAPSHOT , "type" : SnapshotRefType .BRANCH , "max-ref-age-ms" : 1 }
379+ ),
380+ }
381+ }
382+ )
383+
384+ table_v2 .maintenance .expire_snapshots ().remove_expired_refs (default_max_ref_age_ms = 1 ).commit ()
385+
386+ table_v2 .catalog .commit_table .assert_not_called ()
387+
388+
389+
390+ def test_young_ref_is_retained (table_v2 : Table ) -> None :
391+ """A ref whose snapshot is within max_ref_age_ms is not removed."""
392+ table_v2 .catalog = MagicMock ()
393+ table_v2 .catalog .commit_table .return_value = _make_commit_response (table_v2 )
394+
395+ # fresh-tag has a huge max_ref_age_ms — it should never expire
396+ # stale-tag has max_ref_age_ms=1 — it will be expired (triggers a commit)
397+ table_v2 .metadata = table_v2 .metadata .model_copy (
398+ update = {
399+ "refs" : {
400+ "main" : SnapshotRef (** {"snapshot-id" : CURRENT_SNAPSHOT , "type" : SnapshotRefType .BRANCH }),
401+ "fresh-tag" : SnapshotRef (
402+ ** {"snapshot-id" : OLD_SNAPSHOT , "type" : SnapshotRefType .TAG , "max-ref-age-ms" : 9999999999999 }
403+ ),
404+ "stale-tag" : SnapshotRef (
405+ ** {"snapshot-id" : OLD_SNAPSHOT , "type" : SnapshotRefType .TAG , "max-ref-age-ms" : 1 }
406+ ),
407+ }
408+ }
409+ )
410+
411+ table_v2 .maintenance .expire_snapshots ().remove_expired_refs (default_max_ref_age_ms = 1 ).commit ()
412+
413+ table_v2 .catalog .commit_table .assert_called_once ()
414+ args , _ = table_v2 .catalog .commit_table .call_args
415+ updates = args [2 ]
416+ ref_updates = [u for u in updates if isinstance (u , RemoveSnapshotRefUpdate )]
417+ assert any (u .ref_name == "stale-tag" for u in ref_updates ), "stale-tag should be expired"
418+ assert not any (u .ref_name == "fresh-tag" for u in ref_updates ), "fresh-tag must not be expired"
419+
420+
0 commit comments