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 f2b1b552 fix(spec): encode TIME columns in the batch BinaryRow path
(#823)
f2b1b552 is described below
commit f2b1b552a2ee44edee8128c630bc1f594b404202
Author: jackylee <[email protected]>
AuthorDate: Thu Sep 17 16:07:28 2026 +0800
fix(spec): encode TIME columns in the batch BinaryRow path (#823)
---
crates/paimon/src/spec/binary_row.rs | 40 +++++++++-
crates/paimon/src/table/table_write.rs | 129 +++++++++++++++++++++++++++++++++
2 files changed, 166 insertions(+), 3 deletions(-)
diff --git a/crates/paimon/src/spec/binary_row.rs
b/crates/paimon/src/spec/binary_row.rs
index 2c2e2f1a..17c4820a 100644
--- a/crates/paimon/src/spec/binary_row.rs
+++ b/crates/paimon/src/spec/binary_row.rs
@@ -1332,6 +1332,7 @@ enum TypedColumn<'a> {
Utf8View(&'a arrow_array::StringViewArray),
LargeUtf8(&'a arrow_array::LargeStringArray),
Date32(&'a arrow_array::Date32Array),
+ Time32Ms(&'a arrow_array::Time32MillisecondArray),
Decimal128(&'a arrow_array::Decimal128Array, u32, u32), // (array,
precision, scale)
Binary(&'a arrow_array::BinaryArray),
Variant(&'a arrow_array::StructArray),
@@ -1408,6 +1409,18 @@ fn downcast_columns<'a>(
.downcast_ref()
.ok_or_else(|| type_mismatch_err("Date", col_idx))?,
),
+ // Java writes TIME with the same `writeInt` as INTEGER and
DATE
+ // (`BinaryWriter`), so in the binary format a TIME *is* an int
+ // millis-of-day and no precision can add detail. Hence one arm
+ // rather than the `match precision` the `Timestamp` arm below
+ // needs; `paimon_type_to_arrow` maps every precision to
+ // `Time32(Millisecond)` and the batch is validated against the
+ // schema built from it, so that is what arrives here.
+ DataType::Time(_) => TypedColumn::Time32Ms(
+ col.as_any()
+ .downcast_ref()
+ .ok_or_else(|| type_mismatch_err("Time", col_idx))?,
+ ),
DataType::Decimal(d) => TypedColumn::Decimal128(
col.as_any()
.downcast_ref()
@@ -1587,6 +1600,16 @@ fn write_typed_value(
builder.write_int(pos, arr.value(row_idx));
}
}
+ // Java keeps TIME in the fixed-length part and writes it with
`writeInt`
+ // (`BinaryWriter#write` -> `BinaryRowWriter#writeInt`), so the four
bytes and
+ // the zeroed upper half of the slot have to match for `hash_code`
parity.
+ TypedColumn::Time32Ms(arr) => {
+ if arr.is_null(row_idx) {
+ builder.set_null_at(pos);
+ } else {
+ builder.write_int(pos, arr.value(row_idx));
+ }
+ }
TypedColumn::Decimal128(arr, precision, _scale) => {
if arr.is_null(row_idx) {
builder.set_null_at(pos);
@@ -2299,19 +2322,25 @@ mod tests {
#[test]
fn test_batch_vs_per_row_equivalence() {
- use arrow_array::{Int32Array, StringArray};
- use arrow_schema::{DataType as ArrowDT, Field, Schema};
+ use arrow_array::{Int32Array, StringArray, Time32MillisecondArray};
+ use arrow_schema::{DataType as ArrowDT, Field, Schema, TimeUnit};
use std::sync::Arc;
let schema = Arc::new(Schema::new(vec![
Field::new("id", ArrowDT::Int32, true),
Field::new("name", ArrowDT::Utf8, true),
+ Field::new("tm", ArrowDT::Time32(TimeUnit::Millisecond), true),
]));
let batch = RecordBatch::try_new(
schema,
vec![
Arc::new(Int32Array::from(vec![Some(1), None, Some(3)])),
Arc::new(StringArray::from(vec![Some("hello"), Some("world"),
None])),
+ Arc::new(Time32MillisecondArray::from(vec![
+ Some(45_296_123),
+ None,
+ Some(0),
+ ])),
],
)
.unwrap();
@@ -2323,8 +2352,13 @@ mod tests {
"name".into(),
DataType::VarChar(crate::spec::VarCharType::string_type()),
),
+ crate::spec::DataField::new(
+ 2,
+ "tm".into(),
+ DataType::Time(crate::spec::TimeType::new(3).unwrap()),
+ ),
];
- let indices = vec![0, 1];
+ let indices = vec![0, 1, 2];
// Batch results
let batch_bytes = batch_to_serialized_bytes(&batch, &indices,
&fields).unwrap();
diff --git a/crates/paimon/src/table/table_write.rs
b/crates/paimon/src/table/table_write.rs
index 1a9c9a70..835b3df9 100644
--- a/crates/paimon/src/table/table_write.rs
+++ b/crates/paimon/src/table/table_write.rs
@@ -2634,6 +2634,135 @@ pub(in crate::table) mod tests {
assert_eq!(total_rows, 4);
}
+ /// TIME as an append table's `bucket-key`. Java allows it:
`validateBucket`
+ /// rejects only ARRAY, MULTISET, MAP and ROW there. Routing has to agree
with
+ /// `BinaryRow::hash_code`, so the bucket is pinned against the row the
per-row
+ /// encoder produces rather than against a hard-coded number.
+ #[tokio::test]
+ async fn test_time_bucket_key_routes_by_binary_row_hash() {
+ let file_io = test_file_io();
+ let table_path = "memory:/test_time_bucket_key";
+ setup_dirs(&file_io, table_path).await;
+
+ let time_type = DataType::Time(TimeType::new(3).unwrap());
+ let schema = Schema::builder()
+ .column("tm", time_type.clone())
+ .column("value", DataType::Int(IntType::new()))
+ .option("bucket", "4")
+ .option("bucket-key", "tm")
+ .build()
+ .unwrap();
+ let table = Table::new(
+ file_io.clone(),
+ Identifier::new("default", "test_time_bucket_key_table"),
+ table_path.to_string(),
+ TableSchema::new(0, &schema),
+ None,
+ );
+
+ // 12:34:56.123 and midnight.
+ let times = [45_296_123_i32, 0];
+ let arrow_schema = Arc::new(ArrowSchema::new(vec![
+ ArrowField::new("tm",
ArrowDataType::Time32(TimeUnit::Millisecond), true),
+ ArrowField::new("value", ArrowDataType::Int32, true),
+ ]));
+ let batch = RecordBatch::try_new(
+ arrow_schema,
+ vec![
+
Arc::new(Time32MillisecondArray::from(times.map(Some).to_vec())),
+ Arc::new(Int32Array::from(vec![Some(10), Some(20)])),
+ ],
+ )
+ .unwrap();
+
+ let fields = table.schema().fields().to_vec();
+ let mut table_write = TableWrite::new(&table,
"test-user".to_string()).unwrap();
+ let output = table_write
+ .bucket_assigner
+ .assign_batch(&batch, &fields)
+ .await
+ .unwrap();
+
+ let expected: Vec<i32> = times
+ .iter()
+ .map(|&millis| {
+ let row =
BinaryRow::from_datums(&[(Some(&Datum::Time(millis)), &time_type)]);
+ // Mirrors `default_bucket`: `(hash % n).abs()`, which is
Java's
+ // `Math.abs(hashcode % numBuckets)` and is *not* a euclidean
+ // remainder for negative hashes.
+ (row.hash_code() % 4).wrapping_abs()
+ })
+ .collect();
+ assert_eq!(output.buckets, expected);
+
+ // And the write itself must land, not just the routing.
+ table_write.write_arrow_batch(&batch).await.unwrap();
+ let messages = table_write.prepare_commit().await.unwrap();
+ let rows: i64 = messages
+ .iter()
+ .flat_map(|m| m.new_files.iter())
+ .map(|f| f.row_count)
+ .sum();
+ assert_eq!(rows, 2);
+ }
+
+ /// TIME as a partition key. `partition_utils` already renders TIME
partition
+ /// values, but that code was unreachable from the write path while the
batch
+ /// encoder rejected the column, so this is the first test that exercises
the
+ /// two together — hence the assertion on the rendered path, not just the
row.
+ #[tokio::test]
+ async fn test_time_partition_key_writes_formatted_partition() {
+ let file_io = test_file_io();
+ let table_path = "memory:/test_time_partition_key";
+ setup_dirs(&file_io, table_path).await;
+
+ let schema = Schema::builder()
+ .column("tm", DataType::Time(TimeType::new(3).unwrap()))
+ .column("value", DataType::Int(IntType::new()))
+ .partition_keys(["tm"])
+ .build()
+ .unwrap();
+ let table = Table::new(
+ file_io.clone(),
+ Identifier::new("default", "test_time_partition_table"),
+ table_path.to_string(),
+ TableSchema::new(0, &schema),
+ None,
+ );
+ let mut table_write = TableWrite::new(&table,
"test-user".to_string()).unwrap();
+
+ let arrow_schema = Arc::new(ArrowSchema::new(vec![
+ ArrowField::new("tm",
ArrowDataType::Time32(TimeUnit::Millisecond), true),
+ ArrowField::new("value", ArrowDataType::Int32, true),
+ ]));
+ let batch = RecordBatch::try_new(
+ arrow_schema,
+ vec![
+ Arc::new(Time32MillisecondArray::from(vec![Some(45_296_123)])),
+ Arc::new(Int32Array::from(vec![Some(10)])),
+ ],
+ )
+ .unwrap();
+
+ table_write.write_arrow_batch(&batch).await.unwrap();
+ let messages = table_write.prepare_commit().await.unwrap();
+ assert_eq!(messages.len(), 1);
+ let partition =
BinaryRow::from_serialized_bytes(&messages[0].partition).unwrap();
+ assert_eq!(partition.get_int(0).unwrap(), 45_296_123);
+
+ let computer = PartitionComputer::new(
+ table.schema().partition_keys(),
+ table.schema().fields(),
+ "__DEFAULT_PARTITION__",
+ false,
+ )
+ .unwrap();
+ assert_eq!(
+ computer.generate_partition_path(&partition).unwrap(),
+ "tm=12%3A34%3A56.123/"
+ );
+ }
+
fn test_bucketed_schema() -> TableSchema {
let schema = Schema::builder()
.column("id", DataType::Int(IntType::new()))