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,158 @@ 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 along with its snapshot."""
327+ table_v2 .catalog = MagicMock ()
328+ table_v2 .catalog .commit_table .return_value = _make_commit_response (table_v2 )
329+
330+ # "test" tag (fixture) points to OLD_SNAPSHOT with max-ref-age-ms=10000000 (~2.7 h).
331+ # OLD_SNAPSHOT timestamp is from 2018 — definitely older than 2.7 h.
332+ assert "test" in table_v2 .metadata .refs
333+ assert table_v2 .metadata .refs ["test" ].snapshot_id == OLD_SNAPSHOT
334+
335+ table_v2 .maintenance .expire_snapshots ().commit ()
336+
337+ args , _ = table_v2 .catalog .commit_table .call_args
338+ updates = args [2 ]
339+
340+ ref_updates = [u for u in updates if isinstance (u , RemoveSnapshotRefUpdate )]
341+ snap_updates = [u for u in updates if isinstance (u , RemoveSnapshotsUpdate )]
342+
343+ assert any (u .ref_name == "test" for u in ref_updates ), "Expected 'test' tag to be removed"
344+ assert any (OLD_SNAPSHOT in u .snapshot_ids for u in snap_updates ), (
345+ "Expected OLD_SNAPSHOT to be removed since it is no longer referenced"
346+ )
347+
348+
349+ def test_ref_expiration_removes_old_branch (table_v2 : Table ) -> None :
350+ """A non-main branch whose snapshot age exceeds max_ref_age_ms is removed."""
351+ table_v2 .catalog = MagicMock ()
352+ table_v2 .catalog .commit_table .return_value = _make_commit_response (table_v2 )
353+
354+ table_v2 .metadata = table_v2 .metadata .model_copy (
355+ update = {
356+ "refs" : {
357+ "main" : SnapshotRef (
358+ ** {"snapshot-id" : CURRENT_SNAPSHOT , "type" : SnapshotRefType .BRANCH }
359+ ),
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 ().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+ table_v2 .catalog .commit_table .return_value = _make_commit_response (table_v2 )
380+
381+ # Only main exists, with max_ref_age_ms=1 (would expire anything else immediately)
382+ table_v2 .metadata = table_v2 .metadata .model_copy (
383+ update = {
384+ "refs" : {
385+ "main" : SnapshotRef (
386+ ** {"snapshot-id" : CURRENT_SNAPSHOT , "type" : SnapshotRefType .BRANCH , "max-ref-age-ms" : 1 }
387+ ),
388+ }
389+ }
390+ )
391+
392+ table_v2 .maintenance .expire_snapshots ().commit ()
393+
394+ # commit_table should NOT be called — main is never expired, so nothing changes
395+ table_v2 .catalog .commit_table .assert_not_called ()
396+
397+
398+ def test_table_property_max_ref_age_ms_used_as_default (table_v2 : Table ) -> None :
399+ """A ref without per-ref max_ref_age_ms uses the table property as fallback."""
400+ table_v2 .catalog = MagicMock ()
401+ table_v2 .catalog .commit_table .return_value = _make_commit_response (table_v2 )
402+
403+ # Ref has no per-ref max_ref_age_ms
404+ table_v2 .metadata = table_v2 .metadata .model_copy (
405+ update = {
406+ "refs" : {
407+ "main" : SnapshotRef (
408+ ** {"snapshot-id" : CURRENT_SNAPSHOT , "type" : SnapshotRefType .BRANCH }
409+ ),
410+ "old-tag" : SnapshotRef (
411+ ** {"snapshot-id" : OLD_SNAPSHOT , "type" : SnapshotRefType .TAG }
412+ ),
413+ },
414+ # Table-level default: 1 ms — everything is expired
415+ "properties" : {"history.expire.max-ref-age-ms" : "1" },
416+ }
417+ )
418+
419+ table_v2 .maintenance .expire_snapshots ().commit ()
420+
421+ args , _ = table_v2 .catalog .commit_table .call_args
422+ updates = args [2 ]
423+ ref_updates = [u for u in updates if isinstance (u , RemoveSnapshotRefUpdate )]
424+ assert any (u .ref_name == "old-tag" for u in ref_updates ), (
425+ "Expected old-tag to be expired via table property default"
426+ )
427+ assert not any (u .ref_name == "main" for u in ref_updates )
428+
429+
430+ def test_young_ref_is_retained (table_v2 : Table ) -> None :
431+ """A ref whose snapshot is within max_ref_age_ms is not removed."""
432+ table_v2 .catalog = MagicMock ()
433+ table_v2 .catalog .commit_table .return_value = _make_commit_response (table_v2 )
434+
435+ # fresh-tag has a huge max_ref_age_ms — it should never expire
436+ # stale-tag has max_ref_age_ms=1 — it will be expired (triggers a commit)
437+ table_v2 .metadata = table_v2 .metadata .model_copy (
438+ update = {
439+ "refs" : {
440+ "main" : SnapshotRef (** {"snapshot-id" : CURRENT_SNAPSHOT , "type" : SnapshotRefType .BRANCH }),
441+ "fresh-tag" : SnapshotRef (
442+ ** {"snapshot-id" : OLD_SNAPSHOT , "type" : SnapshotRefType .TAG , "max-ref-age-ms" : 9999999999999 }
443+ ),
444+ "stale-tag" : SnapshotRef (
445+ ** {"snapshot-id" : OLD_SNAPSHOT , "type" : SnapshotRefType .TAG , "max-ref-age-ms" : 1 }
446+ ),
447+ }
448+ }
449+ )
450+
451+ table_v2 .maintenance .expire_snapshots ().commit ()
452+
453+ # stale-tag causes commit_table to be called; fresh-tag must not be in the expired list
454+ table_v2 .catalog .commit_table .assert_called_once ()
455+ args , _ = table_v2 .catalog .commit_table .call_args
456+ updates = args [2 ]
457+ ref_updates = [u for u in updates if isinstance (u , RemoveSnapshotRefUpdate )]
458+ assert any (u .ref_name == "stale-tag" for u in ref_updates ), "stale-tag should be expired"
459+ assert not any (u .ref_name == "fresh-tag" for u in ref_updates ), "fresh-tag must not be expired"
0 commit comments