Skip to content

Commit 1652c1a

Browse files
JeroenSchmidtFokko
andauthored
feat: add ManageSnapshots.fast_forward_branch (#3649)
* feat: add ManageSnapshots.fast_forward_branch test: fast_forward_branch auto-creates missing from_branch test: fast_forward_branch is a no-op when snapshots already equal test: fast_forward_branch rejects a tag as the source ref test: fast_forward_branch rejects missing to_ref test: fast_forward_branch rejects non-ancestor target test: fast_forward_branch preserves retention fields test: fast_forward_branch composes in a manage_snapshots chain test(integration): fast_forward_branch on real catalogs docs: add fast_forward_branch section with WAP example chore: apply ruff-format and add None-checks for mypy * feat: intra-chain ref lookups via _effective_refs in ManageSnapshots * test(integration): fast_forward_branch preserves retention intra-chain * docs(fix): Cleanup Example & Add Mermaid Diagram * tests(PR Comments 1): Add fast_forward tag case tests(PR Comments 2): Add more noop cases tests(cleanup): Rename tests with naming format `test__{method}__with_{condition}__{outcome}`. tests(cleanup): Move tests into test class groupings tests(cleanup): Added explicit `table_v2_main_behind` fixture & update the tests to have main be fast-forwarded to reduce confusion against WAP process. * NIT: snapshot.py | Using walrus notation for single lookup. Co-authored-by: Fokko Driesprong <fokko@apache.org> * Trigger Build * fix: Bug introduced by single lookup walrus operation --------- Co-authored-by: Fokko Driesprong <fokko@apache.org>
1 parent 0cd614b commit 1652c1a

8 files changed

Lines changed: 700 additions & 53 deletions

File tree

‎mkdocs/docs/api.md‎

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1483,6 +1483,110 @@ Remove an existing branch:
14831483
table.manage_snapshots().remove_branch("dev").commit()
14841484
```
14851485

1486+
#### Fast-forwarding a branch
1487+
1488+
Fast-forward the `main` branch to the `audit-branch` branch:
1489+
1490+
```python
1491+
with table.manage_snapshots() as ms:
1492+
ms.fast_forward_branch(from_branch="main", to_ref="audit-branch")
1493+
```
1494+
1495+
Fast-forward `from_branch` to point at the snapshot referenced by `to_ref`.
1496+
`to_ref` may be a branch or tag. `from_branch` must be a branch.
1497+
1498+
<!-- markdownlint-disable MD046 -- Allowing indented multi-line formatting in admonition-->
1499+
1500+
!!! info "Fast Forward Behavior"
1501+
1502+
* Case 1: If `from_branch` does not yet exist it is created and pointing at `to_ref`'s
1503+
snapshot. The default retention properties are applied on the auto-created snapshot.
1504+
* Case 2:** If both already point at the same snapshot the call is a no-op.
1505+
* Case 3: Otherwise `from_branch`'s current snapshot must be an ancestor of `to_ref`'s snapshot;
1506+
if not, `NotAncestorError` is raised.
1507+
1508+
<!-- markdownlint-enable MD046 -->
1509+
1510+
#### Example Use-Case: write-audit-publish (WAP)
1511+
1512+
The use of branching & fast-forwarding enable the usage of the write-audit-publish (WAP) process:
1513+
1514+
1. Writes proceed on a side branch
1515+
2. Audit Validation runs against that branch
1516+
3. Publish the new data by fast-forwarding the main branch
1517+
1518+
```mermaid
1519+
---
1520+
title: Conceptually Illustration of the WAP Process
1521+
---
1522+
flowchart LR
1523+
1524+
subgraph audit [audit branch]
1525+
s1_audit["snapshot_1"] -- "1.2 append(new_rows)" --> s2_audit["snapshot_2"]
1526+
v@{ shape: comment, label: '2. Validation Performed & Passed' }
1527+
s2_audit ~~~ v
1528+
v -.-> s2_audit
1529+
end
1530+
1531+
subgraph main [main branch]
1532+
s1["snapshot_1"]
1533+
s2_main["snapshot_2"]
1534+
1535+
end
1536+
1537+
s1 -. "1.1 create_branch" .-> s1_audit
1538+
s2_audit -. "3. fast_forward_branch" .-> s2_main
1539+
1540+
```
1541+
1542+
If validation fails, callers simply skip the fast-forward step. The
1543+
audit branch (and its data files) can then be inspected, rewritten,
1544+
or removed via `remove_branch` and subsequent snapshot expiration -
1545+
without ever having polluted the data on `main`.
1546+
1547+
##### Programmatic Example
1548+
1549+
```python
1550+
import pyarrow as pa
1551+
import pyarrow.compute as pc
1552+
from pyiceberg.catalog import load_catalog
1553+
1554+
catalog = load_catalog("prod")
1555+
table = catalog.load_table("sales.orders")
1556+
1557+
# 1. WRITE — create a side branch off main and append to it.
1558+
# 1.1 Create the Branch
1559+
main_snapshot_id = table.current_snapshot().snapshot_id
1560+
table.manage_snapshots().create_branch(
1561+
snapshot_id=main_snapshot_id,
1562+
branch_name="audit",
1563+
).commit()
1564+
1565+
new_rows = pa.table({
1566+
"order_id": [1001, 1002, 1003],
1567+
"amount": [ 49.99, 129.00, 12.50],
1568+
})
1569+
1570+
# 1.2 Write into the branch
1571+
table.append(new_rows, branch="audit")
1572+
1573+
# 2. AUDIT — scan the audit branch and run whatever validation your data-quality contract requires.
1574+
# Nothing on `main` has changed yet, so readers of `main` still see the pre-write state.
1575+
audit_snapshot_id = table.refs()["audit"].snapshot_id
1576+
audit_data = table.scan(snapshot_id=audit_snapshot_id).to_arrow()
1577+
1578+
assert audit_data.num_rows > 0, "audit branch is empty"
1579+
assert pc.all(pc.greater(audit_data["amount"], 0)).as_py(), \
1580+
"found non-positive amounts"
1581+
1582+
# 3. PUBLISH — validation passed; fast-forward main to audit.
1583+
# fast-forward can occur because the audit branch was created from main and
1584+
# main's current snapshot is an ancestor of audit's snapshot.
1585+
with table.manage_snapshots() as ms:
1586+
ms.fast_forward_branch("main", "audit")
1587+
ms.remove_branch("audit") # optional: clean up the side branch
1588+
```
1589+
14861590
## Table Maintenance
14871591

14881592
PyIceberg provides table maintenance operations through the `table.maintenance` API. This provides a clean interface for performing maintenance tasks like snapshot expiration.

‎mkdocs/mkdocs.yml‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,5 +61,15 @@ markdown_extensions:
6161
- pymdownx.highlight:
6262
anchor_linenums: true
6363
- pymdownx.superfences
64+
- pymdownx.superfences:
65+
preserve_tabs: true
66+
custom_fences:
67+
# Mermaid diagrams
68+
# Needed so that Superfences doesn't break Mermaid
69+
# See: https://facelessuser.github.io/pymdown-extensions/extras/mermaid/#using-in-mkdocs
70+
- name: mermaid
71+
class: mermaid
72+
format: !!python/name:pymdownx.superfences.fence_code_format
73+
6474
- toc:
6575
permalink: true

‎pyiceberg/exceptions.py‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -146,3 +146,15 @@ class WaitingForLockException(Exception):
146146

147147
class ValidationException(Exception):
148148
"""Raised when validation fails."""
149+
150+
151+
class NoSuchSnapshotRefError(ValueError):
152+
"""Raised when a named snapshot ref (branch or tag) does not exist."""
153+
154+
155+
class SnapshotRefTypeError(ValueError):
156+
"""Raised when an operation expects a branch and gets a tag (or vice versa)."""
157+
158+
159+
class NotAncestorError(ValueError):
160+
"""Raised when an operation requires ancestry between two snapshots and it does not hold."""

‎pyiceberg/table/update/snapshot.py‎

Lines changed: 92 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,12 @@
2828
from typing import TYPE_CHECKING, Generic
2929

3030
from pyiceberg.avro.codecs import AvroCompressionCodec
31-
from pyiceberg.exceptions import ValidationException
31+
from pyiceberg.exceptions import (
32+
NoSuchSnapshotRefError,
33+
NotAncestorError,
34+
SnapshotRefTypeError,
35+
ValidationException,
36+
)
3237
from pyiceberg.expressions import AlwaysFalse, BooleanExpression, Or
3338
from pyiceberg.expressions.visitors import (
3439
ROWS_MIGHT_NOT_MATCH,
@@ -52,13 +57,14 @@
5257
)
5358
from pyiceberg.partitioning import PartitionSpec
5459
from pyiceberg.schema import Schema
55-
from pyiceberg.table.refs import MAIN_BRANCH, SnapshotRefType
60+
from pyiceberg.table.refs import MAIN_BRANCH, SnapshotRef, SnapshotRefType
5661
from pyiceberg.table.snapshots import (
5762
Operation,
5863
Snapshot,
5964
SnapshotSummaryCollector,
6065
Summary,
6166
ancestors_of,
67+
is_ancestor_of,
6268
latest_ancestor_before_timestamp,
6369
update_snapshot_summaries,
6470
)
@@ -1043,6 +1049,35 @@ def _commit_if_ref_updates_exist(self) -> None:
10431049
self._updates = ()
10441050
self._requirements = ()
10451051

1052+
def _effective_refs(self) -> dict[str, SnapshotRef]:
1053+
"""Return refs as they would appear after all currently-staged updates.
1054+
1055+
Committed refs from ``table_metadata.refs`` overlaid with the effects
1056+
of every ``SetSnapshotRefUpdate`` / ``RemoveSnapshotRefUpdate`` that has
1057+
been accumulated onto ``self._updates`` in this chain, in order. Later
1058+
stages win. Callers use this instead of ``table_metadata.refs`` when a
1059+
decision needs to observe the results of earlier operations in the
1060+
same ``manage_snapshots()`` chain.
1061+
1062+
Note that this projection is for *decision-making* only. Requirements
1063+
emitted via ``_set_ref_snapshot`` continue to reference committed
1064+
state, which is what the catalog checks at commit time and what makes
1065+
concurrent-write detection correct.
1066+
"""
1067+
refs: dict[str, SnapshotRef] = dict(self._transaction.table_metadata.refs)
1068+
for update in self._updates:
1069+
if isinstance(update, SetSnapshotRefUpdate):
1070+
refs[update.ref_name] = SnapshotRef(
1071+
snapshot_id=update.snapshot_id,
1072+
snapshot_ref_type=update.type,
1073+
max_ref_age_ms=update.max_ref_age_ms,
1074+
max_snapshot_age_ms=update.max_snapshot_age_ms,
1075+
min_snapshots_to_keep=update.min_snapshots_to_keep,
1076+
)
1077+
elif isinstance(update, RemoveSnapshotRefUpdate):
1078+
refs.pop(update.ref_name, None)
1079+
return refs
1080+
10461081
def _remove_ref_snapshot(self, ref_name: str) -> ManageSnapshots:
10471082
"""Remove a snapshot ref.
10481083
@@ -1232,6 +1267,61 @@ def _current_ancestors(self) -> set[int]:
12321267
)
12331268
}
12341269

1270+
def fast_forward_branch(self, from_branch: str, to_ref: str) -> ManageSnapshots:
1271+
"""Fast-forward ``from_branch`` to the snapshot referenced by ``to_ref``.
1272+
1273+
* If ``from_branch`` does not exist, it is created pointing at ``to_ref``'s snapshot (Java/Spark parity).
1274+
* If both refs already point to the same snapshot the call is a no-op.
1275+
* Otherwise ``from_branch`` must be a branch (not a tag) and its current snapshot
1276+
must be an ancestor of ``to_ref``'s snapshot.
1277+
1278+
Within a single ``manage_snapshots()`` chain, ref lookups observe earlier staged
1279+
operations via :meth:`_effective_refs`. This means that `create_branch(...)` followed
1280+
by `fast_forward_branch(...)` on the same ref works as expected.
1281+
1282+
Args:
1283+
from_branch: name of the branch to advance.
1284+
to_ref: name of the branch or tag whose snapshot ``from_branch`` will point to.
1285+
1286+
Returns:
1287+
This for method chaining.
1288+
1289+
Raises:
1290+
NoSuchSnapshotRefError: ``to_ref`` does not exist.
1291+
SnapshotRefTypeError: ``from_branch`` exists but is a tag.
1292+
NotAncestorError: ``from_branch``'s snapshot is not an ancestor of ``to_ref``'s snapshot.
1293+
"""
1294+
refs = self._effective_refs()
1295+
1296+
if (to_snapshot_ref := refs.get(to_ref)) is None:
1297+
raise NoSuchSnapshotRefError(f"Ref does not exist: {to_ref}")
1298+
to_snapshot_id = to_snapshot_ref.snapshot_id
1299+
1300+
if from_branch not in refs:
1301+
return self.create_branch(snapshot_id=to_snapshot_id, branch_name=from_branch)
1302+
1303+
from_ref = refs[from_branch]
1304+
if from_ref.snapshot_ref_type != SnapshotRefType.BRANCH:
1305+
raise SnapshotRefTypeError(f"Ref {from_branch} is a tag, not a branch")
1306+
1307+
if from_ref.snapshot_id == to_snapshot_id:
1308+
return self
1309+
1310+
if not is_ancestor_of(to_snapshot_id, from_ref.snapshot_id, self._transaction.table_metadata):
1311+
raise NotAncestorError(f"Cannot fast-forward: {from_branch} is not an ancestor of {to_ref}")
1312+
1313+
update, requirement = self._transaction._set_ref_snapshot(
1314+
snapshot_id=to_snapshot_id,
1315+
ref_name=from_branch,
1316+
type=SnapshotRefType.BRANCH,
1317+
max_ref_age_ms=from_ref.max_ref_age_ms,
1318+
max_snapshot_age_ms=from_ref.max_snapshot_age_ms,
1319+
min_snapshots_to_keep=from_ref.min_snapshots_to_keep,
1320+
)
1321+
self._updates += update
1322+
self._requirements += requirement
1323+
return self
1324+
12351325

12361326
class ExpireSnapshots(UpdateTableMetadata["ExpireSnapshots"]):
12371327
"""Expire snapshots by ID.

‎tests/integration/test_snapshot_operations.py‎

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -282,6 +282,80 @@ def test_rollback_to_timestamp_no_valid_snapshot(table_with_snapshots: Table) ->
282282
table_with_snapshots.manage_snapshots().rollback_to_timestamp(timestamp_ms=oldest_timestamp).commit()
283283

284284

285+
@pytest.mark.integration
286+
@pytest.mark.parametrize("catalog", [lf("session_catalog_hive"), lf("session_catalog")])
287+
def test_fast_forward_branch(catalog: Catalog) -> None:
288+
identifier = "default.test_table_snapshot_operations"
289+
tbl = catalog.load_table(identifier)
290+
assert len(tbl.history()) > 2
291+
292+
# Create a side branch off an older snapshot on main, then append to it
293+
# so that the side branch is a strict descendant of main's older snapshot
294+
# but the *current* main is not yet caught up.
295+
current_snapshot = tbl.current_snapshot()
296+
assert current_snapshot is not None
297+
main_snapshot_id = current_snapshot.snapshot_id
298+
side_branch = "audit_ff"
299+
300+
tbl.manage_snapshots().create_branch(snapshot_id=main_snapshot_id, branch_name=side_branch).commit()
301+
302+
arrow_schema = tbl.schema().as_arrow()
303+
new_rows = pa.Table.from_pylist([{col.name: None for col in arrow_schema}], schema=arrow_schema)
304+
tbl.append(new_rows, branch=side_branch)
305+
306+
# Validate appending to the side branch has advanced its snapshot.
307+
tbl = catalog.load_table(identifier)
308+
audit_snapshot_id = tbl.refs()[side_branch].snapshot_id
309+
assert audit_snapshot_id != main_snapshot_id, "side-branch append should have advanced it"
310+
311+
# Fast-forward main to the side branch.
312+
tbl.manage_snapshots().fast_forward_branch(from_branch="main", to_ref=side_branch).commit()
313+
314+
tbl = catalog.load_table(identifier)
315+
assert tbl.refs()["main"].snapshot_id == audit_snapshot_id
316+
317+
318+
@pytest.mark.integration
319+
@pytest.mark.parametrize("catalog", [lf("session_catalog_hive"), lf("session_catalog")])
320+
def test_fast_forward_branch_preserves_retention_intra_chain(catalog: Catalog) -> None:
321+
identifier = "default.test_table_snapshot_operations"
322+
tbl = catalog.load_table(identifier)
323+
assert len(tbl.history()) > 2
324+
325+
# Pick an older snapshot as the branch's starting point so the subsequent
326+
# fast-forward to main is a real advance, not a no-op.
327+
older_snapshot_id = tbl.history()[-3].snapshot_id
328+
current_snapshot = tbl.current_snapshot()
329+
assert current_snapshot is not None
330+
main_snapshot_id = current_snapshot.snapshot_id
331+
332+
branch_name = "retention_intra_chain"
333+
max_ref = 3_600_000 # 1h — distinct value per field so a swap would be caught
334+
max_snap = 7_200_000 # 2h
335+
min_keep = 5
336+
337+
# Chain create_branch (with retention) + fast_forward_branch in one commit.
338+
# _effective_refs lets the fast-forward observe the same-chain create and
339+
# carry the retention fields onto the second staged SetSnapshotRefUpdate.
340+
tbl.manage_snapshots().create_branch(
341+
snapshot_id=older_snapshot_id,
342+
branch_name=branch_name,
343+
max_ref_age_ms=max_ref,
344+
max_snapshot_age_ms=max_snap,
345+
min_snapshots_to_keep=min_keep,
346+
).fast_forward_branch(
347+
from_branch=branch_name,
348+
to_ref="main",
349+
).commit()
350+
351+
tbl = catalog.load_table(identifier)
352+
ref = tbl.refs()[branch_name]
353+
assert ref.snapshot_id == main_snapshot_id
354+
assert ref.max_ref_age_ms == max_ref
355+
assert ref.max_snapshot_age_ms == max_snap
356+
assert ref.min_snapshots_to_keep == min_keep
357+
358+
285359
@pytest.mark.integration
286360
def test_rollback_to_timestamp(table_with_snapshots: Table) -> None:
287361
current_snapshot = table_with_snapshots.current_snapshot()

0 commit comments

Comments
 (0)