This is an automated email from the ASF dual-hosted git repository.
JingsongLi pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/paimon.git
The following commit(s) were added to refs/heads/master by this push:
new a60d5fdf14 [python][daft] Support timestamp time travel in read_paimon
(#8527)
a60d5fdf14 is described below
commit a60d5fdf14a9b93367b52c8514069a669b183941
Author: Kerwin Zhang <[email protected]>
AuthorDate: Fri Jul 10 08:35:34 2026 +0800
[python][daft] Support timestamp time travel in read_paimon (#8527)
---
docs/docs/pypaimon/daft.md | 19 ++++-
paimon-python/pypaimon/daft/daft_paimon.py | 87 ++++++++++++++++++----
.../pypaimon/tests/daft/daft_integration_test.py | 59 +++++++++++++--
3 files changed, 141 insertions(+), 24 deletions(-)
diff --git a/docs/docs/pypaimon/daft.md b/docs/docs/pypaimon/daft.md
index 0c2c118f0f..d219912b87 100644
--- a/docs/docs/pypaimon/daft.md
+++ b/docs/docs/pypaimon/daft.md
@@ -100,18 +100,31 @@ df = read_paimon(
catalog_options={"warehouse": "/path/to/warehouse"},
tag_name="release-2026-04",
)
+
+# Read the latest snapshot at or before a timestamp.
+# Accepts an int (epoch millis), a datetime, or a "YYYY-MM-DD HH:MM:SS" string.
+df = read_paimon(
+ "database_name.table_name",
+ catalog_options={"warehouse": "/path/to/warehouse"},
+ timestamp="2026-07-09 10:00:00",
+)
```
-`snapshot_id` and `tag_name` are mutually exclusive.
+`snapshot_id`, `tag_name`, and `timestamp` are mutually exclusive.
**Parameters:**
- `table_identifier`: full table name, e.g. `"db_name.table_name"`.
- `catalog_options`: kwargs forwarded to `CatalogFactory.create()`,
e.g. `{"warehouse": "/path/to/warehouse"}`.
- `snapshot_id`: optional snapshot id to time-travel to. Mutually
- exclusive with `tag_name`.
+ exclusive with `tag_name` and `timestamp`.
- `tag_name`: optional tag name to time-travel to. Mutually
- exclusive with `snapshot_id`.
+ exclusive with `snapshot_id` and `timestamp`.
+- `timestamp`: optional timestamp to time-travel to the latest snapshot at or
+ before it. Accepts an `int` (epoch milliseconds) or `datetime` (both mapped
to
+ `scan.timestamp-millis`), or a `str` such as `"2026-07-09 10:00:00"` (mapped
to
+ `scan.timestamp`). A naive `datetime` is interpreted in the local timezone.
+ Mutually exclusive with `snapshot_id` and `tag_name`.
- `io_config`: optional Daft `IOConfig` for accessing object storage.
If `None`, will be inferred from the catalog options.
diff --git a/paimon-python/pypaimon/daft/daft_paimon.py
b/paimon-python/pypaimon/daft/daft_paimon.py
index c419d4ab8c..996a5228ff 100644
--- a/paimon-python/pypaimon/daft/daft_paimon.py
+++ b/paimon-python/pypaimon/daft/daft_paimon.py
@@ -29,7 +29,8 @@ Usage::
from __future__ import annotations
-from typing import TYPE_CHECKING, Any, Dict, Optional
+import datetime
+from typing import TYPE_CHECKING, Any, Dict, Optional, Union
from urllib.parse import urlparse
if TYPE_CHECKING:
@@ -59,21 +60,68 @@ def _enrich_options_with_rest_token(
return enriched
+# Time-travel targets are mutually exclusive: at most one may be set.
+TimeTravelTimestamp = Union[int, str, "datetime.datetime"]
+
+
+def _validate_single_time_travel(
+ snapshot_id: int | None,
+ tag_name: str | None,
+ timestamp: TimeTravelTimestamp | None,
+) -> None:
+ specified = [
+ name
+ for name, value in (
+ ("snapshot_id", snapshot_id),
+ ("tag_name", tag_name),
+ ("timestamp", timestamp),
+ )
+ if value is not None
+ ]
+ if len(specified) > 1:
+ raise ValueError(
+ "Only one of snapshot_id, tag_name, timestamp can be set, "
+ f"got: {specified}"
+ )
+
+
+def _timestamp_scan_option(timestamp: TimeTravelTimestamp) -> dict[str, str]:
+ """Map a timestamp to the matching Paimon scan option.
+
+ ``datetime`` / ``int`` (epoch millis) -> ``scan.timestamp-millis``;
+ ``str`` (e.g. ``'2026-07-09 10:00:00'``) -> ``scan.timestamp``.
+ A naive ``datetime`` is interpreted in the local timezone.
+ """
+ # bool is a subclass of int; reject it so True/False can't become a
timestamp.
+ if isinstance(timestamp, bool):
+ raise TypeError("timestamp must be int millis, datetime, or str, not
bool")
+ if isinstance(timestamp, datetime.datetime):
+ return {"scan.timestamp-millis": str(int(timestamp.timestamp() *
1000))}
+ if isinstance(timestamp, int):
+ return {"scan.timestamp-millis": str(timestamp)}
+ if isinstance(timestamp, str):
+ return {"scan.timestamp": timestamp}
+ raise TypeError(
+ "timestamp must be int (epoch millis), datetime, or str, "
+ f"got: {type(timestamp).__name__}"
+ )
+
+
def _time_travel_table(
table: FileStoreTable,
snapshot_id: int | None = None,
tag_name: str | None = None,
+ timestamp: TimeTravelTimestamp | None = None,
) -> FileStoreTable:
- if snapshot_id is not None and tag_name is not None:
- raise ValueError(
- "snapshot_id and tag_name cannot be set at the same time"
- )
+ _validate_single_time_travel(snapshot_id, tag_name, timestamp)
travel_options: dict[str, str] = {}
if snapshot_id is not None:
travel_options["scan.snapshot-id"] = str(snapshot_id)
if tag_name is not None:
travel_options["scan.tag-name"] = tag_name
+ if timestamp is not None:
+ travel_options.update(_timestamp_scan_option(timestamp))
if travel_options:
return table.copy(travel_options)
return table
@@ -121,9 +169,12 @@ def _read_table(
io_config=None,
snapshot_id: int | None = None,
tag_name: str | None = None,
+ timestamp: TimeTravelTimestamp | None = None,
) -> "daft.DataFrame":
"""Read a Paimon table object into a lazy Daft DataFrame."""
- table = _time_travel_table(table, snapshot_id=snapshot_id,
tag_name=tag_name)
+ table = _time_travel_table(
+ table, snapshot_id=snapshot_id, tag_name=tag_name, timestamp=timestamp
+ )
return _source_for_table(table, catalog_options=catalog_options,
io_config=io_config).read()
@@ -151,6 +202,7 @@ def _explain_table(
io_config=None,
snapshot_id: int | None = None,
tag_name: str | None = None,
+ timestamp: TimeTravelTimestamp | None = None,
filters: Any = None,
partition_filters: Any = None,
columns: list[str] | None = None,
@@ -160,7 +212,9 @@ def _explain_table(
"""Explain a Paimon table object using Daft's datasource pushdown model."""
from daft.io.pushdowns import Pushdowns
- table = _time_travel_table(table, snapshot_id=snapshot_id,
tag_name=tag_name)
+ table = _time_travel_table(
+ table, snapshot_id=snapshot_id, tag_name=tag_name, timestamp=timestamp
+ )
source = _source_for_table(table, catalog_options=catalog_options,
io_config=io_config)
filter_expr, filter_pyexprs = _normalize_explain_filters(filters)
partition_filter_expr, _ = _normalize_explain_filters(partition_filters)
@@ -194,6 +248,7 @@ def read_paimon(
*,
snapshot_id: Optional[int] = None,
tag_name: Optional[str] = None,
+ timestamp: Optional[TimeTravelTimestamp] = None,
io_config=None,
) -> "daft.DataFrame":
"""Read a Paimon table into a lazy Daft DataFrame.
@@ -208,19 +263,22 @@ def read_paimon(
catalog_options: Options passed to ``CatalogFactory.create()``,
e.g. ``{"warehouse": "/path/to/warehouse"}``.
snapshot_id: Optional snapshot id to time-travel to. Mutually
- exclusive with ``tag_name``.
+ exclusive with ``tag_name`` and ``timestamp``.
tag_name: Optional tag name to time-travel to. Mutually
- exclusive with ``snapshot_id``.
+ exclusive with ``snapshot_id`` and ``timestamp``.
+ timestamp: Optional timestamp to time-travel to the latest snapshot
+ at or before it. Accepts an ``int`` (epoch milliseconds) or
+ ``datetime`` (mapped to ``scan.timestamp-millis``), or a ``str``
+ such as ``'2026-07-09 10:00:00'`` (mapped to ``scan.timestamp``).
+ A naive ``datetime`` is interpreted in the local timezone.
+ Mutually exclusive with ``snapshot_id`` and ``tag_name``.
io_config: Optional Daft IOConfig for accessing object storage.
If None, will be inferred from the catalog options.
Returns:
A lazy ``daft.DataFrame`` backed by this Paimon table.
"""
- if snapshot_id is not None and tag_name is not None:
- raise ValueError(
- "snapshot_id and tag_name cannot be set at the same time"
- )
+ _validate_single_time_travel(snapshot_id, tag_name, timestamp)
from pypaimon.catalog.catalog_factory import CatalogFactory
@@ -230,6 +288,7 @@ def read_paimon(
return _read_table(
table, catalog_options=catalog_options,
io_config=io_config, snapshot_id=snapshot_id, tag_name=tag_name,
+ timestamp=timestamp,
)
@@ -243,6 +302,7 @@ def explain_paimon_scan(
limit: int | None = None,
snapshot_id: Optional[int] = None,
tag_name: Optional[str] = None,
+ timestamp: Optional[TimeTravelTimestamp] = None,
io_config=None,
verbose: bool = False,
) -> "PaimonScanExplain":
@@ -263,6 +323,7 @@ def explain_paimon_scan(
io_config=io_config,
snapshot_id=snapshot_id,
tag_name=tag_name,
+ timestamp=timestamp,
filters=filters,
partition_filters=partition_filters,
columns=columns,
diff --git a/paimon-python/pypaimon/tests/daft/daft_integration_test.py
b/paimon-python/pypaimon/tests/daft/daft_integration_test.py
index 3c5709739d..4b1ff9fbe4 100644
--- a/paimon-python/pypaimon/tests/daft/daft_integration_test.py
+++ b/paimon-python/pypaimon/tests/daft/daft_integration_test.py
@@ -20,6 +20,9 @@
from __future__ import annotations
+import datetime
+import time
+
import pyarrow as pa
import pytest
@@ -29,6 +32,7 @@ daft = pytest.importorskip("daft")
from daft import col
from pypaimon.daft import read_paimon, write_paimon
+from pypaimon.daft.daft_paimon import _timestamp_scan_option
@pytest.fixture
@@ -189,14 +193,53 @@ def test_read_paimon_with_tag_name(catalog_options):
assert result == {"id": [1], "name": ["tagged"]}
-def
test_read_paimon_rejects_snapshot_id_and_tag_name_together(catalog_options):
- with pytest.raises(ValueError, match="snapshot_id and tag_name cannot be
set at the same time"):
- read_paimon(
- "test_db.dummy",
- catalog_options,
- snapshot_id=1,
- tag_name="v1",
- )
+def test_read_paimon_with_timestamp(catalog_options):
+ pa_schema = pa.schema([("id", pa.int64()), ("name", pa.string())])
+ identifier, table = _create_table(catalog_options, "read_timestamp",
pa_schema)
+ _write_arrow(table, pa.table({"id": [1], "name": ["first"]},
schema=pa_schema))
+ # A cutoff strictly between the two commits: scan.timestamp-millis returns
the
+ # latest snapshot at or before it, i.e. the first snapshot only.
+ time.sleep(0.05)
+ cutoff_ms = int(time.time() * 1000)
+ time.sleep(0.05)
+ _write_arrow(table, pa.table({"id": [2], "name": ["second"]},
schema=pa_schema))
+
+ latest = read_paimon(identifier, catalog_options).sort("id").to_pydict()
+ at_millis = read_paimon(identifier, catalog_options,
timestamp=cutoff_ms).to_pydict()
+ at_datetime = read_paimon(
+ identifier,
+ catalog_options,
+ timestamp=datetime.datetime.fromtimestamp(cutoff_ms / 1000),
+ ).to_pydict()
+
+ assert latest["id"] == [1, 2]
+ assert at_millis == {"id": [1], "name": ["first"]}
+ assert at_datetime == {"id": [1], "name": ["first"]}
+
+
+def test_timestamp_scan_option_mapping():
+ assert _timestamp_scan_option(1751990400000) == {"scan.timestamp-millis":
"1751990400000"}
+ assert _timestamp_scan_option("2026-07-09 10:00:00") == {"scan.timestamp":
"2026-07-09 10:00:00"}
+ dt = datetime.datetime(2026, 7, 9, 10, 0, 0)
+ assert _timestamp_scan_option(dt) == {"scan.timestamp-millis":
str(int(dt.timestamp() * 1000))}
+ with pytest.raises(TypeError):
+ _timestamp_scan_option(True)
+ with pytest.raises(TypeError):
+ _timestamp_scan_option(1.5)
+
+
[email protected](
+ "kwargs",
+ [
+ {"snapshot_id": 1, "tag_name": "v1"},
+ {"snapshot_id": 1, "timestamp": 1},
+ {"tag_name": "v1", "timestamp": 1},
+ {"snapshot_id": 1, "tag_name": "v1", "timestamp": 1},
+ ],
+)
+def test_read_paimon_rejects_multiple_time_travel(catalog_options, kwargs):
+ with pytest.raises(ValueError, match="Only one of snapshot_id, tag_name,
timestamp"):
+ read_paimon("test_db.dummy", catalog_options, **kwargs)
def test_write_paimon_append(catalog_options):