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