This is an automated email from the ASF dual-hosted git repository.

JingsongLi pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/paimon-rust.git


The following commit(s) were added to refs/heads/main by this push:
     new f73ebe4e feat: Support Java-compatible scan.timestamp time travel 
(#919)
f73ebe4e is described below

commit f73ebe4e36a99ff8bc4924b2413ee30b8eb23263
Author: Jingsong Lee <[email protected]>
AuthorDate: Wed Sep 23 09:08:15 2026 +0800

    feat: Support Java-compatible scan.timestamp time travel (#919)
---
 Cargo.lock                                         |   1 +
 bindings/c/src/table.rs                            |  35 ++--
 bindings/python/README.md                          |   3 +-
 bindings/python/src/read.rs                        |   3 +-
 bindings/python/tests/test_read.py                 |  24 +++
 crates/integrations/datafusion/src/sql_context.rs  |   3 +-
 .../datafusion/tests/time_travel_schema_tests.rs   | 103 ++++++++++
 crates/paimon/Cargo.toml                           |   1 +
 crates/paimon/src/spec/core_options.rs             | 223 ++++++++++++++++++++-
 crates/paimon/src/table/mod.rs                     |   7 +-
 crates/paimon/src/table/table_scan.rs              |   3 +-
 crates/paimon/src/table/time_travel.rs             |  95 +++++++++
 crates/paimon/src/table/write_builder.rs           |   2 +-
 docs/src/python-binding.md                         |   9 +
 docs/src/sql.md                                    |  13 ++
 15 files changed, 500 insertions(+), 25 deletions(-)

diff --git a/Cargo.lock b/Cargo.lock
index 3bcd32ed..c46567fb 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -4670,6 +4670,7 @@ dependencies = [
  "hex",
  "hmac 0.12.1",
  "indexmap 2.14.0",
+ "jiff",
  "libloading 0.9.0",
  "log",
  "lru 0.18.2",
diff --git a/bindings/c/src/table.rs b/bindings/c/src/table.rs
index 058c95d8..598cc63d 100644
--- a/bindings/c/src/table.rs
+++ b/bindings/c/src/table.rs
@@ -373,8 +373,9 @@ pub unsafe extern "C" fn paimon_table_latest_snapshot(
 }
 
 /// Time-travel selector option names, in the core's resolution priority order.
-const TIME_TRAVEL_SELECTORS: [&str; 5] = [
+const TIME_TRAVEL_SELECTORS: [&str; 6] = [
     "scan.timestamp-millis",
+    "scan.timestamp",
     "scan.watermark",
     "scan.version",
     "scan.snapshot-id",
@@ -446,7 +447,7 @@ pub unsafe extern "C" fn paimon_table_new_read_builder(
 }
 
 /// Create a ReadBuilder from a Table with scan options (e.g. time-travel
-/// selectors `scan.snapshot-id` / `scan.tag-name` / `scan.timestamp-millis` /
+/// selectors `scan.snapshot-id` / `scan.tag-name` / `scan.timestamp-millis` / 
`scan.timestamp` /
 /// `scan.watermark` / `scan.version`). At most one time-travel selector may be
 /// set. A selector that does not resolve to a snapshot is an error (never a
 /// silent read-of-latest).
@@ -2520,20 +2521,22 @@ mod tests {
     fn malformed_selector_value_does_not_silently_read_latest() {
         unsafe {
             let table = boxed_test_table();
-            let k = CString::new("scan.snapshot-id").unwrap();
-            let v = CString::new("abc").unwrap();
-            let opts = [opt(&k, &v)];
-            // Core swallows the parse error and falls back; the binding 
reports
-            // the unified "did not resolve" error rather than building a
-            // latest-reading builder.
-            let (code, message) = assert_rb_err_code_message(
-                paimon_table_new_read_builder_with_options(table, 
opts.as_ptr(), 1),
-            );
-            assert_eq!(code, PaimonErrorCode::InvalidInput as i32);
-            assert!(
-                message.contains("did not resolve"),
-                "message should report the selector did not resolve, got: 
{message}"
-            );
+            for selector in ["scan.snapshot-id", "scan.timestamp"] {
+                let k = CString::new(selector).unwrap();
+                let v = CString::new("abc").unwrap();
+                let opts = [opt(&k, &v)];
+                // Core swallows the parse error and falls back; the binding 
reports
+                // the unified "did not resolve" error rather than building a
+                // latest-reading builder.
+                let (code, message) = assert_rb_err_code_message(
+                    paimon_table_new_read_builder_with_options(table, 
opts.as_ptr(), 1),
+                );
+                assert_eq!(code, PaimonErrorCode::InvalidInput as i32);
+                assert!(
+                    message.contains("did not resolve"),
+                    "message should report the selector did not resolve, got: 
{message}"
+                );
+            }
             paimon_table_free(table);
         }
     }
diff --git a/bindings/python/README.md b/bindings/python/README.md
index c55c4010..b6505d1d 100644
--- a/bindings/python/README.md
+++ b/bindings/python/README.md
@@ -97,7 +97,8 @@ commit_messages = writer.prepare_commit()
 write_builder.new_commit().commit(commit_messages)
 
 # --- Time travel: read a past version ---
-# Supported options: scan.version, scan.timestamp-millis, scan.snapshot-id, or 
scan.tag-name
+# Supported selectors: scan.version, scan.timestamp, scan.timestamp-millis,
+# scan.snapshot-id, scan.tag-name, or scan.watermark
 read_builder_tt = table.new_read_builder({"scan.snapshot-id": "1"})
 scan_tt = read_builder_tt.new_scan()
 plan_tt = scan_tt.plan()
diff --git a/bindings/python/src/read.rs b/bindings/python/src/read.rs
index f3c00ca4..c9c93fcb 100644
--- a/bindings/python/src/read.rs
+++ b/bindings/python/src/read.rs
@@ -39,8 +39,9 @@ const MAP_SELECTED_KEYS_PREFIX: &str = 
"__PAIMON_MAP_SELECTED_KEYS:";
 const MAP_SELECTED_KEYS_DELIMITER: char = ';';
 
 /// Time-travel selector option names, in the core's resolution priority order.
-const TIME_TRAVEL_SELECTORS: [&str; 5] = [
+const TIME_TRAVEL_SELECTORS: [&str; 6] = [
     "scan.timestamp-millis",
+    "scan.timestamp",
     "scan.watermark",
     "scan.version",
     "scan.snapshot-id",
diff --git a/bindings/python/tests/test_read.py 
b/bindings/python/tests/test_read.py
index 0892c245..17721de7 100644
--- a/bindings/python/tests/test_read.py
+++ b/bindings/python/tests/test_read.py
@@ -967,6 +967,30 @@ def test_time_travel_by_snapshot_id():
         assert _rows(batches) == 1  # only snapshot 1's row
 
 
+def test_time_travel_by_timestamp_string():
+    from pathlib import Path
+
+    with tempfile.TemporaryDirectory() as warehouse:
+        _make_two_snapshot_table(warehouse)
+        snapshot_path = Path(warehouse) / "tdb.db/t/snapshot/snapshot-1"
+        snapshot = json.loads(snapshot_path.read_text())
+        snapshot["timeMillis"] = 86_400_000  # 1970-01-02 UTC
+        snapshot_path.write_text(json.dumps(snapshot))
+        table = PaimonCatalog({"warehouse": warehouse}).get_table("tdb.t")
+        for mode in [{}, {"scan.mode": "from-timestamp"}]:
+            builder = table.new_read_builder(
+                {"scan.timestamp": "1970-01-03 00:00:00.123456789", **mode})
+            splits = builder.new_scan().plan().splits()
+            result = pa.Table.from_batches(builder.new_read().read(splits))
+            assert result.column("id").to_pylist() == [1]
+        for value in ["invalid", "1970-01-01 00:00:00"]:
+            with pytest.raises(ValueError, match="did not resolve"):
+                table.new_read_builder({"scan.timestamp": value})
+        with pytest.raises(ValueError, match="Only one"):
+            table.new_read_builder({"scan.timestamp": "1970-01-03",
+                                    "scan.timestamp-millis": "172800000"})
+
+
 def test_time_travel_by_tag_name():
     with tempfile.TemporaryDirectory() as warehouse:
         ctx = _make_two_snapshot_table(warehouse)
diff --git a/crates/integrations/datafusion/src/sql_context.rs 
b/crates/integrations/datafusion/src/sql_context.rs
index aba77b15..34c33035 100644
--- a/crates/integrations/datafusion/src/sql_context.rs
+++ b/crates/integrations/datafusion/src/sql_context.rs
@@ -1461,13 +1461,14 @@ impl SQLContext {
     fn ensure_no_time_travel_for_write(&self, operation: &str) -> DFResult<()> 
{
         use paimon::spec::{
             SCAN_SNAPSHOT_ID_OPTION, SCAN_TAG_NAME_OPTION, 
SCAN_TIMESTAMP_MILLIS_OPTION,
-            SCAN_VERSION_OPTION,
+            SCAN_TIMESTAMP_OPTION, SCAN_VERSION_OPTION,
         };
 
         let options = self.dynamic_options.read().unwrap();
         for key in [
             SCAN_VERSION_OPTION,
             SCAN_TIMESTAMP_MILLIS_OPTION,
+            SCAN_TIMESTAMP_OPTION,
             SCAN_SNAPSHOT_ID_OPTION,
             SCAN_TAG_NAME_OPTION,
         ] {
diff --git a/crates/integrations/datafusion/tests/time_travel_schema_tests.rs 
b/crates/integrations/datafusion/tests/time_travel_schema_tests.rs
index 747cd579..e8598fce 100644
--- a/crates/integrations/datafusion/tests/time_travel_schema_tests.rs
+++ b/crates/integrations/datafusion/tests/time_travel_schema_tests.rs
@@ -397,4 +397,107 @@ async fn test_timestamp_as_of_uses_snapshot_schema() {
         .unwrap();
     assert_eq!(column_names(&batches), vec!["id", "name"]);
     assert_eq!(total_rows(&batches), 3);
+
+    sql_context
+        .sql("SET 'paimon.scan.timestamp' = '1970-01-03 00:00:00.123'")
+        .await
+        .unwrap();
+    let batches = sql_context
+        .sql("SELECT * FROM paimon.default.t")
+        .await
+        .unwrap()
+        .collect()
+        .await
+        .unwrap();
+    assert_eq!(column_names(&batches), vec!["id", "name"]);
+    assert_eq!(total_rows(&batches), 3);
+    let error = match sql_context
+        .sql("DELETE FROM paimon.default.t WHERE id = 1")
+        .await
+    {
+        Err(error) => error,
+        Ok(_) => panic!("writes with scan.timestamp must fail"),
+    };
+    assert!(error.to_string().contains("scan.timestamp"), "{error}");
+    sql_context
+        .sql("RESET 'paimon.scan.timestamp'")
+        .await
+        .unwrap();
+    let batches = sql_context
+        .sql("SELECT * FROM paimon.default.t")
+        .await
+        .unwrap()
+        .collect()
+        .await
+        .unwrap();
+    assert_eq!(column_names(&batches), vec!["id", "name", "age"]);
+    assert_eq!(total_rows(&batches), 5);
+}
+
+#[tokio::test]
+async fn test_session_scan_timestamp_overlap_reads_earlier_snapshot() {
+    const CHILD: &str = "PAIMON_SCAN_TIMESTAMP_SQL_OVERLAP_CHILD";
+    if std::env::var_os(CHILD).is_none() {
+        // The system local timezone is process-global. Isolate this case so
+        // parallel tests cannot change how the ambiguous time is resolved.
+        let output = 
std::process::Command::new(std::env::current_exe().unwrap())
+            .args([
+                "--exact",
+                "test_session_scan_timestamp_overlap_reads_earlier_snapshot",
+            ])
+            .env("TZ", "America/New_York")
+            .env(CHILD, "1")
+            .output()
+            .unwrap();
+        assert!(
+            output.status.success(),
+            "child failed:\n{}{}",
+            String::from_utf8_lossy(&output.stdout),
+            String::from_utf8_lossy(&output.stderr)
+        );
+        return;
+    }
+
+    let (tmp, sql_context) = setup_evolved_table().await;
+    for (snapshot_id, time_millis) in [
+        (1, 1_730_610_000_000_u64), // 2024-11-03T05:00:00Z
+        (2, 1_730_613_600_000_u64), // 2024-11-03T06:00:00Z
+    ] {
+        let path = tmp
+            .path()
+            .join("default.db/t/snapshot")
+            .join(format!("snapshot-{snapshot_id}"));
+        let mut snapshot: serde_json::Value =
+            
serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
+        snapshot["timeMillis"] = serde_json::json!(time_millis);
+        std::fs::write(path, serde_json::to_vec(&snapshot).unwrap()).unwrap();
+    }
+
+    sql_context
+        .sql("SET 'paimon.scan.timestamp' = '2024-11-03 01:30:00'")
+        .await
+        .unwrap();
+    let batches = sql_context
+        .sql("SELECT * FROM paimon.default.t")
+        .await
+        .unwrap()
+        .collect()
+        .await
+        .unwrap();
+    assert_eq!(column_names(&batches), vec!["id", "name"]);
+    assert_eq!(total_rows(&batches), 3);
+
+    sql_context
+        .sql("SET 'paimon.scan.timestamp' = '2024-11-03 02:00:00'")
+        .await
+        .unwrap();
+    let batches = sql_context
+        .sql("SELECT * FROM paimon.default.t")
+        .await
+        .unwrap()
+        .collect()
+        .await
+        .unwrap();
+    assert_eq!(column_names(&batches), vec!["id", "name", "age"]);
+    assert_eq!(total_rows(&batches), 5);
 }
diff --git a/crates/paimon/Cargo.toml b/crates/paimon/Cargo.toml
index c970799d..e58c46b1 100644
--- a/crates/paimon/Cargo.toml
+++ b/crates/paimon/Cargo.toml
@@ -77,6 +77,7 @@ tokio = { version = "1.39.2", features = [
     "time",
 ] }
 chrono = { version = "0.4.38", features = ["serde"] }
+jiff = "0.2.34"
 serde = { version = "1", features = ["derive", "rc"] }
 serde_bytes = "0.11.15"
 serde_json = "1.0.120"
diff --git a/crates/paimon/src/spec/core_options.rs 
b/crates/paimon/src/spec/core_options.rs
index 41a9664c..477b7891 100644
--- a/crates/paimon/src/spec/core_options.rs
+++ b/crates/paimon/src/spec/core_options.rs
@@ -125,6 +125,8 @@ const DEFAULT_COMMIT_TIMEOUT_MS: u64 = u64::MAX;
 const DEFAULT_COMMIT_MIN_RETRY_WAIT_MS: u64 = 10;
 const DEFAULT_COMMIT_MAX_RETRY_WAIT_MS: u64 = 10_000;
 pub const SCAN_TIMESTAMP_MILLIS_OPTION: &str = "scan.timestamp-millis";
+/// Local date/time string used for snapshot time travel, matching Java Paimon.
+pub const SCAN_TIMESTAMP_OPTION: &str = "scan.timestamp";
 pub const SCAN_VERSION_OPTION: &str = "scan.version";
 pub const SCAN_SNAPSHOT_ID_OPTION: &str = "scan.snapshot-id";
 pub const SCAN_TAG_NAME_OPTION: &str = "scan.tag-name";
@@ -502,7 +504,7 @@ impl<'a> CoreOptions<'a> {
                     SCAN_WATERMARK_OPTION,
                 ]
             } else if mode.eq_ignore_ascii_case("from-timestamp") {
-                &[SCAN_TIMESTAMP_MILLIS_OPTION]
+                &[SCAN_TIMESTAMP_MILLIS_OPTION, SCAN_TIMESTAMP_OPTION]
             } else {
                 return Err(crate::Error::Unsupported {
                     message: format!(
@@ -1060,10 +1062,13 @@ impl<'a> CoreOptions<'a> {
     }
 
     fn configured_time_travel_selectors(&self) -> Vec<&'static str> {
-        let mut selectors = Vec::with_capacity(5);
+        let mut selectors = Vec::with_capacity(6);
         if self.options.contains_key(SCAN_TIMESTAMP_MILLIS_OPTION) {
             selectors.push(SCAN_TIMESTAMP_MILLIS_OPTION);
         }
+        if self.options.contains_key(SCAN_TIMESTAMP_OPTION) {
+            selectors.push(SCAN_TIMESTAMP_OPTION);
+        }
         if self.options.contains_key(SCAN_WATERMARK_OPTION) {
             selectors.push(SCAN_WATERMARK_OPTION);
         }
@@ -1124,6 +1129,10 @@ impl<'a> CoreOptions<'a> {
 
         if let Some(ts) = self.parse_i64_option(SCAN_TIMESTAMP_MILLIS_OPTION)? 
{
             Ok(Some(TimeTravelSelector::TimestampMillis(ts)))
+        } else if let Some(value) = self.options.get(SCAN_TIMESTAMP_OPTION) {
+            Ok(Some(TimeTravelSelector::TimestampMillis(
+                parse_scan_timestamp(value, &jiff::tz::TimeZone::system())?,
+            )))
         } else if let Some(watermark) = 
self.parse_i64_option(SCAN_WATERMARK_OPTION)? {
             Ok(Some(TimeTravelSelector::Watermark(watermark)))
         } else if let Some(value) = 
self.options.get(SCAN_VERSION_OPTION).map(String::as_str) {
@@ -1614,6 +1623,66 @@ impl<'a> CoreOptions<'a> {
     }
 }
 
+/// Java DateTimeUtils accepts a date, a space-separated timestamp, or an ISO
+/// local timestamp (whose seconds are optional). It truncates to milliseconds
+/// and resolves the date/time in the process's default time zone.
+fn parse_scan_timestamp(value: &str, zone: &jiff::tz::TimeZone) -> 
crate::Result<i64> {
+    use chrono::{Datelike, NaiveDate, NaiveDateTime, Timelike};
+
+    let datetime = NaiveDateTime::parse_from_str(value, "%Y-%m-%d %H:%M:%S%.f")
+        .or_else(|_| NaiveDateTime::parse_from_str(value, 
"%Y-%m-%dT%H:%M:%S%.f"))
+        .or_else(|_| NaiveDateTime::parse_from_str(value, "%Y-%m-%dT%H:%M"))
+        .or_else(|_| {
+            NaiveDate::parse_from_str(value, "%Y-%m-%d")
+                .map(|date| date.and_hms_opt(0, 0, 0).unwrap())
+        })
+        .map_err(|error| crate::Error::DataInvalid {
+            message: format!("Invalid value for {SCAN_TIMESTAMP_OPTION}: 
'{value}'"),
+            source: Some(Box::new(error)),
+        })?;
+    // Chrono also accepts leap seconds and fractions longer than nanoseconds;
+    // Java's local timestamp parser does not.
+    if datetime.nanosecond() >= 1_000_000_000
+        || value
+            .rsplit_once('.')
+            .is_some_and(|(_, fraction)| fraction.len() > 9)
+    {
+        return Err(crate::Error::DataInvalid {
+            message: format!("Invalid value for {SCAN_TIMESTAMP_OPTION}: 
'{value}'"),
+            source: None,
+        });
+    }
+    // Java parses with precision 3 before applying the system time zone.
+    // Truncating here also preserves the correct millisecond for instants
+    // immediately before the Unix epoch.
+    let year = i16::try_from(datetime.year()).map_err(|error| 
crate::Error::DataInvalid {
+        message: format!("Invalid value for {SCAN_TIMESTAMP_OPTION}: 
'{value}'"),
+        source: Some(Box::new(error)),
+    })?;
+    let civil = jiff::civil::DateTime::new(
+        year,
+        datetime.month() as i8,
+        datetime.day() as i8,
+        datetime.hour() as i8,
+        datetime.minute() as i8,
+        datetime.second() as i8,
+        (datetime.nanosecond() / 1_000_000 * 1_000_000) as i32,
+    )
+    .map_err(|error| crate::Error::DataInvalid {
+        message: format!("Invalid value for {SCAN_TIMESTAMP_OPTION}: 
'{value}'"),
+        source: Some(Box::new(error)),
+    })?;
+    // Jiff's compatible disambiguation matches Java LocalDateTime.atZone:
+    // the earlier instant in a fold, and the later local time in a gap.
+    zone.to_ambiguous_timestamp(civil)
+        .compatible()
+        .map(|timestamp| timestamp.as_millisecond())
+        .map_err(|error| crate::Error::DataInvalid {
+            message: format!("Invalid local time for {SCAN_TIMESTAMP_OPTION}: 
'{value}'"),
+            source: Some(Box::new(error)),
+        })
+}
+
 /// Parse a memory size string to bytes using binary (1024-based) semantics,
 /// mirroring Java Paimon's `MemorySize.parseBytes`.
 ///
@@ -2494,6 +2563,156 @@ mod tests {
         assert_eq!(parallelism(Some("many")), 64);
     }
 
+    #[test]
+    fn test_scan_timestamp_parses_local_time_and_truncates_to_millis() {
+        let zone = jiff::tz::db().get("Asia/Shanghai").unwrap();
+        let midnight = 1_704_124_800_000_i64; // 2024-01-01T16:00:00Z
+        let noon = 1_704_168_184_000_i64; // 2024-01-02T04:03:04Z
+        for (value, expected) in [
+            ("2024-01-02", midnight),
+            ("2024-1-2 12:3:4", noon),
+            ("2024-01-02 12:03:04", noon),
+            ("2024-01-02 12:03:04.123456789", noon + 123),
+            ("2024-01-02T12:03:04.9", noon + 900),
+            ("2024-01-02T12:03", noon - 4000),
+        ] {
+            assert_eq!(
+                parse_scan_timestamp(value, &zone).unwrap(),
+                expected,
+                "{value}"
+            );
+        }
+    }
+
+    #[test]
+    fn test_scan_timestamp_matches_java_zone_transitions() {
+        for (zone, value, expected) in [
+            (
+                "America/New_York",
+                "2024-11-03 01:30:00",
+                "2024-11-03T05:30:00Z",
+            ),
+            (
+                "America/New_York",
+                "2024-11-03 02:00:00",
+                "2024-11-03T07:00:00Z",
+            ),
+            (
+                "America/New_York",
+                "2024-11-03 02:00:00.999",
+                "2024-11-03T07:00:00.999Z",
+            ),
+            (
+                "America/New_York",
+                "2024-03-10 02:30:00",
+                "2024-03-10T07:30:00Z",
+            ),
+            (
+                "Australia/Lord_Howe",
+                "2024-10-06 02:15:00",
+                "2024-10-05T15:45:00Z",
+            ),
+            (
+                "Pacific/Apia",
+                "2011-12-30 12:00:00",
+                "2011-12-30T22:00:00Z",
+            ),
+            (
+                "UTC",
+                "1969-12-31 23:59:59.999999999",
+                "1969-12-31T23:59:59.999Z",
+            ),
+        ] {
+            let zone = jiff::tz::db().get(zone).unwrap();
+            assert_eq!(
+                parse_scan_timestamp(value, &zone).unwrap(),
+                chrono::DateTime::parse_from_rfc3339(expected)
+                    .unwrap()
+                    .timestamp_millis(),
+                "{value} in {zone:?}"
+            );
+        }
+    }
+
+    #[test]
+    fn test_scan_timestamp_local_overlap_uses_earlier_instant() {
+        const CHILD: &str = "PAIMON_SCAN_TIMESTAMP_OVERLAP_CHILD";
+        if std::env::var_os(CHILD).is_some() {
+            // Resolve the system zone in a fresh process, including on 
Windows.
+            let actual =
+                parse_scan_timestamp("2024-11-03 01:30:00", 
&jiff::tz::TimeZone::system()).unwrap();
+            assert_eq!(actual, 1_730_611_800_000); // 2024-11-03T05:30:00Z
+            let overlap_end =
+                parse_scan_timestamp("2024-11-03 02:00:00", 
&jiff::tz::TimeZone::system()).unwrap();
+            assert_eq!(overlap_end, 1_730_617_200_000); // 2024-11-03T07:00:00Z
+            let gap =
+                parse_scan_timestamp("2024-03-10 02:30:00", 
&jiff::tz::TimeZone::system()).unwrap();
+            assert_eq!(gap, 1_710_055_800_000); // 2024-03-10T07:30:00Z
+            return;
+        }
+        let output = 
std::process::Command::new(std::env::current_exe().unwrap())
+            .args([
+                "--exact",
+                
"spec::core_options::tests::test_scan_timestamp_local_overlap_uses_earlier_instant",
+            ])
+            .env("TZ", "America/New_York")
+            .env(CHILD, "1")
+            .output()
+            .unwrap();
+        assert!(
+            output.status.success(),
+            "child failed:\n{}{}",
+            String::from_utf8_lossy(&output.stdout),
+            String::from_utf8_lossy(&output.stderr)
+        );
+    }
+
+    #[test]
+    fn test_scan_timestamp_rejects_invalid_values_and_conflicts() {
+        for value in [
+            "",
+            "invalid",
+            "1700000000000",
+            "2024-13-01",
+            "2024-01-01 25:00:00",
+            "2024-01-01T00:00:00Z",
+            "2024-01-01 23:59:60",
+            "2024-01-01 00:00:00.1234567890",
+        ] {
+            let options = HashMap::from([("scan.timestamp".to_string(), 
value.to_string())]);
+            let core = CoreOptions::new(&options);
+            assert!(core.has_time_travel_selector());
+            let err = core.try_time_travel_selector().unwrap_err();
+            assert!(
+                matches!(err, crate::Error::DataInvalid { message, .. } if 
message.contains("scan.timestamp")),
+                "{value}"
+            );
+        }
+        for selector in [
+            SCAN_TIMESTAMP_MILLIS_OPTION,
+            SCAN_WATERMARK_OPTION,
+            SCAN_VERSION_OPTION,
+            SCAN_SNAPSHOT_ID_OPTION,
+            SCAN_TAG_NAME_OPTION,
+        ] {
+            let options = HashMap::from([
+                ("scan.timestamp".to_string(), "2024-01-02".to_string()),
+                (selector.to_string(), "1".to_string()),
+            ]);
+            let err = CoreOptions::new(&options)
+                .try_time_travel_selector()
+                .unwrap_err();
+            assert!(
+                matches!(err, crate::Error::DataInvalid { message, .. } if 
message.contains("Only one") && message.contains("scan.timestamp") && 
message.contains(selector))
+            );
+        }
+        let options = HashMap::from([
+            ("scan.timestamp".to_string(), "2024-01-02".to_string()),
+            ("scan.mode".to_string(), "from-timestamp".to_string()),
+        ]);
+        assert!(CoreOptions::new(&options).validate_scan_options().is_ok());
+    }
+
     #[test]
     fn test_try_time_travel_selector_rejects_conflicting_selectors() {
         let options = HashMap::from([
diff --git a/crates/paimon/src/table/mod.rs b/crates/paimon/src/table/mod.rs
index c7363f3d..f536f3b5 100644
--- a/crates/paimon/src/table/mod.rs
+++ b/crates/paimon/src/table/mod.rs
@@ -193,7 +193,8 @@ use crate::catalog::{validate_branch_name, Identifier, 
DEFAULT_MAIN_BRANCH};
 use crate::io::FileIO;
 use crate::spec::{
     CoreOptions, DataField, Snapshot, TableSchema, SCAN_SNAPSHOT_ID_OPTION, 
SCAN_TAG_NAME_OPTION,
-    SCAN_TIMESTAMP_MILLIS_OPTION, SCAN_VERSION_OPTION, SCAN_WATERMARK_OPTION,
+    SCAN_TIMESTAMP_MILLIS_OPTION, SCAN_TIMESTAMP_OPTION, SCAN_VERSION_OPTION,
+    SCAN_WATERMARK_OPTION,
 };
 use std::collections::HashMap;
 
@@ -456,6 +457,7 @@ impl Table {
         let selector_changed = extra.keys().any(|k| {
             k == crate::spec::SCAN_VERSION_OPTION
                 || k == crate::spec::SCAN_TIMESTAMP_MILLIS_OPTION
+                || k == crate::spec::SCAN_TIMESTAMP_OPTION
                 || k == crate::spec::SCAN_WATERMARK_OPTION
                 || k == crate::spec::SCAN_SNAPSHOT_ID_OPTION
                 || k == crate::spec::SCAN_TAG_NAME_OPTION
@@ -533,6 +535,7 @@ impl Table {
         let mut options = self.schema.options().clone();
         for selector in [
             SCAN_TIMESTAMP_MILLIS_OPTION,
+            SCAN_TIMESTAMP_OPTION,
             SCAN_WATERMARK_OPTION,
             SCAN_VERSION_OPTION,
             SCAN_SNAPSHOT_ID_OPTION,
@@ -559,7 +562,7 @@ impl Table {
     ///
     /// Mirrors Java `AbstractFileStoreTable.copy(dynamicOptions)` →
     /// `tryTimeTravel`: if the merged options contain a time-travel selector
-    /// (`scan.version` / `scan.timestamp-millis` / `scan.watermark` /
+    /// (`scan.version` / `scan.timestamp-millis` / `scan.timestamp` / 
`scan.watermark` /
     /// `scan.snapshot-id` / `scan.tag-name`) that resolves to a snapshot, the
     /// table's fields and keys come from that snapshot's schema while the
     /// options stay the merged ones (Java `TableSchema.copy(newOptions)`).
diff --git a/crates/paimon/src/table/table_scan.rs 
b/crates/paimon/src/table/table_scan.rs
index 2a84357a..d1c614b6 100644
--- a/crates/paimon/src/table/table_scan.rs
+++ b/crates/paimon/src/table/table_scan.rs
@@ -1490,13 +1490,14 @@ impl<'a> PaimonTableScan<'a> {
     ///
     /// Time travel is resolved from table options:
     /// - `scan.version` is resolved first, overwriting the same selector kind;
-    ///   only one of `scan.timestamp-millis`, `scan.watermark`, 
`scan.snapshot-id`,
+    ///   only one of `scan.timestamp-millis`, `scan.timestamp`, 
`scan.watermark`, `scan.snapshot-id`,
     ///   `scan.tag-name` may remain after resolution
     /// - `scan.version` → tag name (if exists) → `watermark-<value>` → 
snapshot
     ///   id (if parseable) → error (ambiguous by design, like SQL `VERSION AS 
OF`)
     /// - `scan.snapshot-id` → snapshot id only (never a tag lookup)
     /// - `scan.tag-name` → tag name only (never parsed as a snapshot id)
     /// - `scan.timestamp-millis` → find the latest snapshot <= that timestamp
+    /// - `scan.timestamp` → parse in the local time zone, then resolve like 
`scan.timestamp-millis`
     /// - `scan.watermark` → find the earliest snapshot with watermark >= that
     ///   value (snapshots without a watermark are skipped)
     /// - otherwise → read the latest snapshot
diff --git a/crates/paimon/src/table/time_travel.rs 
b/crates/paimon/src/table/time_travel.rs
index 838f0d3c..fadb1715 100644
--- a/crates/paimon/src/table/time_travel.rs
+++ b/crates/paimon/src/table/time_travel.rs
@@ -374,6 +374,101 @@ mod tests {
             .collect()
     }
 
+    #[tokio::test]
+    async fn test_scan_timestamp_reads_historical_schema_and_rows() {
+        use chrono::{Local, TimeZone};
+        use futures::TryStreamExt;
+
+        let (io, path) = setup_evolved_table().await;
+        let table = latest_table(&io, &path);
+        let sm = table.snapshot_manager();
+        let base = Local
+            .with_ymd_and_hms(2024, 1, 2, 12, 0, 0)
+            .unwrap()
+            .timestamp_millis();
+        // Fix commit times so the exact/between/before boundaries never depend
+        // on how quickly the fixture commits complete.
+        for (id, millis) in [(1, base + 123), (2, base + 1000)] {
+            let mut snapshot = 
serde_json::to_value(sm.get_snapshot(id).await.unwrap()).unwrap();
+            snapshot["timeMillis"] = serde_json::json!(millis);
+            io.new_output(&sm.snapshot_path(id))
+                .unwrap()
+                .write(serde_json::to_vec(&snapshot).unwrap().into())
+                .await
+                .unwrap();
+        }
+
+        for timestamp in ["2024-01-02 12:00:00.123", 
"2024-01-02T12:00:00.999999999"] {
+            let opts = options(&[
+                ("scan.timestamp", timestamp),
+                ("scan.mode", "from-timestamp"),
+            ]);
+            let traveled = table
+                .copy_with_time_travel_strict(opts.clone())
+                .await
+                .unwrap();
+            assert_eq!(traveled.schema().id(), 0);
+            assert_eq!(traveled.travel_snapshot().unwrap().id(), 1);
+            assert!(traveled.new_write_builder().new_write().is_err());
+            assert!(table
+                .copy_with_options(opts)
+                .new_write_builder()
+                .new_write()
+                .is_err());
+            let builder = traveled.new_read_builder();
+            let plan = builder.new_scan().plan().await.unwrap();
+            let batches: Vec<RecordBatch> = builder
+                .new_read()
+                .unwrap()
+                .to_arrow(plan.splits())
+                .unwrap()
+                .try_collect()
+                .await
+                .unwrap();
+            let ids: Vec<i32> = batches
+                .iter()
+                .flat_map(|batch| {
+                    assert_eq!(batch.num_columns(), 2);
+                    batch
+                        .column(0)
+                        .as_any()
+                        .downcast_ref::<Int32Array>()
+                        .unwrap()
+                        .values()
+                        .iter()
+                        .copied()
+                })
+                .collect();
+            assert_eq!(ids, vec![1, 2, 3]);
+
+            let changed =
+                traveled.copy_with_options(options(&[("scan.timestamp", 
"2024-01-02 12:00:01")]));
+            assert!(changed.travel_snapshot().is_none());
+            let resolved = changed
+                .copy_with_time_travel_strict(HashMap::new())
+                .await
+                .unwrap();
+            assert_eq!(resolved.travel_snapshot().unwrap().id(), 2);
+            assert_eq!(resolved.schema().id(), 1);
+
+            let pinned = 
traveled.copy_with_pinned_snapshot(traveled.travel_snapshot().unwrap());
+            assert!(!pinned.schema().options().contains_key("scan.timestamp"));
+        }
+        for timestamp in ["2024-01-02 12:00:01", "2024-01-02 12:00:02"] {
+            let selected = super::resolve_snapshot(
+                &table.copy_with_options(options(&[("scan.timestamp", 
timestamp)])),
+            )
+            .await
+            .unwrap()
+            .unwrap();
+            assert_eq!(selected.id(), 2);
+        }
+        for timestamp in ["2024-01-02 12:00:00.122", "bad timestamp"] {
+            let selected = 
table.copy_with_options(options(&[("scan.timestamp", timestamp)]));
+            
assert!(selected.new_read_builder().new_scan().plan().await.is_err());
+        }
+    }
+
     #[tokio::test]
     async fn test_copy_with_time_travel_switches_to_snapshot_schema() {
         let (file_io, table_path) = setup_evolved_table().await;
diff --git a/crates/paimon/src/table/write_builder.rs 
b/crates/paimon/src/table/write_builder.rs
index 7f00059d..b2af61d0 100644
--- a/crates/paimon/src/table/write_builder.rs
+++ b/crates/paimon/src/table/write_builder.rs
@@ -222,7 +222,7 @@ pub(super) fn ensure_table_write_allowed(table: &Table) -> 
crate::Result<()> {
     if !matches!(selector, Ok(None)) {
         return Err(crate::Error::Unsupported {
             message: "Cannot write to a table with a time-travel option set \
-                  (scan.version / scan.timestamp-millis / scan.snapshot-id / 
scan.tag-name)"
+                  (scan.version / scan.timestamp-millis / scan.timestamp / 
scan.watermark / scan.snapshot-id / scan.tag-name)"
                 .to_string(),
         });
     }
diff --git a/docs/src/python-binding.md b/docs/src/python-binding.md
index 6e5ce569..5d5ee49e 100644
--- a/docs/src/python-binding.md
+++ b/docs/src/python-binding.md
@@ -443,6 +443,9 @@ rb = table.new_read_builder({"scan.snapshot-id": "1"})
 # By timestamp (epoch millis)
 rb = table.new_read_builder({"scan.timestamp-millis": "1700000000000"})
 
+# By timestamp string (process local time zone; fractional seconds are 
supported)
+rb = table.new_read_builder({"scan.timestamp": "2024-01-01 12:00:00.123"})
+
 # By version
 rb = table.new_read_builder({"scan.version": "3"})
 
@@ -453,6 +456,12 @@ rb = table.new_read_builder({"scan.tag-name": 
"release-1.0"})
 !!! warning
     Only one time-travel selector may be set. Providing multiple selectors 
will raise a `ValueError`.
 
+`scan.timestamp` selects the latest snapshot committed at or before the given
+local time. It accepts `YYYY-MM-DD`, `YYYY-MM-DD HH:MM:SS[.fraction]`, or
+`YYYY-MM-DDTHH:MM[:SS[.fraction]]`, with up to nine fractional digits truncated
+to milliseconds. It can be combined with `scan.mode=from-timestamp`. Invalid
+timestamps and times before the earliest available snapshot raise an error.
+
 ## Table Inspection
 
 Inspect snapshots, tags, and partition statistics on a table. A 
branch-qualified
diff --git a/docs/src/sql.md b/docs/src/sql.md
index 6cad8cfe..031a38bc 100644
--- a/docs/src/sql.md
+++ b/docs/src/sql.md
@@ -2018,6 +2018,19 @@ SELECT * FROM paimon.default.my_table TIMESTAMP AS OF 
'2024-01-01 00:00:00';
 
 This finds the latest snapshot whose commit time is less than or equal to the 
given timestamp. The timestamp is interpreted in the local timezone.
 
+The session option `scan.timestamp` also accepts a local timestamp string,
+including fractional seconds (up to nine digits, truncated to milliseconds):
+
+```sql
+SET 'paimon.scan.timestamp' = '2024-01-01 00:00:00.123';
+SELECT * FROM paimon.default.my_table;
+RESET 'paimon.scan.timestamp';
+```
+
+It can be combined with `scan.mode=from-timestamp`, but cannot be combined with
+another time-travel selector such as `scan.timestamp-millis` or 
`scan.snapshot-id`.
+An invalid timestamp or a time before the earliest available snapshot fails 
the read.
+
 ### By Watermark
 
 Use `VERSION AS OF 'watermark-<value>'` syntax:

Reply via email to