Skip to content

Commit 15f2635

Browse files
Expire stale refs in ExpireSnapshots
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 721c5aa commit 15f2635

3 files changed

Lines changed: 233 additions & 33 deletions

File tree

‎pyiceberg/table/__init__.py‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -205,6 +205,8 @@ class TableProperties:
205205
MIN_SNAPSHOTS_TO_KEEP = "history.expire.min-snapshots-to-keep"
206206
MIN_SNAPSHOTS_TO_KEEP_DEFAULT = 1
207207

208+
MAX_REF_AGE_MS = "history.expire.max-ref-age-ms"
209+
208210

209211
class Transaction:
210212
_table: Table

‎pyiceberg/table/update/snapshot.py‎

Lines changed: 66 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1047,31 +1047,88 @@ def _commit(self) -> UpdatesAndRequirements:
10471047
Commit the staged updates and requirements.
10481048
10491049
This will remove the snapshots with the given IDs, but will always skip protected snapshots (branch/tag heads).
1050+
Refs (branches/tags) whose snapshot age exceeds their configured max-ref-age-ms are also removed.
10501051
10511052
Returns:
10521053
Tuple of updates and requirements to be committed,
10531054
as required by the calling parent apply functions.
10541055
"""
1055-
# Remove any protected snapshot IDs from the set to expire, just in case
1056-
protected_ids = self._get_protected_snapshot_ids()
1057-
self._snapshot_ids_to_expire -= protected_ids
1058-
update = RemoveSnapshotsUpdate(snapshot_ids=self._snapshot_ids_to_expire)
1059-
self._updates += (update,)
1056+
now_ms = int(datetime.now().timestamp() * 1000)
1057+
expired_ref_names = self._compute_expired_refs(now_ms)
1058+
protected_ids = self._get_protected_snapshot_ids(expired_ref_names)
1059+
1060+
# Snapshots exclusively referenced by expired refs are also eligible for expiration
1061+
for ref_name in expired_ref_names:
1062+
ref = self._transaction.table_metadata.refs[ref_name]
1063+
if ref.snapshot_id not in protected_ids:
1064+
self._snapshot_ids_to_expire.add(ref.snapshot_id)
1065+
1066+
snapshot_ids_to_expire = self._snapshot_ids_to_expire - protected_ids
1067+
1068+
updates: list[TableUpdate] = list(self._updates)
1069+
for ref_name in expired_ref_names:
1070+
updates.append(RemoveSnapshotRefUpdate(ref_name=ref_name))
1071+
if snapshot_ids_to_expire:
1072+
updates.append(RemoveSnapshotsUpdate(snapshot_ids=snapshot_ids_to_expire))
1073+
self._updates = tuple(updates)
10601074
return self._updates, self._requirements
10611075

1062-
def _get_protected_snapshot_ids(self) -> set[int]:
1076+
def _compute_expired_refs(self, now_ms: int) -> set[str]:
1077+
"""
1078+
Compute the set of ref names (branches/tags) that should be expired.
1079+
1080+
A ref is expired when the age of its snapshot exceeds:
1081+
- the ref's own max_ref_age_ms, or
1082+
- the table property history.expire.max-ref-age-ms, if the ref has no per-ref setting.
1083+
The main branch is never expired. Refs with no effective max-ref-age configuration are skipped.
1084+
1085+
Args:
1086+
now_ms: Current time in milliseconds.
1087+
1088+
Returns:
1089+
Set of ref names to remove.
1090+
"""
1091+
from pyiceberg.table import TableProperties
1092+
1093+
props = self._transaction.table_metadata.properties
1094+
table_max_ref_age_ms: int | None = (
1095+
int(props[TableProperties.MAX_REF_AGE_MS]) if TableProperties.MAX_REF_AGE_MS in props else None
1096+
)
1097+
1098+
expired: set[str] = set()
1099+
for name, ref in self._transaction.table_metadata.refs.items():
1100+
if name == MAIN_BRANCH:
1101+
continue
1102+
effective_max_ref_age_ms = ref.max_ref_age_ms if ref.max_ref_age_ms is not None else table_max_ref_age_ms
1103+
if effective_max_ref_age_ms is None:
1104+
continue
1105+
snapshot = self._transaction.table_metadata.snapshot_by_id(ref.snapshot_id)
1106+
if snapshot is None:
1107+
expired.add(name)
1108+
continue
1109+
if (now_ms - snapshot.timestamp_ms) > effective_max_ref_age_ms:
1110+
expired.add(name)
1111+
return expired
1112+
1113+
def _get_protected_snapshot_ids(self, expired_ref_names: set[str] | None = None) -> set[int]:
10631114
"""
10641115
Get the IDs of protected snapshots.
10651116
1066-
These are the HEAD snapshots of all branches and all tagged snapshots. These ids are to be excluded from expiration.
1117+
These are the HEAD snapshots of all branches and all tagged snapshots that are NOT being expired.
1118+
Snapshots exclusively pointed to by expired refs are not protected.
1119+
1120+
Args:
1121+
expired_ref_names: Set of ref names that are being expired. Defaults to empty set.
10671122
10681123
Returns:
10691124
Set of protected snapshot IDs to exclude from expiration.
10701125
"""
1126+
if expired_ref_names is None:
1127+
expired_ref_names = set()
10711128
return {
10721129
ref.snapshot_id
1073-
for ref in self._transaction.table_metadata.refs.values()
1074-
if ref.snapshot_ref_type in [SnapshotRefType.TAG, SnapshotRefType.BRANCH]
1130+
for name, ref in self._transaction.table_metadata.refs.items()
1131+
if name not in expired_ref_names
10751132
}
10761133

10771134
def by_id(self, snapshot_id: int) -> ExpireSnapshots:

‎tests/table/test_expire_snapshots.py‎

Lines changed: 165 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,8 @@
2222
import pytest
2323

2424
from 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
2627
from 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

177173
def 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

Comments
 (0)