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,125 @@ 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+ # --- Ref expiration tests ---
308+ # The table_v2 fixture has two snapshots:
309+ # 3051729675574597004 (timestamp_ms=1515100955770, ~Jan 2018)
310+ # 3055729675574597004 (timestamp_ms=1555100955770, ~Apr 2019, current/main)
311+ # And a "test" tag pointing to 3051729675574597004 with max-ref-age-ms=10000000 (~2.7 h).
312+
313+ OLD_SNAPSHOT = 3051729675574597004
314+ CURRENT_SNAPSHOT = 3055729675574597004
315+
316+
317+ def _make_commit_response (table : Table ) -> CommitTableResponse :
318+ return CommitTableResponse (
319+ metadata = table .metadata ,
320+ metadata_location = "mock://metadata/location" ,
321+ uuid = uuid4 (),
322+ )
323+
324+
325+ def test_ref_expiration_removes_old_tag_and_snapshot (table_v2 : Table ) -> None :
326+ """A tag whose snapshot age exceeds max_ref_age_ms is removed; its orphaned snapshot
327+ is also expired when older_than() is combined."""
328+ table_v2 .catalog = MagicMock ()
329+ table_v2 .catalog .commit_table .return_value = _make_commit_response (table_v2 )
330+
331+ # "test" tag (fixture) points to OLD_SNAPSHOT with max-ref-age-ms=10000000 (~2.7 h).
332+ # OLD_SNAPSHOT timestamp is from 2018 — definitely older than 2.7 h.
333+ assert "test" in table_v2 .metadata .refs
334+ assert table_v2 .metadata .refs ["test" ].snapshot_id == OLD_SNAPSHOT
335+
336+ future = datetime .now () + timedelta (days = 1 )
337+ table_v2 .maintenance .expire_snapshots ().remove_expired_refs (default_max_ref_age_ms = 1 ).older_than (future ).commit ()
338+
339+ args , _ = table_v2 .catalog .commit_table .call_args
340+ updates = args [2 ]
341+
342+ ref_updates = [u for u in updates if isinstance (u , RemoveSnapshotRefUpdate )]
343+ snap_updates = [u for u in updates if isinstance (u , RemoveSnapshotsUpdate )]
344+
345+ assert any (u .ref_name == "test" for u in ref_updates ), "Expected 'test' tag to be removed"
346+ assert any (OLD_SNAPSHOT in u .snapshot_ids for u in snap_updates ), (
347+ "Expected OLD_SNAPSHOT to be removed since it is no longer referenced"
348+ )
349+
350+
351+ def test_ref_expiration_removes_old_branch (table_v2 : Table ) -> None :
352+ """A non-main branch whose snapshot age exceeds max_ref_age_ms is removed."""
353+ table_v2 .catalog = MagicMock ()
354+ table_v2 .catalog .commit_table .return_value = _make_commit_response (table_v2 )
355+
356+ table_v2 .metadata = table_v2 .metadata .model_copy (
357+ update = {
358+ "refs" : {
359+ "main" : SnapshotRef (** {"snapshot-id" : CURRENT_SNAPSHOT , "type" : SnapshotRefType .BRANCH }),
360+ "stale-branch" : SnapshotRef (
361+ ** {"snapshot-id" : OLD_SNAPSHOT , "type" : SnapshotRefType .BRANCH , "max-ref-age-ms" : 1 }
362+ ),
363+ }
364+ }
365+ )
366+
367+ table_v2 .maintenance .expire_snapshots ().remove_expired_refs (default_max_ref_age_ms = 1 ).commit ()
368+
369+ args , _ = table_v2 .catalog .commit_table .call_args
370+ updates = args [2 ]
371+ ref_updates = [u for u in updates if isinstance (u , RemoveSnapshotRefUpdate )]
372+ assert any (u .ref_name == "stale-branch" for u in ref_updates )
373+ assert not any (u .ref_name == "main" for u in ref_updates )
374+
375+
376+ def test_main_branch_never_expires (table_v2 : Table ) -> None :
377+ """main branch is never removed regardless of age or max_ref_age_ms."""
378+ table_v2 .catalog = MagicMock ()
379+
380+ table_v2 .metadata = table_v2 .metadata .model_copy (
381+ update = {
382+ "refs" : {
383+ "main" : SnapshotRef (
384+ ** {"snapshot-id" : CURRENT_SNAPSHOT , "type" : SnapshotRefType .BRANCH , "max-ref-age-ms" : 1 }
385+ ),
386+ }
387+ }
388+ )
389+
390+ table_v2 .maintenance .expire_snapshots ().remove_expired_refs (default_max_ref_age_ms = 1 ).commit ()
391+
392+ table_v2 .catalog .commit_table .assert_not_called ()
393+
394+
395+
396+ def test_young_ref_is_retained (table_v2 : Table ) -> None :
397+ """A ref whose snapshot is within max_ref_age_ms is not removed."""
398+ table_v2 .catalog = MagicMock ()
399+ table_v2 .catalog .commit_table .return_value = _make_commit_response (table_v2 )
400+
401+ # fresh-tag has a huge max_ref_age_ms — it should never expire
402+ # stale-tag has max_ref_age_ms=1 — it will be expired (triggers a commit)
403+ table_v2 .metadata = table_v2 .metadata .model_copy (
404+ update = {
405+ "refs" : {
406+ "main" : SnapshotRef (** {"snapshot-id" : CURRENT_SNAPSHOT , "type" : SnapshotRefType .BRANCH }),
407+ "fresh-tag" : SnapshotRef (
408+ ** {"snapshot-id" : OLD_SNAPSHOT , "type" : SnapshotRefType .TAG , "max-ref-age-ms" : 9999999999999 }
409+ ),
410+ "stale-tag" : SnapshotRef (
411+ ** {"snapshot-id" : OLD_SNAPSHOT , "type" : SnapshotRefType .TAG , "max-ref-age-ms" : 1 }
412+ ),
413+ }
414+ }
415+ )
416+
417+ table_v2 .maintenance .expire_snapshots ().remove_expired_refs (default_max_ref_age_ms = 1 ).commit ()
418+
419+ table_v2 .catalog .commit_table .assert_called_once ()
420+ args , _ = table_v2 .catalog .commit_table .call_args
421+ updates = args [2 ]
422+ ref_updates = [u for u in updates if isinstance (u , RemoveSnapshotRefUpdate )]
423+ assert any (u .ref_name == "stale-tag" for u in ref_updates ), "stale-tag should be expired"
424+ assert not any (u .ref_name == "fresh-tag" for u in ref_updates ), "fresh-tag must not be expired"
425+
426+
0 commit comments