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 e2f1973  fix(core): align BinaryRow timestamp write path with 
euclidean representation (#485)
e2f1973 is described below

commit e2f1973bcc4a6359b3b0eefba3c67915e36f0e24
Author: Wuhen- Li <[email protected]>
AuthorDate: Thu Jul 9 10:06:19 2026 +0800

    fix(core): align BinaryRow timestamp write path with euclidean 
representation (#485)
    
    The BinaryRow write/extract path converted microsecond timestamps with
    truncating division while the DataFusion literal pushdown path uses
    euclidean division. For pre-epoch sub-millisecond values the two paths
    disagreed: e.g. -1us was stored as (millis=0, nanos=-1000) but the pushed
    literal became (millis=-1, nanos=999_000), making pushed equality/range
    predicates false-negative.
    
    Switch the three write-path spots in binary_row.rs to div_euclid/rem_euclid
    so stored timestamps match the pushdown representation and Paimon Java's
    canonical (millisecond, nanoOfMillisecond in [0, 999999]) form.
    
    Add a unit test (fails pre-fix) verifying the write path normalizes a
    negative sub-millisecond timestamp, plus an end-to-end DataFusion scan test
    covering pushed equality/range predicates on pre-epoch fractional 
timestamps.
---
 .../integrations/datafusion/tests/read_tables.rs   | 81 ++++++++++++++++++++++
 crates/paimon/src/spec/binary_row.rs               | 67 ++++++++++++++++--
 2 files changed, 142 insertions(+), 6 deletions(-)

diff --git a/crates/integrations/datafusion/tests/read_tables.rs 
b/crates/integrations/datafusion/tests/read_tables.rs
index f2b8894..cb9a68f 100644
--- a/crates/integrations/datafusion/tests/read_tables.rs
+++ b/crates/integrations/datafusion/tests/read_tables.rs
@@ -499,6 +499,87 @@ async fn 
test_temporal_filter_pushdown_via_datafusion_scan() {
     );
 }
 
+/// Regression test for pre-epoch, sub-millisecond timestamps.
+///
+/// The BinaryRow write path and the DataFusion literal pushdown path must
+/// agree on the `(millis, nanos)` representation of a timestamp. Previously 
the
+/// write path used truncating division while pushdown used euclidean division,
+/// so `1969-12-31 23:59:59.999999` (`-1us`) was stored as `(0, -1000)` but the
+/// pushed literal became `(-1, 999_000)`. A pushed equality/range predicate on
+/// such a value was therefore false-negative and dropped matching rows.
+#[tokio::test]
+async fn test_negative_temporal_filter_pushdown_via_datafusion_scan() {
+    let (_tmp, sql_context) = common::setup_sql_context().await;
+    sql_context
+        .sql(
+            "CREATE TABLE paimon.test_db.negative_temporal_pushdown (
+                id INT,
+                name STRING,
+                ts TIMESTAMP(6)
+            )",
+        )
+        .await
+        .expect("CREATE TABLE should succeed")
+        .collect()
+        .await
+        .expect("CREATE TABLE should collect");
+    // id=1 sits 1us before the epoch (negative micros), id=2 sits at the 
epoch,
+    // id=3 is a normal post-epoch value. Each row is written in its own 
INSERT so
+    // it lands in a separate data file, forcing per-file min/max stats 
pruning to
+    // rely on the pushed predicate rather than a range that happens to 
overlap.
+    for values in [
+        "(1, 'neg_us', TIMESTAMP '1969-12-31 23:59:59.999999')",
+        "(2, 'epoch', TIMESTAMP '1970-01-01 00:00:00.000000')",
+        "(3, 'post', TIMESTAMP '1970-01-01 00:00:00.000001')",
+    ] {
+        sql_context
+            .sql(&format!(
+                "INSERT INTO paimon.test_db.negative_temporal_pushdown VALUES 
{values}"
+            ))
+            .await
+            .expect("INSERT should succeed")
+            .collect()
+            .await
+            .expect("INSERT should collect");
+    }
+
+    // Equality on the pre-epoch fractional timestamp must return exactly id=1.
+    let eq_sql = "SELECT id, name FROM 
paimon.test_db.negative_temporal_pushdown \
+        WHERE ts = TIMESTAMP '1969-12-31 23:59:59.999999'";
+    let plan = sql_context
+        .sql(eq_sql)
+        .await
+        .expect("SQL planning should succeed")
+        .create_physical_plan()
+        .await
+        .expect("Physical plan creation should succeed");
+    let plan_text = format_physical_plan(&plan);
+    let scan_lines = paimon_scan_lines(&plan_text);
+    assert!(
+        scan_lines
+            .iter()
+            .any(|line| line.contains("predicate=ts = TS(")),
+        "Temporal predicate should be pushed into PaimonTableScan, 
plan:\n{plan_text}"
+    );
+
+    let rows = common::collect_id_name(&sql_context, eq_sql).await;
+    assert_eq!(
+        rows,
+        vec![(1, "neg_us".to_string())],
+        "Pushed equality on a pre-epoch sub-millisecond timestamp must match 
the stored row"
+    );
+
+    // Range predicate across the epoch boundary must keep only the pre-epoch 
row.
+    let range_sql = "SELECT id, name FROM 
paimon.test_db.negative_temporal_pushdown \
+        WHERE ts < TIMESTAMP '1970-01-01 00:00:00.000000'";
+    let rows = common::collect_id_name(&sql_context, range_sql).await;
+    assert_eq!(
+        rows,
+        vec![(1, "neg_us".to_string())],
+        "Pushed range predicate must order pre-epoch fractional timestamps 
correctly"
+    );
+}
+
 #[tokio::test]
 async fn test_limit_pushdown_on_data_evolution_table_returns_merged_rows() {
     let batches = collect_query("SELECT id, name FROM 
paimon.default.data_evolution_table LIMIT 3")
diff --git a/crates/paimon/src/spec/binary_row.rs 
b/crates/paimon/src/spec/binary_row.rs
index cc6601b..98bb1c2 100644
--- a/crates/paimon/src/spec/binary_row.rs
+++ b/crates/paimon/src/spec/binary_row.rs
@@ -799,8 +799,8 @@ pub fn extract_datum_from_arrow(
                     .ok_or_else(|| type_mismatch_err("Timestamp(us)", 
col_idx))?;
                 let micros = arr.value(row_idx);
                 Datum::Timestamp {
-                    millis: micros / 1000,
-                    nanos: ((micros % 1000) * 1000) as i32,
+                    millis: micros.div_euclid(1_000),
+                    nanos: (micros.rem_euclid(1_000) * 1_000) as i32,
                 }
             }
         }
@@ -821,8 +821,8 @@ pub fn extract_datum_from_arrow(
                     .ok_or_else(|| 
type_mismatch_err("LocalZonedTimestamp(us)", col_idx))?;
                 let micros = arr.value(row_idx);
                 Datum::LocalZonedTimestamp {
-                    millis: micros / 1000,
-                    nanos: ((micros % 1000) * 1000) as i32,
+                    millis: micros.div_euclid(1_000),
+                    nanos: (micros.rem_euclid(1_000) * 1_000) as i32,
                 }
             }
         }
@@ -1264,8 +1264,8 @@ fn write_typed_value(
                 builder.set_null_at(pos);
             } else {
                 let micros = arr.value(row_idx);
-                let millis = micros / 1000;
-                let nanos = ((micros % 1000) * 1000) as i32;
+                let millis = micros.div_euclid(1_000);
+                let nanos = (micros.rem_euclid(1_000) * 1_000) as i32;
                 builder.write_timestamp_non_compact(pos, millis, nanos);
             }
         }
@@ -1738,4 +1738,59 @@ mod tests {
             );
         }
     }
+
+    #[test]
+    fn test_negative_sub_millisecond_timestamp_uses_euclidean_parts() {
+        use arrow_array::TimestampMicrosecondArray;
+        use arrow_schema::{DataType as ArrowDT, Field, Schema, TimeUnit};
+        use std::sync::Arc;
+
+        // 1 microsecond before the epoch: 1969-12-31 23:59:59.999999.
+        // Truncating division would store (millis=0, nanos=-1000), which is
+        // inconsistent with the DataFusion literal pushdown path that uses
+        // euclidean division and produces (millis=-1, nanos=999_000). The
+        // mismatch made pushed predicates on pre-epoch fractional timestamps
+        // false-negative, so the write path must normalize the same way.
+        let micros: i64 = -1;
+
+        let schema = Arc::new(Schema::new(vec![Field::new(
+            "ts",
+            ArrowDT::Timestamp(TimeUnit::Microsecond, None),
+            true,
+        )]));
+        let batch = RecordBatch::try_new(
+            schema,
+            vec![Arc::new(TimestampMicrosecondArray::from(vec![Some(
+                micros,
+            )]))],
+        )
+        .unwrap();
+
+        let ts_type = 
DataType::Timestamp(crate::spec::TimestampType::new(6).unwrap());
+        let fields = vec![crate::spec::DataField::new(0, "ts".into(), 
ts_type.clone())];
+        let indices = vec![0];
+
+        // Single-row extraction path (`extract_datum_from_arrow`).
+        let datum = extract_datum_from_arrow(&batch, 0, 0, &ts_type)
+            .unwrap()
+            .unwrap();
+        assert_eq!(
+            datum,
+            Datum::Timestamp {
+                millis: -1,
+                nanos: 999_000,
+            },
+            "extract_datum_from_arrow should normalize negative 
sub-millisecond timestamps"
+        );
+
+        // Batch write path must persist the same euclidean parts so that a
+        // pushed-down literal compares equal to the stored value.
+        let row = BinaryRow::from_arrow(&batch, 0, &indices, &fields).unwrap();
+        let (millis, nanos) = row.get_timestamp_raw(0, 6).unwrap();
+        assert_eq!(
+            (millis, nanos),
+            (-1, 999_000),
+            "binary-row write path must store euclidean timestamp parts"
+        );
+    }
 }

Reply via email to