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 42392f50 fix(arrow): scale INTEGER/BIGINT epoch seconds evolved to
TIMESTAMP (#924)
42392f50 is described below
commit 42392f502403847b206bbc23255539b62eb56091
Author: jackylee <[email protected]>
AuthorDate: Thu Sep 24 10:32:29 2026 +0800
fix(arrow): scale INTEGER/BIGINT epoch seconds evolved to TIMESTAMP (#924)
---
crates/paimon/src/arrow/nested_evolution.rs | 120 +++++++++++++++++++++++++++-
1 file changed, 117 insertions(+), 3 deletions(-)
diff --git a/crates/paimon/src/arrow/nested_evolution.rs
b/crates/paimon/src/arrow/nested_evolution.rs
index 1a65cd38..df71d925 100644
--- a/crates/paimon/src/arrow/nested_evolution.rs
+++ b/crates/paimon/src/arrow/nested_evolution.rs
@@ -33,7 +33,9 @@
use std::sync::Arc;
-use arrow_array::{new_null_array, Array, ArrayRef, ListArray, MapArray,
StringArray, StructArray};
+use arrow_array::{
+ new_null_array, Array, ArrayRef, Int64Array, ListArray, MapArray,
StringArray, StructArray,
+};
use arrow_cast::cast;
use arrow_schema::{DataType as ArrowDataType, Field as ArrowField, Fields};
@@ -107,6 +109,22 @@ pub(crate) fn evolve_column(
_ => {}
}
+ // Java `NumericPrimitiveToTimestamp` reads an INTEGER/BIGINT column as
epoch
+ // *seconds* (`value.longValue() * 1000` millis, fed to `toLocalDateTime`);
+ // Arrow's Int64->Timestamp cast instead reinterprets the raw value
directly
+ // in the column's unit, so a BIGINT epoch column evolved to TIMESTAMP(6)
+ // would read 10^6x too small (~1970). Scale seconds into the target unit
+ // first. Scoped to INTEGER/BIGINT into TIMESTAMP *without* local time
zone:
+ // Java's `create()` returns null for every other numeric source, and its
+ // TIMESTAMP-with-local-zone target resolves the offset with
+ // `ZoneId.systemDefault()` (environment dependent) -- both are left to the
+ // Arrow fallthrough below rather than guessed at here.
+ if matches!(source_type, DataType::Int(_) | DataType::BigInt(_)) {
+ if let DataType::Timestamp(ts) = target_type {
+ return numeric_epoch_seconds_to_timestamp(source, &target_arrow,
ts.precision());
+ }
+ }
+
cast(source, &target_arrow).map_err(|e| crate::Error::UnexpectedError {
message: format!(
"failed to cast nested value from {:?} to {:?} during schema
evolution",
@@ -117,6 +135,49 @@ pub(crate) fn evolve_column(
})
}
+/// Evolve an INTEGER/BIGINT column into a TIMESTAMP (without local time zone)
+/// the way Java `NumericPrimitiveToTimestamp` does: interpret each value as
+/// epoch seconds and scale it into the target time unit. Nulls are preserved,
+/// and a value that overflows `i64` in the target unit becomes NULL, matching
+/// the safe-cast overflow behavior of the surrounding evolution path.
+fn numeric_epoch_seconds_to_timestamp(
+ source: &ArrayRef,
+ target_arrow: &ArrowDataType,
+ precision: u32,
+) -> crate::Result<ArrayRef> {
+ // Ticks per second for the target unit (precision buckets match
+ // `paimon_type_to_arrow`'s `timestamp_time_unit`).
+ let ticks_per_second: i64 = match precision {
+ 0 => 1,
+ 1..=3 => 1_000,
+ 4..=6 => 1_000_000,
+ _ => 1_000_000_000,
+ };
+ let as_i64 =
+ cast(source, &ArrowDataType::Int64).map_err(|e|
crate::Error::UnexpectedError {
+ message: format!(
+ "failed to widen {:?} to Int64 before scaling epoch seconds to
timestamp",
+ source.data_type()
+ ),
+ source: Some(Box::new(e)),
+ })?;
+ let seconds = as_i64
+ .as_any()
+ .downcast_ref::<Int64Array>()
+ .expect("cast to Int64 yields an Int64Array");
+ // Scale seconds into the unit; a null stays null and an overflow becomes
null.
+ let scaled: Int64Array = seconds
+ .iter()
+ .map(|value| value.and_then(|v| v.checked_mul(ticks_per_second)))
+ .collect();
+ // Reinterpret the already-scaled value straight into the timestamp unit
+ // (Arrow's Int64->Timestamp copies it verbatim, no further scaling).
+ cast(&(Arc::new(scaled) as ArrayRef), target_arrow).map_err(|e|
crate::Error::UnexpectedError {
+ message: format!("failed to build {target_arrow:?} from scaled epoch
seconds"),
+ source: Some(Box::new(e)),
+ })
+}
+
/// Match Paimon's constructed-value string form, including nested values and
/// NULL containers. Arrow's generic cast cannot convert ROW/ARRAY/MAP to Utf8.
fn render_string_values(
@@ -480,9 +541,12 @@ fn rebuild_map(
mod tests {
use super::*;
use crate::spec::{
- ArrayType, BigIntType, DataField, DecimalType, IntType, MapType,
VarCharType,
+ ArrayType, BigIntType, DataField, DecimalType, IntType, MapType,
TimestampType, VarCharType,
+ };
+ use arrow_array::{
+ Decimal128Array, Int32Array, Int64Array, StringArray,
TimestampMicrosecondArray,
+ TimestampMillisecondArray, TimestampSecondArray,
};
- use arrow_array::{Decimal128Array, Int32Array, Int64Array, StringArray};
use arrow_buffer::NullBuffer;
use arrow_schema::{DataType as ArrowDataType, Fields};
@@ -658,6 +722,56 @@ mod tests {
assert_eq!(inner_out.column_by_name("b").unwrap().null_count(), 2);
}
+ #[test]
+ fn evolves_bigint_epoch_seconds_to_timestamp_scaled_to_the_column_unit() {
+ // A BIGINT column holding epoch *seconds* (Java's
NumericPrimitiveToTimestamp
+ // reads INTEGER/BIGINT that way) evolved to TIMESTAMP must be scaled
into
+ // the column's unit, not reinterpreted raw. 1_700_000_000 s is
2023-11-14.
+ let secs = 1_700_000_000_i64;
+ let source: ArrayRef = Arc::new(Int64Array::from(vec![Some(secs),
None]));
+ let source_type = DataType::BigInt(BigIntType::new());
+
+ let ts6 = DataType::Timestamp(TimestampType::new(6).unwrap());
+ let micros = evolve_column(&source, &source_type, &ts6).unwrap();
+ assert_eq!(micros.data_type(), &paimon_type_to_arrow(&ts6).unwrap());
+ let micros = micros
+ .as_any()
+ .downcast_ref::<TimestampMicrosecondArray>()
+ .expect("microsecond timestamps");
+ assert_eq!(micros.value(0), secs * 1_000_000);
+ assert!(micros.is_null(1));
+
+ let ts0 = DataType::Timestamp(TimestampType::new(0).unwrap());
+ let seconds = evolve_column(&source, &source_type, &ts0).unwrap();
+ let seconds = seconds
+ .as_any()
+ .downcast_ref::<TimestampSecondArray>()
+ .expect("second timestamps");
+ assert_eq!(seconds.value(0), secs);
+
+ let ts3 = DataType::Timestamp(TimestampType::new(3).unwrap());
+ let millis = evolve_column(&source, &source_type, &ts3).unwrap();
+ let millis = millis
+ .as_any()
+ .downcast_ref::<TimestampMillisecondArray>()
+ .expect("millisecond timestamps");
+ assert_eq!(millis.value(0), secs * 1_000);
+ }
+
+ #[test]
+ fn evolves_int_epoch_seconds_to_timestamp_like_bigint() {
+ // Java switches INTEGER and BIGINT together in the same cast rule.
+ let secs = 1_700_000_000_i32;
+ let source: ArrayRef = Arc::new(Int32Array::from(vec![Some(secs)]));
+ let ts6 = DataType::Timestamp(TimestampType::new(6).unwrap());
+ let out = evolve_column(&source, &DataType::Int(IntType::new()),
&ts6).unwrap();
+ let micros = out
+ .as_any()
+ .downcast_ref::<TimestampMicrosecondArray>()
+ .expect("microsecond timestamps");
+ assert_eq!(micros.value(0), secs as i64 * 1_000_000);
+ }
+
#[test]
fn preserves_null_rows() {
let codec: ArrayRef = Arc::new(StringArray::from(vec![Some("h264"),
None]));