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 52f82d33 fix(core): align timestamp partition paths with Java (#833)
52f82d33 is described below

commit 52f82d3364cd8ab0efc2261a1e7218a1f7e6879c
Author: Jingsong Lee <[email protected]>
AuthorDate: Tue Sep 15 11:44:52 2026 +0800

    fix(core): align timestamp partition paths with Java (#833)
---
 bindings/python/tests/test_write.py        |  33 +++++++
 crates/paimon/src/spec/partition_utils.rs  | 143 ++++++++++++++++++++++++-----
 crates/paimon/src/table/index_file_path.rs |  88 ++++++++++++++++++
 3 files changed, 240 insertions(+), 24 deletions(-)

diff --git a/bindings/python/tests/test_write.py 
b/bindings/python/tests/test_write.py
index aeac37d5..8d524354 100644
--- a/bindings/python/tests/test_write.py
+++ b/bindings/python/tests/test_write.py
@@ -16,6 +16,7 @@
 # under the License.
 
 import tempfile
+from datetime import datetime
 
 import pyarrow as pa
 import pytest
@@ -248,3 +249,35 @@ def test_abort_different_builder_same_table_raises():
         messages = write.prepare_commit()
         with pytest.raises(ValueError):
             table.new_write_builder().new_commit().abort(messages)
+
+
[email protected]("legacy,precision,unit,value,directory", [
+    (False, 3, "ms", datetime(2026, 9, 15, 12), "ts=2026-09-15 
12%3A00%3A00.000"),
+    (False, 6, "us", datetime(2026, 9, 15, 12), "ts=2026-09-15 
12%3A00%3A00.000000"),
+    (False, 3, "ms", datetime(2026, 9, 15, 12, 0, 0, 120000), "ts=2026-09-15 
12%3A00%3A00.120"),
+    (True, 3, "ms", datetime(2026, 9, 15, 12, 0, 0, 100000), 
"ts=2026-09-15T12%3A00%3A00.100"),
+    (True, 6, "us", datetime(2026, 9, 15, 12, 0, 0, 120100), 
"ts=2026-09-15T12%3A00%3A00.120100"),
+])
+def test_timestamp_partition_writes_and_reads_use_java_paths(tmp_path, legacy, 
precision, unit, value, directory):
+    ctx = SQLContext()
+    ctx.register_catalog("paimon", {"warehouse": str(tmp_path)})
+    ctx.sql("CREATE SCHEMA paimon.wdb")
+    ctx.sql(
+        "CREATE TABLE paimon.wdb.t (id INT, ts TIMESTAMP({})) PARTITIONED BY 
(ts) "
+        "WITH ('partition.legacy-name' = '{}')".format(precision, 
str(legacy).lower())
+    )
+    schema = pa.schema([("id", pa.int32()), ("ts", pa.timestamp(unit))])
+    for row_id in [1, 2]:
+        table = _get_table(str(tmp_path))
+        builder = table.new_write_builder()
+        write = builder.new_write()
+        write.write_arrow(pa.record_batch([[row_id], [value]], schema=schema))
+        builder.new_commit().commit(write.prepare_commit())
+    files = list(tmp_path.rglob("data-*.parquet"))
+    assert len(files) == 2
+    assert all(file.parent.parent.name == directory for file in files)
+    table = _get_table(str(tmp_path))
+    plan = table.new_read_builder().new_scan().plan()
+    assert sum(split.row_count() for split in plan.splits()) == 2
+    rows = pa.Table.from_batches(ctx.sql("SELECT id, ts FROM 
paimon.wdb.t")).sort_by("id")
+    assert rows.to_pydict() == {"id": [1, 2], "ts": [value, value]}
diff --git a/crates/paimon/src/spec/partition_utils.rs 
b/crates/paimon/src/spec/partition_utils.rs
index 69e1941d..60b83eb2 100644
--- a/crates/paimon/src/spec/partition_utils.rs
+++ b/crates/paimon/src/spec/partition_utils.rs
@@ -270,16 +270,17 @@ fn format_partition_value(
 
         DataType::LocalZonedTimestamp(t) => {
             let (millis, nano_of_milli) = row.get_timestamp_raw(pos, 
t.precision())?;
+            let dt = if legacy {
+                // Legacy Timestamp.toString() does not apply timezone 
conversion.
+                millis_to_naive_datetime(millis, nano_of_milli)
+            } else {
+                // Non-legacy TimestampToStringCastRule applies 
TimeZone.getDefault().
+                epoch_millis_to_local_datetime(millis, nano_of_milli)
+            };
             if legacy {
-                // Legacy: Timestamp.toString() → toLocalDateTime().toString(),
-                // which does NOT apply timezone conversion.
-                let dt = millis_to_naive_datetime(millis, nano_of_milli);
                 format_timestamp_legacy(dt)
             } else {
-                // Non-legacy: convert to local timezone, mirroring Java
-                // TimestampToStringCastRule which applies 
TimeZone.getDefault().
-                let local_dt = epoch_millis_to_local_datetime(millis, 
nano_of_milli);
-                format_timestamp_non_legacy(local_dt, t.precision())
+                format_timestamp_non_legacy(dt, t.precision())
             }
         }
 
@@ -446,7 +447,7 @@ fn epoch_millis_to_local_datetime(millis: i64, 
nano_of_milli: i32) -> NaiveDateT
 ///
 /// - Omits seconds if seconds == 0 and nanos == 0: `2024-01-01T12:34`
 /// - Omits fractional part if nanos == 0: `2024-01-01T12:34:56`
-/// - Appends minimal fractional digits (strips trailing zeros): 
`2024-01-01T12:34:56.123`
+/// - Uses 3, 6, or 9 fractional digits: `2024-01-01T12:34:56.100`
 fn format_timestamp_legacy(dt: NaiveDateTime) -> String {
     let nano = dt.nanosecond();
     let sec = dt.second();
@@ -460,9 +461,15 @@ fn format_timestamp_legacy(dt: NaiveDateTime) -> String {
     let mut result = format!("{date_hour_min}:{sec:02}");
     if nano > 0 {
         let frac = format!("{nano:09}");
-        let trimmed = frac.trim_end_matches('0');
+        let digits = if nano.is_multiple_of(1_000_000) {
+            3
+        } else if nano.is_multiple_of(1_000) {
+            6
+        } else {
+            9
+        };
         result.push('.');
-        result.push_str(trimmed);
+        result.push_str(&frac[..digits]);
     }
     result
 }
@@ -470,16 +477,12 @@ fn format_timestamp_legacy(dt: NaiveDateTime) -> String {
 /// Format a timestamp using non-legacy `DateTimeUtils.formatTimestamp()` 
semantics.
 ///
 /// Always uses space separator: `yyyy-MM-dd HH:mm:ss[.fraction]`.
-/// Fraction: pad nano to 9 digits, strip trailing zeros down to at most 
`precision` digits.
+/// Fraction: pad nano to 9 digits, strip trailing zeros while keeping at 
least `precision` digits.
 fn format_timestamp_non_legacy(dt: NaiveDateTime, precision: u32) -> String {
     let nano = dt.nanosecond();
     let ymdhms = dt.format("%Y-%m-%d %H:%M:%S").to_string();
 
-    if precision == 0 || nano == 0 {
-        return ymdhms;
-    }
-
-    // Pad nano to 9 digits, then strip trailing zeros but keep at least up to 
`precision` digits.
+    // Whole seconds keep the declared precision; nonzero fractions are never 
truncated.
     let nano_str = format!("{nano:09}");
     let mut fraction = &nano_str[..];
 
@@ -930,12 +933,22 @@ mod tests {
             .and_hms_opt(12, 34, 56)
             .unwrap();
         assert_eq!(format_timestamp_legacy(dt2), "2024-01-01T12:34:56");
-        // 3. nano>0: include fraction with trailing zero stripping
-        let dt3 = NaiveDate::from_ymd_opt(2024, 1, 1)
-            .unwrap()
-            .and_hms_nano_opt(12, 34, 56, 123_000_000)
-            .unwrap();
-        assert_eq!(format_timestamp_legacy(dt3), "2024-01-01T12:34:56.123");
+        // Java LocalDateTime.toString() emits fractions in groups of three 
digits.
+        for (nanos, expected) in [
+            (100_000_000, "2024-01-01T12:34:56.100"),
+            (120_000_000, "2024-01-01T12:34:56.120"),
+            (123_000_000, "2024-01-01T12:34:56.123"),
+            (123_400_000, "2024-01-01T12:34:56.123400"),
+            (123_456_000, "2024-01-01T12:34:56.123456"),
+            (123_456_700, "2024-01-01T12:34:56.123456700"),
+            (1, "2024-01-01T12:34:56.000000001"),
+        ] {
+            let dt = NaiveDate::from_ymd_opt(2024, 1, 1)
+                .unwrap()
+                .and_hms_nano_opt(12, 34, 56, nanos)
+                .unwrap();
+            assert_eq!(format_timestamp_legacy(dt), expected);
+        }
     }
 
     #[test]
@@ -944,8 +957,11 @@ mod tests {
             .unwrap()
             .and_hms_nano_opt(12, 0, 0, 123_000_000)
             .unwrap();
-        // precision=0 → no fraction
-        assert_eq!(format_timestamp_non_legacy(dt, 0), "2024-01-01 12:00:00");
+        // Precision is a minimum fraction width, not a truncation boundary.
+        assert_eq!(
+            format_timestamp_non_legacy(dt, 0),
+            "2024-01-01 12:00:00.123"
+        );
         // precision=3 → strip trailing zeros down to 3 digits
         assert_eq!(
             format_timestamp_non_legacy(dt, 3),
@@ -958,6 +974,85 @@ mod tests {
         );
     }
 
+    #[test]
+    fn test_timestamp_non_legacy_whole_seconds_keep_declared_precision() {
+        let dt = NaiveDate::from_ymd_opt(2024, 1, 1)
+            .unwrap()
+            .and_hms_opt(12, 0, 0)
+            .unwrap();
+        for (precision, expected) in [
+            (0, "2024-01-01 12:00:00"),
+            (1, "2024-01-01 12:00:00.0"),
+            (3, "2024-01-01 12:00:00.000"),
+            (6, "2024-01-01 12:00:00.000000"),
+            (9, "2024-01-01 12:00:00.000000000"),
+        ] {
+            assert_eq!(format_timestamp_non_legacy(dt, precision), expected);
+        }
+    }
+
+    #[test]
+    fn test_timestamp_non_legacy_preserves_significant_fraction_digits() {
+        for (nanos, precision, expected) in [
+            (120_000_000, 0, "2024-01-01 12:00:00.12"),
+            (120_000_000, 3, "2024-01-01 12:00:00.120"),
+            (123_456_700, 0, "2024-01-01 12:00:00.1234567"),
+            (123_456_700, 3, "2024-01-01 12:00:00.1234567"),
+            (123_456_700, 9, "2024-01-01 12:00:00.123456700"),
+        ] {
+            let dt = NaiveDate::from_ymd_opt(2024, 1, 1)
+                .unwrap()
+                .and_hms_nano_opt(12, 0, 0, nanos)
+                .unwrap();
+            assert_eq!(format_timestamp_non_legacy(dt, precision), expected);
+        }
+    }
+
+    #[test]
+    fn test_timestamp_partition_paths_match_java() {
+        for (nanos, precision, legacy, expected) in [
+            (0, 0, false, "ts=2024-01-01 12%3A34%3A00/"),
+            (0, 3, false, "ts=2024-01-01 12%3A34%3A00.000/"),
+            (120_000_000, 0, false, "ts=2024-01-01 12%3A34%3A00.12/"),
+            (100_000_000, 3, true, "ts=2024-01-01T12%3A34%3A00.100/"),
+            (123_400_000, 6, true, "ts=2024-01-01T12%3A34%3A00.123400/"),
+            (
+                123_456_700,
+                9,
+                true,
+                "ts=2024-01-01T12%3A34%3A00.123456700/",
+            ),
+        ] {
+            let dt = NaiveDate::from_ymd_opt(2024, 1, 1)
+                .unwrap()
+                .and_hms_nano_opt(12, 34, 0, nanos)
+                .unwrap();
+            let fields = vec![make_field(
+                "ts",
+                DataType::Timestamp(TimestampType::new(precision).unwrap()),
+            )];
+            let computer = PartitionComputer::new(
+                &["ts".to_string()],
+                &fields,
+                TEST_DEFAULT_PARTITION_NAME,
+                legacy,
+            )
+            .unwrap();
+            let mut builder = crate::spec::BinaryRowBuilder::new(1);
+            if precision <= 3 {
+                builder.write_timestamp_compact(0, 
dt.and_utc().timestamp_millis());
+            } else {
+                builder.write_timestamp_non_compact(
+                    0,
+                    dt.and_utc().timestamp_millis(),
+                    (nanos % 1_000_000) as i32,
+                );
+            }
+            let row = builder.build();
+            assert_eq!(computer.generate_partition_path(&row).unwrap(), 
expected);
+        }
+    }
+
     #[test]
     fn test_timestamp_partition_legacy() {
         // 2024-01-01 12:34:00 UTC = epoch millis 1704110040000
diff --git a/crates/paimon/src/table/index_file_path.rs 
b/crates/paimon/src/table/index_file_path.rs
index 2b540332..d96743fc 100644
--- a/crates/paimon/src/table/index_file_path.rs
+++ b/crates/paimon/src/table/index_file_path.rs
@@ -312,6 +312,94 @@ mod tests {
         }
     }
 
+    #[tokio::test]
+    async fn timestamp_dvs_use_canonical_paths_and_explicit_precedence() {
+        use crate::catalog::Identifier;
+        use crate::io::FileIOBuilder;
+        use crate::spec::{
+            bucket_path_under, BinaryRowBuilder, DataType, FileKind, Schema, 
TableSchema,
+            TimestampType,
+        };
+        for (legacy, millis, java_partition) in [
+            (false, 0, "ts=1970-01-01 00%3A00%3A00.000/"),
+            (true, 100, "ts=1970-01-01T00%3A00%3A00.100/"),
+        ] {
+            let dir = tempfile::tempdir().unwrap();
+            let table_path = dir.path().to_str().unwrap();
+            let schema = Schema::builder()
+                .column("ts", 
DataType::Timestamp(TimestampType::new(3).unwrap()))
+                .partition_keys(vec!["ts".to_string()])
+                .option("partition.legacy-name", legacy.to_string())
+                .option("index-file-in-data-file-dir", "true")
+                .build()
+                .unwrap();
+            let schema = TableSchema::new(0, &schema);
+            let file_io = FileIOBuilder::new("file").build().unwrap();
+            let table = Table::new(
+                file_io,
+                Identifier::new("db", "t"),
+                table_path.to_string(),
+                schema,
+                None,
+            );
+            let java_bucket = bucket_path_under(table_path, java_partition, 7);
+            let table_index = format!("{table_path}/index");
+            for path in [&java_bucket, &table_index] {
+                std::fs::create_dir_all(path).unwrap();
+            }
+            for (parent, name, bytes) in [
+                (&java_bucket, "idx-java", b"Java".as_slice()),
+                (&table_index, "idx-java", b"wrong table copy".as_slice()),
+                (&table_index, "idx-table", b"table".as_slice()),
+                (&java_bucket, "idx-explicit", b"wrong Java copy".as_slice()),
+                (&table_index, "idx-explicit", b"wrong table copy".as_slice()),
+            ] {
+                std::fs::write(format!("{parent}/{name}"), bytes).unwrap();
+            }
+            let mut row = BinaryRowBuilder::new(1);
+            row.write_timestamp_compact(0, millis);
+            let partition = row.build_serialized();
+            let mut entries: Vec<_> = ["idx-java", "idx-table", 
"idx-explicit", "idx-missing"]
+                .into_iter()
+                .map(|name| {
+                    let mut file = committed_file(None);
+                    file.file_name = name.to_string();
+                    file.index_type = "DELETION_VECTORS".to_string();
+                    IndexManifestEntry {
+                        version: 1,
+                        kind: FileKind::Add,
+                        partition: partition.clone(),
+                        bucket: 7,
+                        index_file: file,
+                    }
+                })
+                .collect();
+            let explicit = 
format!("{table_path}/missing-explicit/idx-explicit");
+            entries[2].index_file.external_path = Some(explicit.clone());
+            resolve_legacy_deletion_vector_entries(&table, &mut entries)
+                .await
+                .unwrap();
+            assert_eq!(entries[0].index_file.external_path, None);
+            assert_eq!(
+                entries[1].index_file.external_path,
+                Some(format!("{table_index}/idx-table"))
+            );
+            assert_eq!(entries[2].index_file.external_path, 
Some(explicit.clone()));
+            assert_eq!(entries[3].index_file.external_path, None);
+            let location = IndexFileLocation::BucketLocal {
+                table_path,
+                bucket_path: &java_bucket,
+                index_file_in_data_file_dir: true,
+            };
+            for (entry, expected) in 
entries.iter().take(2).zip([b"Java".as_slice(), b"table"]) {
+                let file = &entry.index_file;
+                let path = location.resolve(&file.file_name, 
file.external_path.as_deref());
+                assert_eq!(std::fs::read(path).unwrap(), expected);
+            }
+            assert!(!table.file_io().exists(&explicit).await.unwrap());
+        }
+    }
+
     /// A committed index file with the given `_GLOBAL_INDEX` and 
`_SOURCE_META`.
     fn committed_file(global_index_meta: Option<Option<Vec<u8>>>) -> 
IndexFileMeta {
         IndexFileMeta {

Reply via email to