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 d05cb03a fix: Map precision-zero timestamps to Arrow seconds (#884)
d05cb03a is described below
commit d05cb03ad8818f67ff827ad16a3fc570044668db
Author: Jingsong Lee <[email protected]>
AuthorDate: Sun Sep 20 15:36:50 2026 +0800
fix: Map precision-zero timestamps to Arrow seconds (#884)
---
crates/paimon/src/arrow/format/avro.rs | 13 +-
crates/paimon/src/arrow/format/mod.rs | 47 ++++
crates/paimon/src/arrow/format/mosaic.rs | 4 +-
crates/paimon/src/arrow/format/parquet.rs | 226 +++++++++++++++++-
crates/paimon/src/arrow/format/row.rs | 104 ++++++++-
crates/paimon/src/arrow/mod.rs | 17 +-
crates/paimon/src/arrow/partition.rs | 19 +-
crates/paimon/src/arrow/residual.rs | 243 +++++++++++++++++++-
crates/paimon/src/arrow/shredding/variant.rs | 8 +-
crates/paimon/src/spec/binary_row.rs | 44 +++-
crates/paimon/src/table/aggregator/numeric.rs | 55 +++--
crates/paimon/src/table/data_file_reader.rs | 315 +++++++++++++++++++++++++-
12 files changed, 1048 insertions(+), 47 deletions(-)
diff --git a/crates/paimon/src/arrow/format/avro.rs
b/crates/paimon/src/arrow/format/avro.rs
index 4757f0a0..6c17cbca 100644
--- a/crates/paimon/src/arrow/format/avro.rs
+++ b/crates/paimon/src/arrow/format/avro.rs
@@ -27,7 +27,7 @@ use arrow_array::{
BinaryArray, BooleanArray, Date32Array, Decimal128Array, Float32Array,
Float64Array,
Int16Array, Int32Array, Int64Array, Int8Array, LargeBinaryArray,
ListArray, MapArray,
RecordBatch, StringArray, StructArray, Time32MillisecondArray,
TimestampMicrosecondArray,
- TimestampMillisecondArray, TimestampNanosecondArray,
+ TimestampMillisecondArray, TimestampNanosecondArray, TimestampSecondArray,
};
use arrow_buffer::{BooleanBuffer, NullBuffer, OffsetBuffer, ScalarBuffer};
use arrow_schema::SchemaRef;
@@ -433,7 +433,16 @@ fn build_timestamp_column(
.map(|i| get_field_at(&records[i], idx).and_then(value_as_i64))
.collect();
match precision {
- 0..=3 =>
Arc::new(TimestampMillisecondArray::from(values).with_timezone_opt(tz)),
+ 0 => Arc::new(
+ TimestampSecondArray::from(
+ values
+ .into_iter()
+ .map(|value| value.map(|value| value.div_euclid(1_000)))
+ .collect::<Vec<_>>(),
+ )
+ .with_timezone_opt(tz),
+ ),
+ 1..=3 =>
Arc::new(TimestampMillisecondArray::from(values).with_timezone_opt(tz)),
4..=6 =>
Arc::new(TimestampMicrosecondArray::from(values).with_timezone_opt(tz)),
_ =>
Arc::new(TimestampNanosecondArray::from(values).with_timezone_opt(tz)),
}
diff --git a/crates/paimon/src/arrow/format/mod.rs
b/crates/paimon/src/arrow/format/mod.rs
index 4cd42a3c..b4ede7e6 100644
--- a/crates/paimon/src/arrow/format/mod.rs
+++ b/crates/paimon/src/arrow/format/mod.rs
@@ -331,6 +331,53 @@ pub(crate) async fn create_format_writer(
}
}
+fn timestamp_millis_schema(schema: &SchemaRef) -> SchemaRef {
+ let fields = schema
+ .fields()
+ .iter()
+ .map(timestamp_millis_field)
+ .collect::<Vec<_>>();
+ Arc::new(arrow_schema::Schema::new_with_metadata(
+ fields,
+ schema.metadata().clone(),
+ ))
+}
+
+fn timestamp_millis_field(field: &arrow_schema::FieldRef) ->
arrow_schema::FieldRef {
+ let data_type = timestamp_millis_data_type(field.data_type());
+ if &data_type == field.data_type() {
+ field.clone()
+ } else {
+ Arc::new(field.as_ref().clone().with_data_type(data_type))
+ }
+}
+
+fn timestamp_millis_data_type(data_type: &arrow_schema::DataType) ->
arrow_schema::DataType {
+ use arrow_schema::DataType as ArrowDataType;
+
+ match data_type {
+ ArrowDataType::Timestamp(arrow_schema::TimeUnit::Second, timezone) => {
+ ArrowDataType::Timestamp(arrow_schema::TimeUnit::Millisecond,
timezone.clone())
+ }
+ ArrowDataType::List(field) =>
ArrowDataType::List(timestamp_millis_field(field)),
+ ArrowDataType::LargeList(field) =>
ArrowDataType::LargeList(timestamp_millis_field(field)),
+ ArrowDataType::FixedSizeList(field, size) => {
+ ArrowDataType::FixedSizeList(timestamp_millis_field(field), *size)
+ }
+ ArrowDataType::Struct(fields) => ArrowDataType::Struct(
+ fields
+ .iter()
+ .map(timestamp_millis_field)
+ .collect::<Vec<_>>()
+ .into(),
+ ),
+ ArrowDataType::Map(field, sorted) => {
+ ArrowDataType::Map(timestamp_millis_field(field), *sorted)
+ }
+ _ => data_type.clone(),
+ }
+}
+
#[cfg(test)]
mod tests {
use super::*;
diff --git a/crates/paimon/src/arrow/format/mosaic.rs
b/crates/paimon/src/arrow/format/mosaic.rs
index fa0d8f93..2c5a2970 100644
--- a/crates/paimon/src/arrow/format/mosaic.rs
+++ b/crates/paimon/src/arrow/format/mosaic.rs
@@ -15,7 +15,7 @@
// specific language governing permissions and limitations
// under the License.
-use super::{FilePredicates, FormatFileReader};
+use super::{timestamp_millis_schema, FilePredicates, FormatFileReader};
use crate::arrow::build_target_arrow_schema;
use crate::arrow::filtering::{
predicates_may_match_with_schema, remap_predicates_to_file, StatsAccessor,
@@ -320,7 +320,7 @@ fn read_mosaic_batches_blocking(
row_filter_factory: None,
file_fields: existing_scan_fields.clone(),
});
- let read_schema = build_target_arrow_schema(&existing_scan_fields)?;
+ let read_schema =
timestamp_millis_schema(&build_target_arrow_schema(&existing_scan_fields)?);
validate_mosaic_schema(&read_schema)?;
let projected_names = existing_scan_fields
.iter()
diff --git a/crates/paimon/src/arrow/format/parquet.rs
b/crates/paimon/src/arrow/format/parquet.rs
index d4ddd584..a8ddca4b 100644
--- a/crates/paimon/src/arrow/format/parquet.rs
+++ b/crates/paimon/src/arrow/format/parquet.rs
@@ -16,7 +16,9 @@
// under the License.
use super::shredding::PhysicalFormatWriterFactory;
-use super::{FilePredicates, FormatFileReader, FormatFileWriter,
FormatWriteResult};
+use super::{
+ timestamp_millis_schema, FilePredicates, FormatFileReader,
FormatFileWriter, FormatWriteResult,
+};
use crate::arrow::filtering::{predicates_may_match_with_schema, StatsAccessor};
use crate::arrow::read_budget::ReadPermit;
use crate::arrow::shredding::map::MapShreddingReadPlan;
@@ -31,6 +33,7 @@ use crate::spec::{
use crate::table::{ArrowRecordBatchStream, RowRange};
use crate::Error;
use arrow_array::{BooleanArray, RecordBatch};
+use arrow_cast::cast;
use async_trait::async_trait;
use bytes::Bytes;
use futures::future::BoxFuture;
@@ -137,6 +140,8 @@ fn row_filter_required_bytes(
/// Streams data directly to storage via `AsyncArrowWriter` + opendal.
pub(crate) struct ParquetFormatWriter {
inner: AsyncArrowWriter<Box<dyn crate::io::AsyncFileWrite>>,
+ /// Logical Arrow schema accepted from the Paimon write path.
+ input_schema: arrow_schema::SchemaRef,
/// Physical Arrow schema the writer was created with; re-encoded with the
/// shredding field metadata at close time.
schema: arrow_schema::SchemaRef,
@@ -201,6 +206,8 @@ impl ParquetFormatWriter {
// Reject a bad codec before allocating the writer.
let codec = parse_compression(compression, zstd_level)?;
let async_write = output.async_writer().await?;
+ let input_schema = schema;
+ let schema = timestamp_millis_schema(&input_schema);
let inner = create_parquet_arrow_writer(async_write, schema.clone(),
codec)?;
let core_options = CoreOptions::new(format_options);
let stats_modes = write_fields
@@ -208,6 +215,7 @@ impl ParquetFormatWriter {
.transpose()?;
Ok(Self {
inner,
+ input_schema,
schema,
write_fields: write_fields.map(|fields| fields.to_vec()),
stats_modes,
@@ -289,6 +297,23 @@ fn parse_compression(codec: &str, zstd_level: i32) ->
crate::Result<Compression>
#[async_trait]
impl FormatFileWriter for ParquetFormatWriter {
async fn write(&mut self, batch: &RecordBatch) -> crate::Result<()> {
+ if batch.schema().as_ref() != self.input_schema.as_ref() {
+ return Err(crate::Error::DataInvalid {
+ message: format!(
+ "Parquet write batch schema {:?} does not match writer
schema {:?}",
+ batch.schema(),
+ self.input_schema
+ ),
+ source: None,
+ });
+ }
+ let physical_batch;
+ let batch = if self.input_schema.as_ref() == self.schema.as_ref() {
+ batch
+ } else {
+ physical_batch = cast_record_batch_to_schema(batch, &self.schema)?;
+ &physical_batch
+ };
self.inner
.write(batch)
.await
@@ -379,6 +404,48 @@ impl FormatFileWriter for ParquetFormatWriter {
}
}
+fn cast_record_batch_to_schema(
+ batch: &RecordBatch,
+ target_schema: &arrow_schema::SchemaRef,
+) -> crate::Result<RecordBatch> {
+ if batch.num_columns() != target_schema.fields().len() {
+ return Err(crate::Error::DataInvalid {
+ message: format!(
+ "Parquet write batch has {} columns, expected {}",
+ batch.num_columns(),
+ target_schema.fields().len()
+ ),
+ source: None,
+ });
+ }
+ let columns = batch
+ .columns()
+ .iter()
+ .zip(target_schema.fields())
+ .map(|(column, field)| {
+ if column.data_type() == field.data_type() {
+ Ok(column.clone())
+ } else {
+ cast(column, field.data_type()).map_err(|error|
crate::Error::DataInvalid {
+ message: format!(
+ "Failed to convert Parquet column '{}' from {:?} to
{:?}: {error}",
+ field.name(),
+ column.data_type(),
+ field.data_type()
+ ),
+ source: Some(Box::new(error)),
+ })
+ }
+ })
+ .collect::<crate::Result<Vec<_>>>()?;
+ RecordBatch::try_new(target_schema.clone(), columns).map_err(|error| {
+ crate::Error::DataInvalid {
+ message: format!("Failed to build Parquet physical batch:
{error}"),
+ source: Some(Box::new(error)),
+ }
+ })
+}
+
#[async_trait]
impl FormatFileReader for ParquetFormatReader {
async fn read_batch_stream(
@@ -2336,14 +2403,15 @@ mod tests {
use crate::arrow::{build_target_arrow_schema, variant_arrow_type,
ReadBudget};
use crate::io::FileIOBuilder;
use crate::spec::{
- ArrayType, BigIntType, DataField, DataType, Datum, IntType, MapType,
PredicateBuilder,
- VarCharType, VariantType,
+ ArrayType, BigIntType, DataField, DataType, Datum, IntType,
LocalZonedTimestampType,
+ MapType, PredicateBuilder, TimestampType, VarCharType, VariantType,
};
use crate::table::RowRange;
use crate::variant::GenericVariant;
use crate::Error;
use arrow_array::{
- Array, BinaryArray, Int32Array, Int64Array, MapArray, RecordBatch,
StringArray, StructArray,
+ Array, BinaryArray, Int32Array, Int64Array, MapArray, RecordBatch,
StringArray,
+ StructArray, TimestampMillisecondArray, TimestampSecondArray,
};
use arrow_buffer::{NullBuffer, OffsetBuffer, ScalarBuffer};
use arrow_schema::{DataType as ArrowDataType, Field as ArrowField, Schema
as ArrowSchema};
@@ -3136,6 +3204,156 @@ mod tests {
assert_eq!(total_rows, 5);
}
+ fn timestamp_zero_fields() -> Vec<DataField> {
+ vec![
+ DataField::new(
+ 0,
+ "ts".to_string(),
+ DataType::Timestamp(TimestampType::new(0).unwrap()),
+ ),
+ DataField::new(
+ 1,
+ "ts_ltz".to_string(),
+
DataType::LocalZonedTimestamp(LocalZonedTimestampType::new(0).unwrap()),
+ ),
+ ]
+ }
+
+ async fn assert_timestamp_zero_filters(bytes: Bytes, fields: &[DataField])
{
+ let cases = [
+ (
+ "ts",
+ Datum::Timestamp {
+ millis: 2_000,
+ nanos: 0,
+ },
+ ),
+ (
+ "ts_ltz",
+ Datum::LocalZonedTimestamp {
+ millis: 2_000,
+ nanos: 0,
+ },
+ ),
+ ];
+ for (column, literal) in cases {
+ let predicate = PredicateBuilder::new(fields)
+ .equal(column, literal)
+ .unwrap();
+ let predicates = FilePredicates {
+ predicates: vec![predicate],
+ row_filter_factory: None,
+ file_fields: fields.to_vec(),
+ };
+ let batches = ParquetFormatReader::default()
+ .read_batch_stream(
+ Box::new(TrackingFileRead::new(bytes.clone())),
+ bytes.len() as u64,
+ fields,
+ Some(&predicates),
+ None,
+ None,
+ )
+ .await
+ .unwrap()
+ .try_collect::<Vec<_>>()
+ .await
+ .unwrap();
+ assert_eq!(
+ batches.iter().map(RecordBatch::num_rows).sum::<usize>(),
+ 1,
+ "filter on {column}"
+ );
+ let array = batches[0]
+ .column_by_name(column)
+ .unwrap()
+ .as_any()
+ .downcast_ref::<TimestampMillisecondArray>()
+ .unwrap();
+ assert_eq!(array.value(0), 2_000, "filter on {column}");
+ }
+ }
+
+ #[tokio::test]
+ async fn
test_timestamp_zero_parquet_write_preserves_millis_encoding_and_filters() {
+ let fields = timestamp_zero_fields();
+ let schema = build_target_arrow_schema(&fields).unwrap();
+ let batch = RecordBatch::try_new(
+ schema.clone(),
+ vec![
+ Arc::new(TimestampSecondArray::from(vec![-1, 2, 3])),
+ Arc::new(TimestampSecondArray::from(vec![-1, 2,
3]).with_timezone("UTC")),
+ ],
+ )
+ .unwrap();
+ let file_io = FileIOBuilder::new("memory").build().unwrap();
+ let path = "memory:/timestamp_zero_millis_encoding.parquet";
+ let output = file_io.new_output(path).unwrap();
+ let mut writer: Box<dyn FormatFileWriter> = Box::new(
+ ParquetFormatWriter::new(&output, schema, "zstd", 1,
Some(&fields), &HashMap::new())
+ .await
+ .unwrap(),
+ );
+ writer.write(&batch).await.unwrap();
+ writer.close().await.unwrap();
+
+ let bytes = file_io.new_input(path).unwrap().read().await.unwrap();
+ let metadata = load_metadata_with_page_index(&bytes, false);
+ for (index, adjusted_to_utc) in [(0, false), (1, true)] {
+ let column = metadata.file_metadata().schema_descr().column(index);
+ assert_eq!(column.physical_type(), parquet::basic::Type::INT64);
+ assert_eq!(
+ column.logical_type_ref(),
+ Some(&parquet::basic::LogicalType::Timestamp {
+ is_adjusted_to_u_t_c: adjusted_to_utc,
+ unit: parquet::basic::TimeUnit::MILLIS,
+ })
+ );
+ let ParquetStatistics::Int64(stats) =
+ metadata.row_group(0).column(index).statistics().unwrap()
+ else {
+ panic!("timestamp column must have INT64 statistics");
+ };
+ assert_eq!(stats.min_opt(), Some(&-1_000));
+ assert_eq!(stats.max_opt(), Some(&3_000));
+ }
+ assert_timestamp_zero_filters(bytes, &fields).await;
+ }
+
+ #[tokio::test]
+ async fn test_existing_millis_timestamp_zero_parquet_filters() {
+ let fields = timestamp_zero_fields();
+ let schema = Arc::new(ArrowSchema::new(vec![
+ ArrowField::new(
+ "ts",
+ ArrowDataType::Timestamp(arrow_schema::TimeUnit::Millisecond,
None),
+ true,
+ ),
+ ArrowField::new(
+ "ts_ltz",
+ ArrowDataType::Timestamp(arrow_schema::TimeUnit::Millisecond,
Some("UTC".into())),
+ true,
+ ),
+ ]));
+ let batch = RecordBatch::try_new(
+ schema.clone(),
+ vec![
+ Arc::new(TimestampMillisecondArray::from(vec![-1_000, 2_000,
3_000])),
+ Arc::new(
+ TimestampMillisecondArray::from(vec![-1_000, 2_000, 3_000])
+ .with_timezone("UTC"),
+ ),
+ ],
+ )
+ .unwrap();
+ let mut bytes = Vec::new();
+ let mut writer = AsyncArrowWriter::try_new(&mut bytes, schema,
None).unwrap();
+ writer.write(&batch).await.unwrap();
+ writer.close().await.unwrap();
+
+ assert_timestamp_zero_filters(Bytes::from(bytes), &fields).await;
+ }
+
#[tokio::test]
async fn test_parquet_inline_fixed_size_list_roundtrip() {
use arrow_array::builder::{FixedSizeListBuilder, Float32Builder};
diff --git a/crates/paimon/src/arrow/format/row.rs
b/crates/paimon/src/arrow/format/row.rs
index 0c65645a..9419507e 100644
--- a/crates/paimon/src/arrow/format/row.rs
+++ b/crates/paimon/src/arrow/format/row.rs
@@ -33,13 +33,14 @@ use arrow_array::builder::{
BinaryBuilder, BooleanBuilder, Date32Builder, Decimal128Builder,
Float32Builder,
Float64Builder, Int16Builder, Int32Builder, Int64Builder, Int8Builder,
LargeBinaryBuilder,
StringBuilder, Time32MillisecondBuilder, TimestampMicrosecondBuilder,
- TimestampMillisecondBuilder, TimestampNanosecondBuilder,
+ TimestampMillisecondBuilder, TimestampNanosecondBuilder,
TimestampSecondBuilder,
};
use arrow_array::{
Array, ArrayRef, BinaryArray, BooleanArray, Date32Array, Decimal128Array,
Float32Array,
Float64Array, Int16Array, Int32Array, Int64Array, Int8Array,
LargeBinaryArray, ListArray,
MapArray, RecordBatch, RecordBatchOptions, StringArray, StructArray,
Time32MillisecondArray,
TimestampMicrosecondArray, TimestampMillisecondArray,
TimestampNanosecondArray,
+ TimestampSecondArray,
};
use arrow_buffer::{BooleanBuffer, NullBuffer, OffsetBuffer, ScalarBuffer};
use arrow_schema::{DataType as ArrowDataType, Field, Fields, SchemaRef,
TimeUnit};
@@ -507,7 +508,8 @@ fn validate_arrow_map_entries(
fn timestamp_time_unit_for_precision(precision: u32) -> TimeUnit {
match precision {
- 0..=3 => TimeUnit::Millisecond,
+ 0 => TimeUnit::Second,
+ 1..=3 => TimeUnit::Millisecond,
4..=6 => TimeUnit::Microsecond,
_ => TimeUnit::Nanosecond,
}
@@ -901,6 +903,16 @@ fn write_timestamp(
precision: u32,
) -> crate::Result<()> {
let (millis, nanos_of_milli) = match array.data_type() {
+ ArrowDataType::Timestamp(TimeUnit::Second, _) => (
+ downcast::<TimestampSecondArray>(array,
&DataType::Timestamp(Default::default()))?
+ .value(row_idx)
+ .checked_mul(1_000)
+ .ok_or_else(|| Error::DataInvalid {
+ message: ".row timestamp second conversion
overflow".to_string(),
+ source: None,
+ })?,
+ 0,
+ ),
ArrowDataType::Timestamp(TimeUnit::Millisecond, _) => (
downcast::<TimestampMillisecondArray>(array,
&DataType::Timestamp(Default::default()))?
.value(row_idx),
@@ -1173,6 +1185,7 @@ enum ColumnBuilder {
LargeBinary(LargeBinaryBuilder),
Date(Date32Builder),
Time(Time32MillisecondBuilder),
+ TimestampS(TimestampSecondBuilder),
TimestampMs(TimestampMillisecondBuilder),
TimestampUs(TimestampMicrosecondBuilder),
TimestampNs(TimestampNanosecondBuilder),
@@ -1313,6 +1326,7 @@ impl ColumnBuilder {
Self::LargeBinary(b) => b.append_null(),
Self::Date(b) => b.append_null(),
Self::Time(b) => b.append_null(),
+ Self::TimestampS(b) => b.append_null(),
Self::TimestampMs(b) => b.append_null(),
Self::TimestampUs(b) => b.append_null(),
Self::TimestampNs(b) => b.append_null(),
@@ -1372,6 +1386,9 @@ impl ColumnBuilder {
(Self::LargeBinary(b), DataType::Blob(_)) =>
b.append_value(input.read_bytes()?),
(Self::Date(b), DataType::Date(_)) =>
b.append_value(input.read_i32()?),
(Self::Time(b), DataType::Time(_)) =>
b.append_value(input.read_i32()?),
+ (Self::TimestampS(b), DataType::Timestamp(_) |
DataType::LocalZonedTimestamp(_)) => {
+ b.append_value(read_timestamp_value(input, TimeUnit::Second)?);
+ }
(Self::TimestampMs(b), DataType::Timestamp(_) |
DataType::LocalZonedTimestamp(_)) => {
b.append_value(read_timestamp_value(input,
TimeUnit::Millisecond)?);
}
@@ -1499,6 +1516,7 @@ impl ColumnBuilder {
Self::LargeBinary(mut b) => Arc::new(b.finish()),
Self::Date(mut b) => Arc::new(b.finish()),
Self::Time(mut b) => Arc::new(b.finish()),
+ Self::TimestampS(mut b) => Arc::new(b.finish()),
Self::TimestampMs(mut b) => Arc::new(b.finish()),
Self::TimestampUs(mut b) => Arc::new(b.finish()),
Self::TimestampNs(mut b) => Arc::new(b.finish()),
@@ -1750,7 +1768,15 @@ fn map_entries_field(
fn timestamp_builder(precision: u32, timezone: bool, capacity: usize) ->
ColumnBuilder {
match precision {
- 0..=3 => {
+ 0 => {
+ let builder = TimestampSecondBuilder::with_capacity(capacity);
+ if timezone {
+ ColumnBuilder::TimestampS(builder.with_timezone("UTC"))
+ } else {
+ ColumnBuilder::TimestampS(builder)
+ }
+ }
+ 1..=3 => {
let builder = TimestampMillisecondBuilder::with_capacity(capacity);
if timezone {
ColumnBuilder::TimestampMs(builder.with_timezone("UTC"))
@@ -1801,7 +1827,7 @@ fn read_timestamp_value(input: &mut BlockInput<'_>, unit:
TimeUnit) -> crate::Re
source: None,
})?
}
- TimeUnit::Second => millis / 1_000,
+ TimeUnit::Second => millis.div_euclid(1_000),
})
}
@@ -2341,8 +2367,9 @@ mod tests {
use crate::io::FileIOBuilder;
use crate::spec::{
ArrayType, BigIntType, BlobType, BooleanType, DataType, DateType,
Datum, DecimalType,
- DoubleType, FloatType, IntType, MapType, MultisetType, Predicate,
PredicateOperator,
- RowType, TimeType, TimestampType, VarBinaryType, VarCharType,
VariantType,
+ DoubleType, FloatType, IntType, LocalZonedTimestampType, MapType,
MultisetType, Predicate,
+ PredicateOperator, RowType, TimeType, TimestampType, VarBinaryType,
VarCharType,
+ VariantType,
};
use crate::variant::GenericVariant;
use futures::TryStreamExt;
@@ -2641,6 +2668,71 @@ mod tests {
assert_eq!(names.value(1), "ccc");
}
+ #[tokio::test]
+ async fn row_writer_reader_roundtrip_timestamp_zero_as_seconds() {
+ let file_io = FileIOBuilder::new("memory").build().unwrap();
+ let path = "memory:/row-timestamp-zero/data.row";
+ let output = file_io.new_output(path).unwrap();
+ let fields = vec![
+ DataField::new(
+ 0,
+ "ts".to_string(),
+ DataType::Timestamp(TimestampType::new(0).unwrap()),
+ ),
+ DataField::new(
+ 1,
+ "ts_ltz".to_string(),
+
DataType::LocalZonedTimestamp(LocalZonedTimestampType::new(0).unwrap()),
+ ),
+ ];
+ let schema = build_target_arrow_schema(&fields).unwrap();
+ let mut writer = RowFormatWriter::new(&output, schema.clone(),
fields.clone(), 1)
+ .await
+ .unwrap();
+ let batch = RecordBatch::try_new(
+ schema,
+ vec![
+ Arc::new(TimestampSecondArray::from(vec![Some(-1), Some(2),
None])),
+ Arc::new(
+ TimestampSecondArray::from(vec![Some(-1), Some(2),
None]).with_timezone("UTC"),
+ ),
+ ],
+ )
+ .unwrap();
+ writer.write(&batch).await.unwrap();
+ Box::new(writer).close().await.unwrap();
+
+ let input = file_io.new_input(path).unwrap();
+ let bytes = input.read().await.unwrap();
+ let actual = RowFormatReader
+ .read_batch_stream(
+ Box::new(BytesFileRead(bytes.clone())),
+ bytes.len() as u64,
+ &fields,
+ None,
+ Some(8),
+ None,
+ )
+ .await
+ .unwrap()
+ .try_collect::<Vec<_>>()
+ .await
+ .unwrap();
+
+ assert_eq!(actual.len(), 1);
+ assert_eq!(actual[0].schema().as_ref(), batch.schema().as_ref());
+ for column in 0..2 {
+ let values = actual[0]
+ .column(column)
+ .as_any()
+ .downcast_ref::<TimestampSecondArray>()
+ .unwrap();
+ assert_eq!(values.value(0), -1);
+ assert_eq!(values.value(1), 2);
+ assert!(values.is_null(2));
+ }
+ }
+
#[tokio::test]
async fn row_writer_reader_preserves_binary_and_large_binary_types() {
let file_io = FileIOBuilder::new("memory").build().unwrap();
diff --git a/crates/paimon/src/arrow/mod.rs b/crates/paimon/src/arrow/mod.rs
index 4792cdfb..637d6ee2 100644
--- a/crates/paimon/src/arrow/mod.rs
+++ b/crates/paimon/src/arrow/mod.rs
@@ -149,7 +149,8 @@ pub fn paimon_type_to_arrow(dt: &PaimonDataType) ->
crate::Result<ArrowDataType>
fn timestamp_time_unit(precision: u32) -> crate::Result<TimeUnit> {
match precision {
- 0..=3 => Ok(TimeUnit::Millisecond),
+ 0 => Ok(TimeUnit::Second),
+ 1..=3 => Ok(TimeUnit::Millisecond),
4..=6 => Ok(TimeUnit::Microsecond),
7..=9 => Ok(TimeUnit::Nanosecond),
_ => Err(crate::Error::Unsupported {
@@ -518,6 +519,15 @@ mod tests {
#[test]
fn test_timestamp_roundtrip() {
+ // second precision
+ let ts0 = PaimonDataType::Timestamp(TimestampType::new(0).unwrap());
+ assert_paimon_to_arrow(&ts0,
&ArrowDataType::Timestamp(TimeUnit::Second, None));
+ assert_arrow_to_paimon(
+ &ArrowDataType::Timestamp(TimeUnit::Second, None),
+ true,
+ &ts0,
+ );
+
// millisecond precision
let ts3 = PaimonDataType::Timestamp(TimestampType::new(3).unwrap());
assert_paimon_to_arrow(&ts3,
&ArrowDataType::Timestamp(TimeUnit::Millisecond, None));
@@ -548,6 +558,11 @@ mod tests {
#[test]
fn test_local_zoned_timestamp() {
+ let lzts0 =
PaimonDataType::LocalZonedTimestamp(LocalZonedTimestampType::new(0).unwrap());
+ let arrow0 = ArrowDataType::Timestamp(TimeUnit::Second,
Some("UTC".into()));
+ assert_paimon_to_arrow(&lzts0, &arrow0);
+ assert_arrow_to_paimon(&arrow0, true, &lzts0);
+
let lzts =
PaimonDataType::LocalZonedTimestamp(LocalZonedTimestampType::new(3).unwrap());
let arrow = ArrowDataType::Timestamp(TimeUnit::Millisecond,
Some("UTC".into()));
assert_paimon_to_arrow(&lzts, &arrow);
diff --git a/crates/paimon/src/arrow/partition.rs
b/crates/paimon/src/arrow/partition.rs
index 5e426cab..88c75118 100644
--- a/crates/paimon/src/arrow/partition.rs
+++ b/crates/paimon/src/arrow/partition.rs
@@ -24,7 +24,7 @@ use arrow_array::{
new_null_array, ArrayRef, BinaryArray, BooleanArray, Date32Array,
Decimal128Array,
Float32Array, Float64Array, Int16Array, Int32Array, Int64Array, Int8Array,
StringArray,
Time32MillisecondArray, TimestampMicrosecondArray,
TimestampMillisecondArray,
- TimestampNanosecondArray,
+ TimestampNanosecondArray, TimestampSecondArray,
};
use std::sync::Arc;
@@ -121,7 +121,15 @@ fn timestamp_array(
num_rows: usize,
) -> crate::Result<ArrayRef> {
let array: ArrayRef = match precision {
- 0..=3 => {
+ 0 => {
+ let value = millis.div_euclid(1_000);
+ let array = TimestampSecondArray::from(vec![Some(value);
num_rows]);
+ match timezone {
+ Some(tz) => Arc::new(array.with_timezone(tz)),
+ None => Arc::new(array),
+ }
+ }
+ 1..=3 => {
let array = TimestampMillisecondArray::from(vec![Some(millis);
num_rows]);
match timezone {
Some(tz) => Arc::new(array.with_timezone(tz)),
@@ -179,6 +187,13 @@ mod tests {
}),
DataType::LocalZonedTimestamp(LocalZonedTimestampType::new(9).unwrap()),
),
+ (
+ Some(Datum::LocalZonedTimestamp {
+ millis: -1_000,
+ nanos: 0,
+ }),
+
DataType::LocalZonedTimestamp(LocalZonedTimestampType::new(0).unwrap()),
+ ),
];
let datums: Vec<_> = values
.iter()
diff --git a/crates/paimon/src/arrow/residual.rs
b/crates/paimon/src/arrow/residual.rs
index 50d716c4..f1e5fe2a 100644
--- a/crates/paimon/src/arrow/residual.rs
+++ b/crates/paimon/src/arrow/residual.rs
@@ -52,7 +52,7 @@ use arrow_array::{
FixedSizeListArray, Float32Array, Float64Array, Int16Array, Int32Array,
Int64Array, Int8Array,
LargeBinaryArray, LargeListArray, ListArray, RecordBatch, Scalar,
StringArray,
Time32MillisecondArray, TimestampMicrosecondArray,
TimestampMillisecondArray,
- TimestampNanosecondArray,
+ TimestampNanosecondArray, TimestampSecondArray,
};
use arrow_ord::cmp::{
eq as arrow_eq, gt as arrow_gt, gt_eq as arrow_gt_eq, lt as arrow_lt,
lt_eq as arrow_lt_eq,
@@ -336,6 +336,19 @@ pub(crate) fn evaluate_exact_leaf_predicate(
) {
return evaluate_array_membership_predicate(array, data_type, op,
literals);
}
+ // TIMESTAMP(0) is exposed as Arrow seconds, while Parquet keeps Paimon's
+ // millisecond physical representation. Compare both representations in
+ // i128 nanoseconds so the decoded array's unit cannot disagree with the
+ // literal scalar and a fractional literal (for example 1.5 seconds) is not
+ // rounded down to the column precision.
+ if matches!(
+ data_type,
+ DataType::Timestamp(_) | DataType::LocalZonedTimestamp(_)
+ ) && matches!(array.data_type(), arrow_schema::DataType::Timestamp(_, _))
+ && !matches!(op, PredicateOperator::IsNull |
PredicateOperator::IsNotNull)
+ {
+ return evaluate_timestamp_leaf(array, data_type, op, literals);
+ }
// Decimals are compared by mathematical value across scales (Paimon
// `datum_cmp`/`decimal_cmp`). Arrow scalar comparison requires the
literal to
// be representable at the column scale, which fails for a finer-scale
literal
@@ -393,6 +406,152 @@ pub(crate) fn evaluate_exact_leaf_predicate(
}
}
+/// Evaluate timestamps independently of their decoded Arrow unit.
+///
+/// Paimon's [`Datum`] stores epoch milliseconds plus nanoseconds within that
+/// millisecond. Converting both the Arrow value and the literal to `i128`
+/// epoch nanoseconds is lossless for every Arrow timestamp unit and avoids the
+/// range loss that casting a seconds column to Arrow nanoseconds would cause.
+fn evaluate_timestamp_leaf(
+ array: &ArrayRef,
+ data_type: &DataType,
+ op: PredicateOperator,
+ literals: &[Datum],
+) -> Result<BooleanArray, ArrowError> {
+ use std::cmp::Ordering;
+
+ let local_zoned = match data_type {
+ DataType::Timestamp(_) => false,
+ DataType::LocalZonedTimestamp(_) => true,
+ _ => unreachable!("guarded by caller"),
+ };
+ let literal_nanos = |literal: &Datum| -> Result<i128, ArrowError> {
+ let (millis, nanos) = match (local_zoned, literal) {
+ (false, Datum::Timestamp { millis, nanos })
+ | (true, Datum::LocalZonedTimestamp { millis, nanos }) =>
(*millis, *nanos),
+ _ => {
+ return Err(ArrowError::ComputeError(
+ "timestamp column compared against a non-matching
timestamp literal"
+ .to_string(),
+ ))
+ }
+ };
+ if !(0..1_000_000).contains(&nanos) {
+ return Err(ArrowError::ComputeError(format!(
+ "timestamp nanos-of-millisecond is out of range: {nanos}"
+ )));
+ }
+ Ok(i128::from(millis) * 1_000_000 + i128::from(nanos))
+ };
+ let value_nanos = |row: usize| -> Result<i128, ArrowError> {
+ let value = match array.data_type() {
+ arrow_schema::DataType::Timestamp(arrow_schema::TimeUnit::Second,
_) => {
+ i128::from(
+ array
+ .as_any()
+ .downcast_ref::<TimestampSecondArray>()
+ .expect("Arrow timestamp unit matches array type")
+ .value(row),
+ ) * 1_000_000_000
+ }
+
arrow_schema::DataType::Timestamp(arrow_schema::TimeUnit::Millisecond, _) => {
+ i128::from(
+ array
+ .as_any()
+ .downcast_ref::<TimestampMillisecondArray>()
+ .expect("Arrow timestamp unit matches array type")
+ .value(row),
+ ) * 1_000_000
+ }
+
arrow_schema::DataType::Timestamp(arrow_schema::TimeUnit::Microsecond, _) => {
+ i128::from(
+ array
+ .as_any()
+ .downcast_ref::<TimestampMicrosecondArray>()
+ .expect("Arrow timestamp unit matches array type")
+ .value(row),
+ ) * 1_000
+ }
+
arrow_schema::DataType::Timestamp(arrow_schema::TimeUnit::Nanosecond, _) =>
i128::from(
+ array
+ .as_any()
+ .downcast_ref::<TimestampNanosecondArray>()
+ .expect("Arrow timestamp unit matches array type")
+ .value(row),
+ ),
+ other => {
+ return Err(ArrowError::ComputeError(format!(
+ "timestamp predicate expects an Arrow timestamp column,
got {other:?}"
+ )))
+ }
+ };
+ Ok(value)
+ };
+
+ let literal_values = literals
+ .iter()
+ .map(literal_nanos)
+ .collect::<Result<Vec<_>, _>>()?;
+ let mut result = Vec::with_capacity(array.len());
+ for row in 0..array.len() {
+ if array.is_null(row) {
+ result.push(false);
+ continue;
+ }
+ let value = value_nanos(row)?;
+ let keep = match op {
+ PredicateOperator::Eq
+ | PredicateOperator::NotEq
+ | PredicateOperator::Lt
+ | PredicateOperator::LtEq
+ | PredicateOperator::Gt
+ | PredicateOperator::GtEq => {
+ let literal = literal_values
+ .first()
+ .ok_or_else(|| unconvertible_literal_error(op,
data_type))?;
+ let ordering = value.cmp(literal);
+ match op {
+ PredicateOperator::Eq => ordering == Ordering::Equal,
+ PredicateOperator::NotEq => ordering != Ordering::Equal,
+ PredicateOperator::Lt => ordering == Ordering::Less,
+ PredicateOperator::LtEq => ordering != Ordering::Greater,
+ PredicateOperator::Gt => ordering == Ordering::Greater,
+ PredicateOperator::GtEq => ordering != Ordering::Less,
+ _ => unreachable!(),
+ }
+ }
+ PredicateOperator::In | PredicateOperator::NotIn => {
+ let contains = literal_values.contains(&value);
+ if matches!(op, PredicateOperator::In) {
+ contains
+ } else {
+ !contains
+ }
+ }
+ PredicateOperator::Between | PredicateOperator::NotBetween => {
+ let (Some(low), Some(high)) = (literal_values.first(),
literal_values.get(1))
+ else {
+ return Err(unconvertible_literal_error(op, data_type));
+ };
+ let within =
+ value.cmp(low) != Ordering::Less && value.cmp(high) !=
Ordering::Greater;
+ if matches!(op, PredicateOperator::Between) {
+ within
+ } else {
+ !within
+ }
+ }
+ _ => {
+ return Err(ArrowError::ComputeError(format!(
+ "operator {op:?} is not a timestamp value comparison"
+ )))
+ }
+ };
+ result.push(keep);
+ }
+ Ok(BooleanArray::from(result))
+}
+
fn evaluate_array_membership_predicate(
array: &ArrayRef,
data_type: &DataType,
@@ -545,6 +704,12 @@ fn evaluate_array_element_equality(
PredicateOperator::Eq,
std::slice::from_ref(literal),
)?)),
+ DataType::Timestamp(_) | DataType::LocalZonedTimestamp(_) =>
evaluate_timestamp_leaf(
+ values,
+ element_type,
+ PredicateOperator::Eq,
+ std::slice::from_ref(literal),
+ ),
_ => {
let Some(scalar) = literal_scalar_for_arrow_filter(literal,
element_type)
.map_err(|error| ArrowError::ComputeError(error.to_string()))?
@@ -1114,7 +1279,15 @@ fn timestamp_scalar(
timezone: Option<&'static str>,
) -> crate::Result<Option<ArrayRef>> {
let array: ArrayRef = match precision {
- 0..=3 => {
+ 0 => {
+ let value = millis.div_euclid(1_000);
+ let array = TimestampSecondArray::new_scalar(value).into_inner();
+ match timezone {
+ Some(tz) => Arc::new(array.with_timezone(tz)),
+ None => Arc::new(array),
+ }
+ }
+ 1..=3 => {
let array =
TimestampMillisecondArray::new_scalar(millis).into_inner();
match timezone {
Some(tz) => Arc::new(array.with_timezone(tz)),
@@ -1179,13 +1352,13 @@ fn float64_literal(literal: &Datum) -> Option<f64> {
mod tests {
use super::*;
use crate::spec::{
- row_id_leaf, ArrayType, DecimalType, DoubleType, FloatType, IntType,
VarCharType,
- ROW_ID_FIELD_NAME,
+ row_id_leaf, ArrayType, DecimalType, DoubleType, FloatType, IntType,
+ LocalZonedTimestampType, TimestampType, VarCharType, ROW_ID_FIELD_NAME,
};
use arrow_array::builder::{
Decimal128Builder, Float32Builder, Float64Builder, Int32Builder,
ListBuilder,
};
- use arrow_array::{Int32Array, StringArray};
+ use arrow_array::{Int32Array, StringArray, TimestampMillisecondArray,
TimestampSecondArray};
use arrow_schema::{DataType as ArrowDataType, Field as ArrowField, Schema
as ArrowSchema};
use std::sync::Arc;
@@ -1265,6 +1438,66 @@ mod tests {
mask.iter().map(|value| value.unwrap_or(false)).collect()
}
+ #[test]
+ fn
test_timestamp_zero_predicates_preserve_fractional_literals_and_physical_units()
{
+ let cases = [
+ (
+ Arc::new(TimestampSecondArray::from(vec![Some(1), Some(2)]))
as ArrayRef,
+ DataType::Timestamp(TimestampType::new(0).unwrap()),
+ Datum::Timestamp {
+ millis: 1_500,
+ nanos: 0,
+ },
+ ),
+ (
+ Arc::new(TimestampSecondArray::from(vec![Some(1),
Some(2)]).with_timezone("UTC"))
+ as ArrayRef,
+
DataType::LocalZonedTimestamp(LocalZonedTimestampType::new(0).unwrap()),
+ Datum::LocalZonedTimestamp {
+ millis: 1_500,
+ nanos: 0,
+ },
+ ),
+ ];
+
+ for (array, data_type, literal) in cases {
+ let equal = evaluate_exact_leaf_predicate(
+ &array,
+ &data_type,
+ PredicateOperator::Eq,
+ std::slice::from_ref(&literal),
+ )
+ .unwrap();
+ assert_eq!(bool_values(&equal), vec![false, false]);
+
+ let greater_or_equal = evaluate_exact_leaf_predicate(
+ &array,
+ &data_type,
+ PredicateOperator::GtEq,
+ std::slice::from_ref(&literal),
+ )
+ .unwrap();
+ assert_eq!(bool_values(&greater_or_equal), vec![false, true]);
+ }
+
+ let existing_millis: ArrayRef =
Arc::new(TimestampMillisecondArray::from(vec![
+ Some(-1_000),
+ Some(2_000),
+ Some(3_000),
+ ]));
+ let equal = evaluate_exact_leaf_predicate(
+ &existing_millis,
+ &DataType::Timestamp(TimestampType::new(0).unwrap()),
+ PredicateOperator::Eq,
+ &[Datum::Timestamp {
+ millis: 2_000,
+ nanos: 0,
+ }],
+ )
+ .unwrap();
+ assert_eq!(bool_values(&equal), vec![false, true, false]);
+ }
+
#[test]
fn test_array_membership_predicates_match_java_multivalue_semantics() {
let array = int_list_array();
diff --git a/crates/paimon/src/arrow/shredding/variant.rs
b/crates/paimon/src/arrow/shredding/variant.rs
index 85475be5..6246fc80 100644
--- a/crates/paimon/src/arrow/shredding/variant.rs
+++ b/crates/paimon/src/arrow/shredding/variant.rs
@@ -33,7 +33,7 @@ use arrow_array::{
new_null_array, Array, ArrayRef, BinaryArray, BooleanArray, Date32Array,
Decimal128Array,
Float32Array, Float64Array, Int16Array, Int32Array, Int64Array, Int8Array,
LargeBinaryArray,
ListArray, RecordBatch, StringArray, StructArray,
TimestampMicrosecondArray,
- TimestampMillisecondArray, TimestampNanosecondArray,
+ TimestampMillisecondArray, TimestampNanosecondArray, TimestampSecondArray,
};
use arrow_buffer::{BooleanBuffer, NullBuffer, OffsetBuffer, ScalarBuffer};
use arrow_schema::{DataType as ArrowDataType, Field as ArrowField, Fields,
TimeUnit};
@@ -1052,7 +1052,8 @@ fn timestamp_array(
})
.collect::<Vec<_>>();
Ok(match precision {
- 0..=3 =>
Arc::new(TimestampMillisecondArray::from(values).with_timezone_opt(tz)),
+ 0 =>
Arc::new(TimestampSecondArray::from(values).with_timezone_opt(tz)),
+ 1..=3 =>
Arc::new(TimestampMillisecondArray::from(values).with_timezone_opt(tz)),
4..=6 =>
Arc::new(TimestampMicrosecondArray::from(values).with_timezone_opt(tz)),
_ =>
Arc::new(TimestampNanosecondArray::from(values).with_timezone_opt(tz)),
})
@@ -1261,6 +1262,9 @@ fn value_at(array: &dyn Array, row: usize, data_type:
&DataType) -> Result<Optio
fn timestamp_value_at(array: &dyn Array, row: usize) -> Result<i64> {
match array.data_type() {
+ ArrowDataType::Timestamp(TimeUnit::Second, _) => {
+ Ok(downcast_array::<TimestampSecondArray>(array,
"TimestampS")?.value(row))
+ }
ArrowDataType::Timestamp(TimeUnit::Millisecond, _) => {
Ok(downcast_array::<TimestampMillisecondArray>(array,
"TimestampMs")?.value(row))
}
diff --git a/crates/paimon/src/spec/binary_row.rs
b/crates/paimon/src/spec/binary_row.rs
index 17c4820a..15b81289 100644
--- a/crates/paimon/src/spec/binary_row.rs
+++ b/crates/paimon/src/spec/binary_row.rs
@@ -1166,7 +1166,21 @@ fn extract_timestamp_parts_from_arrow(
expected: &str,
) -> crate::Result<(i64, i32)> {
match precision {
- 0..=3 => {
+ 0 => {
+ let arr = col
+ .as_any()
+ .downcast_ref::<arrow_array::TimestampSecondArray>()
+ .ok_or_else(|| type_mismatch_err(&format!("{expected}(s)"),
col_idx))?;
+ let millis =
+ arr.value(row_idx)
+ .checked_mul(1_000)
+ .ok_or_else(|| crate::Error::DataInvalid {
+ message: format!("{expected}(s) value overflows
milliseconds"),
+ source: None,
+ })?;
+ Ok((millis, 0))
+ }
+ 1..=3 => {
let arr = col
.as_any()
.downcast_ref::<arrow_array::TimestampMillisecondArray>()
@@ -1336,6 +1350,7 @@ enum TypedColumn<'a> {
Decimal128(&'a arrow_array::Decimal128Array, u32, u32), // (array,
precision, scale)
Binary(&'a arrow_array::BinaryArray),
Variant(&'a arrow_array::StructArray),
+ TimestampS(&'a arrow_array::TimestampSecondArray),
TimestampMs(&'a arrow_array::TimestampMillisecondArray),
TimestampUs(&'a arrow_array::TimestampMicrosecondArray),
TimestampNs(&'a arrow_array::TimestampNanosecondArray),
@@ -1442,7 +1457,12 @@ fn downcast_columns<'a>(
TypedColumn::Variant(arr)
}
DataType::Timestamp(ts) => match ts.precision() {
- 0..=3 => TypedColumn::TimestampMs(
+ 0 => TypedColumn::TimestampS(
+ col.as_any()
+ .downcast_ref()
+ .ok_or_else(|| type_mismatch_err("Timestamp(s)",
col_idx))?,
+ ),
+ 1..=3 => TypedColumn::TimestampMs(
col.as_any()
.downcast_ref()
.ok_or_else(|| type_mismatch_err("Timestamp(ms)",
col_idx))?,
@@ -1465,7 +1485,12 @@ fn downcast_columns<'a>(
},
DataType::LocalZonedTimestamp(ts) => {
match ts.precision() {
- 0..=3 =>
TypedColumn::TimestampMs(col.as_any().downcast_ref().ok_or_else(
+ 0 => {
+
TypedColumn::TimestampS(col.as_any().downcast_ref().ok_or_else(
+ || type_mismatch_err("LocalZonedTimestamp(s)",
col_idx),
+ )?)
+ }
+ 1..=3 =>
TypedColumn::TimestampMs(col.as_any().downcast_ref().ok_or_else(
|| type_mismatch_err("LocalZonedTimestamp(ms)",
col_idx),
)?),
4..=6 =>
TypedColumn::TimestampUs(col.as_any().downcast_ref().ok_or_else(
@@ -1660,6 +1685,19 @@ fn write_typed_value(
}
}
}
+ TypedColumn::TimestampS(arr) => {
+ if arr.is_null(row_idx) {
+ builder.set_null_at(pos);
+ } else {
+ let millis =
arr.value(row_idx).checked_mul(1_000).ok_or_else(|| {
+ crate::Error::DataInvalid {
+ message: "Timestamp(s) value overflows
milliseconds".to_string(),
+ source: None,
+ }
+ })?;
+ builder.write_timestamp_compact(pos, millis);
+ }
+ }
TypedColumn::TimestampMs(arr) => {
if arr.is_null(row_idx) {
builder.set_null_at(pos);
diff --git a/crates/paimon/src/table/aggregator/numeric.rs
b/crates/paimon/src/table/aggregator/numeric.rs
index 5546be3a..280eb2c7 100644
--- a/crates/paimon/src/table/aggregator/numeric.rs
+++ b/crates/paimon/src/table/aggregator/numeric.rs
@@ -45,7 +45,7 @@ use arrow_array::{
Array, ArrayRef, BinaryArray, BooleanArray, Date32Array, Decimal128Array,
Float32Array,
Float64Array, Int16Array, Int32Array, Int64Array, Int8Array, StringArray,
Time32MillisecondArray, TimestampMicrosecondArray,
TimestampMillisecondArray,
- TimestampNanosecondArray,
+ TimestampNanosecondArray, TimestampSecondArray,
};
use arrow_schema::{DataType as ArrowDataType, TimeUnit};
@@ -474,7 +474,8 @@ fn arrow_timestamp_timezone(
fn timestamp_time_unit(precision: u32) -> crate::Result<TimeUnit> {
match precision {
- 0..=3 => Ok(TimeUnit::Millisecond),
+ 0 => Ok(TimeUnit::Second),
+ 1..=3 => Ok(TimeUnit::Millisecond),
4..=6 => Ok(TimeUnit::Microsecond),
7..=9 => Ok(TimeUnit::Nanosecond),
other => Err(crate::Error::Unsupported {
@@ -601,17 +602,10 @@ fn agg_minmax(
MinMaxState::Date32(acc) => update_primitive!(acc, Date32Array),
MinMaxState::Time32Ms(acc) => update_primitive!(acc,
Time32MillisecondArray),
MinMaxState::Timestamp { unit, acc, .. } => match unit {
+ TimeUnit::Second => update_primitive!(acc, TimestampSecondArray),
TimeUnit::Millisecond => update_primitive!(acc,
TimestampMillisecondArray),
TimeUnit::Microsecond => update_primitive!(acc,
TimestampMicrosecondArray),
TimeUnit::Nanosecond => update_primitive!(acc,
TimestampNanosecondArray),
- other => {
- return Err(crate::Error::DataInvalid {
- message: format!(
- "Timestamp with unit {other:?} not expected for field
'{field_name}'"
- ),
- source: None,
- });
- }
},
MinMaxState::Utf8(acc) => {
let v = downcast::<StringArray>(array, field_name)?.value(row_idx);
@@ -690,6 +684,9 @@ fn minmax_result(state: &MinMaxState, agg_name: &str,
field_name: &str) -> crate
} => match unit {
// `with_timezone_opt` keeps the result array's Arrow type equal
to the
// field's, which `RecordBatch::try_new` checks.
+ TimeUnit::Second => {
+
Arc::new(TimestampSecondArray::from(vec![*acc]).with_timezone_opt(timezone.clone()))
+ }
TimeUnit::Millisecond => Arc::new(
TimestampMillisecondArray::from(vec![*acc]).with_timezone_opt(timezone.clone()),
),
@@ -699,14 +696,6 @@ fn minmax_result(state: &MinMaxState, agg_name: &str,
field_name: &str) -> crate
TimeUnit::Nanosecond => Arc::new(
TimestampNanosecondArray::from(vec![*acc]).with_timezone_opt(timezone.clone()),
),
- other => {
- return Err(crate::Error::DataInvalid {
- message: format!(
- "Timestamp with unit {other:?} not expected for field
'{field_name}'"
- ),
- source: None,
- });
- }
},
MinMaxState::Utf8(acc) =>
Arc::new(StringArray::from(vec![acc.clone()])),
MinMaxState::Binary(acc) =>
Arc::new(BinaryArray::from_opt_vec(vec![acc.as_deref()])),
@@ -1463,6 +1452,36 @@ mod tests {
arrow_array::RecordBatch::try_new(Arc::new(schema),
vec![max.result().unwrap()]).unwrap();
}
+ #[test]
+ fn test_min_max_timestamp_zero_uses_seconds() {
+ let dt = DataType::Timestamp(TimestampType::new(0).unwrap());
+ let arr = TimestampSecondArray::from(vec![Some(30), Some(10),
Some(20)]);
+ let mut min = min_agg(dt.clone());
+ let mut max = max_agg(dt);
+ for i in 0..arr.len() {
+ min.agg(&arr, i).unwrap();
+ max.agg(&arr, i).unwrap();
+ }
+ assert_eq!(
+ min.result()
+ .unwrap()
+ .as_any()
+ .downcast_ref::<TimestampSecondArray>()
+ .unwrap()
+ .value(0),
+ 10
+ );
+ assert_eq!(
+ max.result()
+ .unwrap()
+ .as_any()
+ .downcast_ref::<TimestampSecondArray>()
+ .unwrap()
+ .value(0),
+ 30
+ );
+ }
+
#[test]
fn test_min_max_date_and_timestamp_supported() {
// Date32
diff --git a/crates/paimon/src/table/data_file_reader.rs
b/crates/paimon/src/table/data_file_reader.rs
index d72703b0..0b3cb03e 100644
--- a/crates/paimon/src/table/data_file_reader.rs
+++ b/crates/paimon/src/table/data_file_reader.rs
@@ -1965,6 +1965,200 @@ mod tests {
.unwrap()
}
+ #[tokio::test]
+ async fn test_mosaic_timestamp_zero_read_filter_and_row_selection() {
+ use crate::spec::{LocalZonedTimestampType, MapType, TimestampType};
+ use arrow_array::builder::{ListBuilder, TimestampMillisecondBuilder};
+ use arrow_array::{ArrayRef, MapArray, StructArray,
TimestampMillisecondArray};
+ use arrow_buffer::{NullBuffer, OffsetBuffer, ScalarBuffer};
+ use arrow_schema::DataType as ArrowDataType;
+
+ let fields_at_precision = |precision| {
+ let timestamp =
DataType::Timestamp(TimestampType::new(precision).unwrap());
+ let timestamp_ltz =
+
DataType::LocalZonedTimestamp(LocalZonedTimestampType::new(precision).unwrap());
+ vec![
+ data_field(0, "ts", timestamp.clone()),
+ data_field(1, "ts_ltz", timestamp_ltz.clone()),
+ data_field(2, "timestamps",
DataType::Array(ArrayType::new(timestamp))),
+ data_field(
+ 3,
+ "timestamp_map",
+ DataType::Map(MapType::new(
+ DataType::VarChar(VarCharType::string_type()),
+ timestamp_ltz,
+ )),
+ ),
+ ]
+ };
+ let read_fields = fields_at_precision(0);
+ let output_schema = build_target_arrow_schema(&read_fields).unwrap();
+ let physical_schema =
build_target_arrow_schema(&fields_at_precision(3)).unwrap();
+ let ArrowDataType::List(element) =
physical_schema.field(2).data_type() else {
+ unreachable!()
+ };
+ let mut timestamps =
+
ListBuilder::new(TimestampMillisecondBuilder::new()).with_field(element.clone());
+ timestamps.values().append_value(-1_000);
+ timestamps.values().append_value(2_000);
+ timestamps.append(true);
+ timestamps.append(true);
+ timestamps.append(false);
+ timestamps.values().append_value(3_000);
+ timestamps.append(true);
+
+ let ArrowDataType::Map(entries_field, sorted) =
physical_schema.field(3).data_type() else {
+ unreachable!()
+ };
+ let ArrowDataType::Struct(entry_fields) = entries_field.data_type()
else {
+ unreachable!()
+ };
+ let entries = StructArray::try_new(
+ entry_fields.clone(),
+ vec![
+ Arc::new(StringArray::from(vec!["before", "after", "last"])),
+ Arc::new(
+ TimestampMillisecondArray::from(vec![-1_000, 2_000, 3_000])
+ .with_timezone("UTC"),
+ ),
+ ],
+ None,
+ )
+ .unwrap();
+ let timestamp_map = MapArray::try_new(
+ entries_field.clone(),
+ OffsetBuffer::new(ScalarBuffer::from(vec![0, 2, 2, 2, 3])),
+ entries,
+ Some(NullBuffer::from(vec![true, true, false, true])),
+ *sorted,
+ )
+ .unwrap();
+ let timestamps_millis = vec![Some(-1_000), Some(2_000), None,
Some(3_000)];
+ let physical_batch = RecordBatch::try_new(
+ physical_schema,
+ vec![
+
Arc::new(TimestampMillisecondArray::from(timestamps_millis.clone())),
+
Arc::new(TimestampMillisecondArray::from(timestamps_millis).with_timezone("UTC")),
+ Arc::new(timestamps.finish()),
+ Arc::new(timestamp_map),
+ ],
+ )
+ .unwrap();
+ let expected_columns = physical_batch
+ .columns()
+ .iter()
+ .zip(output_schema.fields())
+ .map(|(column, field)| arrow_cast::cast(column,
field.data_type()).unwrap())
+ .collect::<Vec<ArrayRef>>();
+ let data = write_mosaic(&physical_batch);
+ let file_io = FileIOBuilder::new("memory").build().unwrap();
+ let table_path = "memory:/mosaic_timestamp_zero";
+ let bucket_path = format!("{table_path}/bucket-0");
+ let file_name = "part-0.mosaic";
+ file_io
+ .new_output(&format!("{bucket_path}/{file_name}"))
+ .unwrap()
+ .write(data.clone())
+ .await
+ .unwrap();
+ let builder = PredicateBuilder::new(&read_fields);
+ let cases = vec![
+ (Vec::new(), None, vec![0, 1, 2, 3]),
+ (
+ vec![builder
+ .greater_or_equal(
+ "ts",
+ Datum::Timestamp {
+ millis: 1_500,
+ nanos: 0,
+ },
+ )
+ .unwrap()],
+ None,
+ vec![1, 3],
+ ),
+ (
+ vec![builder
+ .equal(
+ "ts_ltz",
+ Datum::LocalZonedTimestamp {
+ millis: -1_000,
+ nanos: 0,
+ },
+ )
+ .unwrap()],
+ None,
+ vec![0],
+ ),
+ (
+ vec![builder
+ .array_contains(
+ "timestamps",
+ Datum::Timestamp {
+ millis: 2_000,
+ nanos: 0,
+ },
+ )
+ .unwrap()],
+ None,
+ vec![0],
+ ),
+ (
+ Vec::new(),
+ Some(vec![RowRange::new(0, 0), RowRange::new(3, 3)]),
+ vec![0, 3],
+ ),
+ ];
+ for row_groups in [0, 2] {
+ for (predicates, row_ranges, expected_rows) in &cases {
+ let mut split = DataSplitBuilder::new()
+ .with_snapshot(1)
+ .with_partition(crate::spec::BinaryRow::new(0))
+ .with_bucket(0)
+ .with_bucket_path(bucket_path.clone())
+ .with_total_buckets(1)
+ .with_data_files(vec![data_file(file_name, data.len() as
i64, 4, 1)]);
+ if let Some(row_ranges) = row_ranges {
+ split = split.with_row_ranges(row_ranges.clone());
+ }
+ let batches = DataFileReader::new(
+ file_io.clone(),
+ SchemaManager::new(file_io.clone(),
table_path.to_string()),
+ 1,
+ read_fields.clone(),
+ read_fields.clone(),
+ predicates.clone(),
+ )
+ .with_mosaic_prefetch(MosaicPrefetchOptions {
+ row_groups,
+ ..Default::default()
+ })
+ .read(&[split.build().unwrap()])
+ .unwrap()
+ .try_collect::<Vec<_>>()
+ .await
+ .unwrap();
+ let actual =
+ arrow_select::concat::concat_batches(&output_schema,
&batches).unwrap();
+ let expected_indices =
arrow_array::UInt32Array::from(expected_rows.clone());
+ let expected = RecordBatch::try_new(
+ output_schema.clone(),
+ expected_columns
+ .iter()
+ .map(|column| {
+ arrow_select::take::take(column,
&expected_indices, None).unwrap()
+ })
+ .collect(),
+ )
+ .unwrap();
+ assert_eq!(
+ actual, expected,
+ "row_groups={row_groups}, predicates={predicates:?},
row_ranges={row_ranges:?}"
+ );
+ }
+ }
+ }
+
#[tokio::test]
async fn test_mosaic_physical_missing_column_is_null_filled() {
let physical_fields = vec![
@@ -2963,10 +3157,13 @@ mod vector_parquet_tests {
use crate::arrow::format::ParquetFormatWriter;
use crate::io::FileIOBuilder;
use crate::spec::stats::BinaryTableStats;
- use crate::spec::{DataFileMeta, DataType, FloatType, VectorType};
+ use crate::spec::{
+ DataFileMeta, DataType, Datum, FloatType, LocalZonedTimestampType,
PredicateBuilder,
+ TimestampType, VectorType,
+ };
use crate::table::source::DataSplitBuilder;
use arrow_array::builder::{FixedSizeListBuilder, Float32Builder};
- use arrow_array::{FixedSizeListArray, Float32Array, RecordBatch};
+ use arrow_array::{FixedSizeListArray, Float32Array, RecordBatch,
TimestampSecondArray};
use arrow_schema::{DataType as ArrowDataType, Field as ArrowField};
use futures::TryStreamExt;
@@ -3122,6 +3319,120 @@ mod vector_parquet_tests {
.expect("child should be Float32Array");
assert_eq!(floats2.values(), &[3.0, 4.0]);
}
+
+ #[tokio::test]
+ async fn test_datafilereader_timestamp_zero_parquet_roundtrip_and_filter()
{
+ let read_fields = vec![
+ DataField::new(
+ 0,
+ "ts".to_string(),
+ DataType::Timestamp(TimestampType::new(0).unwrap()),
+ ),
+ DataField::new(
+ 1,
+ "ts_ltz".to_string(),
+
DataType::LocalZonedTimestamp(LocalZonedTimestampType::new(0).unwrap()),
+ ),
+ ];
+ let arrow_schema = build_target_arrow_schema(&read_fields).unwrap();
+ let batch = RecordBatch::try_new(
+ arrow_schema.clone(),
+ vec![
+ Arc::new(TimestampSecondArray::from(vec![-1, 2, 3])),
+ Arc::new(TimestampSecondArray::from(vec![-1, 2,
3]).with_timezone("UTC")),
+ ],
+ )
+ .unwrap();
+
+ let file_io = FileIOBuilder::new("memory").build().unwrap();
+ let table_path = "memory:/timestamp_zero_parquet_e2e";
+ let bucket_path = format!("{table_path}/bucket-0");
+ let file_name = "part-0.parquet";
+ let file_path = format!("{bucket_path}/{file_name}");
+ let output = file_io.new_output(&file_path).unwrap();
+ let mut writer: Box<dyn FormatFileWriter> = Box::new(
+ ParquetFormatWriter::new(
+ &output,
+ arrow_schema.clone(),
+ "zstd",
+ 1,
+ Some(&read_fields),
+ &std::collections::HashMap::new(),
+ )
+ .await
+ .unwrap(),
+ );
+ writer.write(&batch).await.unwrap();
+ let file_size = writer.close().await.unwrap().file_size;
+
+ let schema_id = 1;
+ let split = DataSplitBuilder::new()
+ .with_snapshot(1)
+ .with_partition(crate::spec::BinaryRow::new(0))
+ .with_bucket(0)
+ .with_bucket_path(bucket_path)
+ .with_total_buckets(1)
+ .with_data_files(vec![data_file(file_name, file_size as i64, 3,
schema_id)])
+ .build()
+ .unwrap();
+ let predicates = vec![
+ PredicateBuilder::new(&read_fields)
+ .equal(
+ "ts",
+ Datum::Timestamp {
+ millis: 2_000,
+ nanos: 0,
+ },
+ )
+ .unwrap(),
+ PredicateBuilder::new(&read_fields)
+ .equal(
+ "ts_ltz",
+ Datum::LocalZonedTimestamp {
+ millis: 2_000,
+ nanos: 0,
+ },
+ )
+ .unwrap(),
+ ];
+ let reader = DataFileReader::new(
+ file_io.clone(),
+ SchemaManager::new(file_io, table_path.to_string()),
+ schema_id,
+ read_fields.clone(),
+ read_fields,
+ predicates,
+ );
+ let batches = reader
+ .read(&[split])
+ .unwrap()
+ .try_collect::<Vec<_>>()
+ .await
+ .unwrap();
+
+ assert_eq!(batches.len(), 1);
+ assert_eq!(batches[0].schema().as_ref(), arrow_schema.as_ref());
+ assert_eq!(
+ batches[0]
+ .column_by_name("ts")
+ .unwrap()
+ .as_any()
+ .downcast_ref::<TimestampSecondArray>()
+ .unwrap()
+ .values(),
+ &[2]
+ );
+ assert_eq!(
+ batches[0]
+ .column_by_name("ts_ltz")
+ .unwrap()
+ .as_any()
+ .downcast_ref::<TimestampSecondArray>()
+ .unwrap()
+ .values(),
+ &[2]
+ );
+ }
}
#[cfg(test)]