Skip to content

Commit 40c88b9

Browse files
SNOW-3525585: Add Iceberg snapshot-id time travel support (version=) (#4231)
Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 195e5e7 commit 40c88b9

6 files changed

Lines changed: 291 additions & 5 deletions

File tree

src/snowflake/snowpark/_internal/utils.py

Lines changed: 27 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1955,6 +1955,7 @@ class TimeTravelConfig(NamedTuple):
19551955
timestamp: Optional[str] = None
19561956
timestamp_type: Optional[str] = None
19571957
stream: Optional[str] = None
1958+
version: Optional[int] = None
19581959

19591960
@staticmethod
19601961
def validate_and_normalize_params(
@@ -1964,6 +1965,7 @@ def validate_and_normalize_params(
19641965
timestamp: Optional[Union[str, datetime.datetime]] = None,
19651966
timestamp_type: Optional[Union[str, "TimestampTimeZone"]] = None,
19661967
stream: Optional[str] = None,
1968+
version: Optional[int] = None,
19671969
) -> Optional["TimeTravelConfig"]:
19681970
"""
19691971
Validates and normalizes time travel parameters.
@@ -1986,7 +1988,7 @@ def validate_and_normalize_params(
19861988
ValueError: If parameters are invalid.
19871989
"""
19881990
time_travel_arg_count = sum(
1989-
arg is not None for arg in (statement, offset, timestamp, stream)
1991+
arg is not None for arg in (statement, offset, timestamp, stream, version)
19901992
)
19911993

19921994
# Validate mode
@@ -2003,10 +2005,28 @@ def validate_and_normalize_params(
20032005
f"Invalid time travel mode: {time_travel_mode}. Must be 'at' or 'before'."
20042006
)
20052007

2008+
# version (Iceberg snapshot id) only works with 'at' mode — matches
2009+
# Snowflake's ``AT(VERSION => <id>)`` grammar and Spark Iceberg's
2010+
# ``snapshot-id`` option semantics ("read snapshot N", not "before N").
2011+
if version is not None and time_travel_mode.lower() != "at":
2012+
raise ValueError(
2013+
"Iceberg snapshot version time travel can only be used with "
2014+
"time_travel_mode='at', not 'before'."
2015+
)
2016+
2017+
# Validate version type — snapshot IDs are 64-bit integers in Iceberg.
2018+
# Reject bool explicitly because ``isinstance(True, int)`` is True in Python.
2019+
if version is not None and (
2020+
not isinstance(version, int) or isinstance(version, bool)
2021+
):
2022+
raise ValueError(
2023+
f"'version' must be an int Iceberg snapshot id, got {type(version).__name__}."
2024+
)
2025+
20062026
# Validate exactly one parameter is provided
20072027
if time_travel_arg_count != 1:
20082028
raise ValueError(
2009-
"Exactly one of 'statement', 'offset', 'timestamp', or 'stream' must be provided."
2029+
"Exactly one of 'statement', 'offset', 'timestamp', 'stream', or 'version' must be provided."
20102030
)
20112031

20122032
# Normalize timestamp
@@ -2040,6 +2060,7 @@ def validate_and_normalize_params(
20402060
timestamp=normalized_timestamp,
20412061
timestamp_type=timestamp_type,
20422062
stream=stream,
2063+
version=version,
20432064
)
20442065

20452066
def generate_sql_clause(self) -> str:
@@ -2048,7 +2069,8 @@ def generate_sql_clause(self) -> str:
20482069
Args:
20492070
config: Time travel configuration.
20502071
Returns:
2051-
SQL clause like " AT (TIMESTAMP => TO_TIMESTAMP_NTZ('...'))"
2072+
SQL clause like " AT (TIMESTAMP => TO_TIMESTAMP_NTZ('...'))" or
2073+
" AT (VERSION => 1234567890)" for Iceberg snapshot id time travel.
20522074
"""
20532075
clause = f" {self.time_travel_mode.upper()} "
20542076

@@ -2058,6 +2080,8 @@ def generate_sql_clause(self) -> str:
20582080
clause += f"(OFFSET => {self.offset})"
20592081
elif self.stream is not None:
20602082
clause += f"(STREAM => '{self.stream}')"
2083+
elif self.version is not None:
2084+
clause += f"(VERSION => {self.version})"
20612085
elif self.timestamp is not None:
20622086
if self.timestamp_type is not None:
20632087
timestamp_type = self.timestamp_type.upper()

src/snowflake/snowpark/dataframe_reader.py

Lines changed: 56 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -162,6 +162,13 @@ def _extract_time_travel_from_options(options: dict) -> dict:
162162
- Automatically sets time_travel_mode to 'at'
163163
- Cannot be used with time_travel_mode='before' (raises error)
164164
- Cannot be mixed with regular 'timestamp' option (raises error)
165+
166+
Special handling for 'SNAPSHOT-ID' / 'SNAPSHOT_ID' (Spark Iceberg
167+
compatibility) — both aliases map to the internal ``version`` time
168+
travel parameter:
169+
- Automatically set time_travel_mode to 'at'
170+
(Iceberg snapshot ids only support ``AT(VERSION => N)``, not ``BEFORE``)
171+
- Cannot be used with time_travel_mode='before' (raises error)
165172
"""
166173
result = {}
167174
excluded_keys = set()
@@ -183,6 +190,35 @@ def _extract_time_travel_from_options(options: dict) -> dict:
183190
result["timestamp"] = options["AS-OF-TIMESTAMP"]
184191
excluded_keys.add("TIMESTAMP")
185192

193+
# Handle Iceberg snapshot id (Spark ``snapshot-id`` / ``snapshot_id``).
194+
# Auto-sets mode='at' since ``AT(VERSION => N)`` is the only valid form.
195+
snapshot_id_value = options.get("SNAPSHOT-ID")
196+
snapshot_id_source = "snapshot-id"
197+
if snapshot_id_value is None:
198+
snapshot_id_value = options.get("SNAPSHOT_ID")
199+
snapshot_id_source = "snapshot_id"
200+
if snapshot_id_value is not None:
201+
if (
202+
"TIME_TRAVEL_MODE" in options
203+
and options["TIME_TRAVEL_MODE"].lower() == "before"
204+
):
205+
raise ValueError(
206+
f"Cannot use '{snapshot_id_source}' option with "
207+
"time_travel_mode='before'. Iceberg snapshot id time travel "
208+
"only supports time_travel_mode='at'."
209+
)
210+
# Coerce string snapshot ids (Spark accepts both string and long
211+
# literals via .option(); we normalize to int so the SQL emits an
212+
# unquoted long).
213+
try:
214+
result["version"] = int(snapshot_id_value)
215+
except (TypeError, ValueError):
216+
raise ValueError(
217+
f"'{snapshot_id_source}' must be a 64-bit integer Iceberg "
218+
f"snapshot id, got {snapshot_id_value!r}."
219+
)
220+
result["time_travel_mode"] = "at"
221+
186222
for option_key, param_name in _TIME_TRAVEL_OPTIONS_PARAMS_MAP.items():
187223
if option_key in options and option_key not in excluded_keys:
188224
result[param_name] = options[option_key]
@@ -549,6 +585,7 @@ def table(
549585
timestamp: Optional[Union[str, datetime]] = None,
550586
timestamp_type: Optional[Union[str, TimestampTimeZone]] = None,
551587
stream: Optional[str] = None,
588+
**kwargs,
552589
) -> Table:
553590
"""Returns a Table that points to the specified table.
554591
@@ -605,6 +642,15 @@ def table(
605642
... .option("offset", -60) # This will be IGNORED
606643
... .table("my_table", time_travel_mode="at", offset=-3600)) # Only this is used
607644
"""
645+
# ``version`` (Iceberg snapshot id) is intentionally not in the public
646+
# signature — it's consumed by Snowpark Connect and may be removed
647+
# once a first-class API lands. Accept it through **kwargs so direct
648+
# callers can still pass it without us advertising it.
649+
version = kwargs.pop("version", None)
650+
if kwargs:
651+
raise TypeError(
652+
f"table() got unexpected keyword arguments: {sorted(kwargs)}"
653+
)
608654

609655
# AST.
610656
stmt = None
@@ -626,14 +672,22 @@ def table(
626672
if stream is not None:
627673
ast.stream.value = stream
628674

629-
if time_travel_mode is not None:
675+
if time_travel_mode is not None or version is not None:
676+
# If version is provided without mode, default to 'at' (snapshot ids
677+
# only make sense with AT — symmetric with iceberg_tag handling).
678+
effective_mode = (
679+
time_travel_mode
680+
if time_travel_mode
681+
else ("at" if version is not None else None)
682+
)
630683
time_travel_params = {
631-
"time_travel_mode": time_travel_mode,
684+
"time_travel_mode": effective_mode,
632685
"statement": statement,
633686
"offset": offset,
634687
"timestamp": timestamp,
635688
"timestamp_type": timestamp_type,
636689
"stream": stream,
690+
"version": version,
637691
}
638692
else:
639693
# if time_travel_mode is not provided, extract time travel config from options

src/snowflake/snowpark/session.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2728,6 +2728,7 @@ def table(
27282728
timestamp: Optional[Union[str, datetime.datetime]] = None,
27292729
timestamp_type: Optional[Union[str, TimestampTimeZone]] = None,
27302730
stream: Optional[str] = None,
2731+
**kwargs,
27312732
) -> Table:
27322733
"""
27332734
Returns a Table that points the specified table.
@@ -2775,6 +2776,16 @@ def table(
27752776
# timestamp_type remains "NTZ" (user's explicit choice respected)
27762777
>>> table2 = session.read.table("my_table", time_travel_mode="at", timestamp=tz_aware, timestamp_type="NTZ") # doctest: +SKIP
27772778
"""
2779+
# ``version`` (Iceberg snapshot id) is intentionally not in the public
2780+
# signature — it's consumed by Snowpark Connect and may be removed
2781+
# once a first-class API lands. Accept it through **kwargs so direct
2782+
# callers can still pass it without us advertising it.
2783+
version = kwargs.pop("version", None)
2784+
if kwargs:
2785+
raise TypeError(
2786+
f"table() got unexpected keyword arguments: {sorted(kwargs)}"
2787+
)
2788+
27782789
if _emit_ast:
27792790
stmt = self._ast_batch.bind()
27802791
ast = with_src_position(stmt.expr.table, stmt)
@@ -2811,6 +2822,7 @@ def table(
28112822
timestamp=timestamp,
28122823
timestamp_type=timestamp_type,
28132824
stream=stream,
2825+
version=version,
28142826
)
28152827
# Replace API call origin for table
28162828
set_api_call_source(t, "Session.table")

src/snowflake/snowpark/table.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -301,7 +301,18 @@ def __init__(
301301
timestamp: Optional[Union[str, datetime.datetime]] = None,
302302
timestamp_type: Optional[Union[str, TimestampTimeZone]] = None,
303303
stream: Optional[str] = None,
304+
**kwargs,
304305
) -> None:
306+
# ``version`` (Iceberg snapshot id) is intentionally not in the public
307+
# signature — it's consumed by Snowpark Connect and may be removed
308+
# once a first-class API lands. Accept it through **kwargs so direct
309+
# callers can still pass it without us advertising it.
310+
version = kwargs.pop("version", None)
311+
if kwargs:
312+
raise TypeError(
313+
f"Table() got unexpected keyword arguments: {sorted(kwargs)}"
314+
)
315+
305316
if _ast_stmt is None and session is not None and _emit_ast:
306317
_ast_stmt = session._ast_batch.bind()
307318
ast = with_src_position(_ast_stmt.expr.table, _ast_stmt)
@@ -328,6 +339,7 @@ def __init__(
328339
timestamp=timestamp,
329340
timestamp_type=timestamp_type,
330341
stream=stream,
342+
version=version,
331343
)
332344

333345
snowflake_table_plan = SnowflakeTable(

tests/integ/test_dataframe.py

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8326,3 +8326,77 @@ def test_time_travel_comprehensive_coverage(session):
83268326
finally:
83278327
Utils.drop_table(session, table1_name)
83288328
Utils.drop_table(session, table2_name)
8329+
8330+
8331+
# ----------------------------------------------------------------------
8332+
# Iceberg snapshot id (``version=``) time travel.
8333+
#
8334+
# TODO(SNOW-3525585): Wire these up to a CI test account that has:
8335+
# * a Catalog-Linked Database (CLD) such as cldUnity / cldglue, AND
8336+
# * an unmanaged Iceberg table inside it with at least two snapshots
8337+
# readable through ``INFORMATION_SCHEMA.GET_TABLE_VERSIONS(...)``.
8338+
#
8339+
# Snowflake's ``AT(VERSION => N)`` syntax requires the
8340+
# ``FEATURE_ICEBERG_TIME_TRAVEL`` server parameter to be enabled on the
8341+
# account and is currently scoped to **unmanaged** Iceberg tables in CLDs.
8342+
# Because the existing snowpark-python integ accounts don't have a CLD with
8343+
# a multi-snapshot Iceberg table provisioned, these tests are skipped by
8344+
# default and run manually against ``sfctest0`` (see
8345+
# ``tests/sas_tests/test_iceberg_snapshot_id_sample.py`` in the
8346+
# snowflake-eng/sas repo for the manual reproducer).
8347+
# ----------------------------------------------------------------------
8348+
@pytest.mark.skip(
8349+
reason=(
8350+
"Requires a CLD-linked unmanaged Iceberg table with multiple "
8351+
"snapshots and FEATURE_ICEBERG_TIME_TRAVEL enabled on the account. "
8352+
"Tested manually; see TODO above."
8353+
)
8354+
)
8355+
def test_iceberg_snapshot_id_time_travel_session_table(session):
8356+
"""End-to-end: ``Session.table(..., version=<snapshot_id>)`` returns the
8357+
table state at the requested Iceberg snapshot."""
8358+
table_fqn = "CLDUNITY.scosschema.snapshot_demo"
8359+
8360+
snapshot_ids = [
8361+
row["SNAPSHOT_ID"]
8362+
for row in session.sql(
8363+
f"SELECT SNAPSHOT_ID FROM "
8364+
f"TABLE(INFORMATION_SCHEMA.GET_TABLE_VERSIONS('{table_fqn}')) "
8365+
"ORDER BY SNAPSHOT_TIMESTAMP"
8366+
).collect()
8367+
]
8368+
assert len(snapshot_ids) >= 2, "Demo table needs at least 2 snapshots"
8369+
8370+
first_snapshot = session.table(
8371+
table_fqn, time_travel_mode="at", version=snapshot_ids[0]
8372+
).collect()
8373+
latest = session.table(table_fqn).collect()
8374+
assert len(first_snapshot) <= len(latest)
8375+
8376+
8377+
@pytest.mark.skip(
8378+
reason=(
8379+
"Requires a CLD-linked unmanaged Iceberg table with multiple "
8380+
"snapshots and FEATURE_ICEBERG_TIME_TRAVEL enabled on the account. "
8381+
"Tested manually; see TODO above."
8382+
)
8383+
)
8384+
def test_iceberg_snapshot_id_time_travel_dataframe_reader_option(session):
8385+
"""End-to-end: ``session.read.option('snapshot-id', N).table(...)``
8386+
routes through the Spark Iceberg-compat alias and produces the same
8387+
result as the explicit ``version=`` kwarg."""
8388+
table_fqn = "CLDUNITY.scosschema.snapshot_demo"
8389+
8390+
snapshot_id = session.sql(
8391+
f"SELECT SNAPSHOT_ID FROM "
8392+
f"TABLE(INFORMATION_SCHEMA.GET_TABLE_VERSIONS('{table_fqn}')) "
8393+
"ORDER BY SNAPSHOT_TIMESTAMP LIMIT 1"
8394+
).collect()[0]["SNAPSHOT_ID"]
8395+
8396+
via_kwarg = session.read.table(
8397+
table_fqn, time_travel_mode="at", version=snapshot_id
8398+
).collect()
8399+
via_option = (
8400+
session.read.option("snapshot-id", snapshot_id).table(table_fqn).collect()
8401+
)
8402+
assert via_kwarg == via_option

0 commit comments

Comments
 (0)