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 85f68e5c [core] Normalize Arrow layouts for upserts and data evolution 
updates (#1144)
85f68e5c is described below

commit 85f68e5c7d09893cc77cb357607053c6686c4760
Author: Jingsong Lee <[email protected]>
AuthorDate: Sat Oct 17 16:35:06 2026 +0800

    [core] Normalize Arrow layouts for upserts and data evolution updates 
(#1144)
---
 crates/paimon/src/table/data_evolution_writer.rs |  78 ++++++++-
 crates/paimon/src/table/table_upsert.rs          | 134 ++++++++++++++
 crates/paimon/src/table/write_batch_normalize.rs | 211 ++++++++++++++++++++++-
 3 files changed, 421 insertions(+), 2 deletions(-)

diff --git a/crates/paimon/src/table/data_evolution_writer.rs 
b/crates/paimon/src/table/data_evolution_writer.rs
index a2069eb8..446f7f94 100644
--- a/crates/paimon/src/table/data_evolution_writer.rs
+++ b/crates/paimon/src/table/data_evolution_writer.rs
@@ -1121,7 +1121,12 @@ fn apply_field_updates(
     matches: &[(usize, usize, usize)],
 ) -> Result<ArrayRef> {
     if update_columns.iter().any(|column| column == path) {
-        return interleave_updated_leaf(path, original, matched_batches, 
matches);
+        let updated = interleave_updated_leaf(path, original, matched_batches, 
matches)?;
+        // Readers allow runtime NULLs and relax nested field nullability.
+        // Restore the declared write layout, validating only visible children,
+        // before constructing a partial-column file with the table schema.
+        let target = 
crate::arrow::paimon_type_to_arrow(write_field.data_type())?;
+        return super::write_batch_normalize::normalize_write_array(&updated, 
&target);
     }
 
     let DataType::Row(row) = write_field.data_type() else {
@@ -1973,6 +1978,77 @@ mod tests {
             .contains("Ambiguous data-evolution write path"));
     }
 
+    #[test]
+    fn whole_row_updates_restore_declared_child_nullability() {
+        let write_field = DataField::new(
+            0,
+            "value".into(),
+            DataType::Row(RowType::new(vec![DataField::new(
+                1,
+                "text".into(),
+                
DataType::VarChar(crate::spec::VarCharType::with_nullable(false, 100).unwrap()),
+            )])),
+        );
+        let runtime_fields = vec![arrow_schema::Field::new("text", 
ArrowDataType::Utf8, true)];
+        let row = |values: Vec<Option<&str>>, validity: Option<Vec<bool>>| -> 
ArrayRef {
+            Arc::new(StructArray::new(
+                runtime_fields.clone().into(),
+                vec![Arc::new(StringArray::from(values))],
+                validity.map(NullBuffer::from),
+            ))
+        };
+        let original = row(vec![Some("old"), Some("keep")], None::<Vec<bool>>);
+        let updates = RecordBatch::try_from_iter([
+            (
+                "_ROW_ID",
+                Arc::new(Int64Array::from(vec![0, 1])) as ArrayRef,
+            ),
+            (
+                "value",
+                row(vec![None, Some("中文")], Some(vec![false, true])),
+            ),
+        ])
+        .unwrap();
+        let result = apply_field_updates(
+            &write_field,
+            "value",
+            &original,
+            &["value".into()],
+            &[updates],
+            &[(0, 0, 0), (1, 0, 1)],
+        )
+        .unwrap();
+        let expected = 
crate::arrow::paimon_type_to_arrow(write_field.data_type()).unwrap();
+        assert_eq!(result.data_type(), &expected);
+        let result = result.as_any().downcast_ref::<StructArray>().unwrap();
+        assert!(result.is_null(0));
+        assert_eq!(
+            result
+                .column(0)
+                .as_any()
+                .downcast_ref::<StringArray>()
+                .unwrap()
+                .value(1),
+            "中文"
+        );
+        // Required children are enforced for a visible ROW, including when
+        // Arrow's runtime read type had relaxed their nullability.
+        let invalid = RecordBatch::try_from_iter([
+            ("_ROW_ID", Arc::new(Int64Array::from(vec![0])) as ArrayRef),
+            ("value", row(vec![None], None::<Vec<bool>>)),
+        ])
+        .unwrap();
+        assert!(apply_field_updates(
+            &write_field,
+            "value",
+            &original,
+            &["value".into()],
+            &[invalid],
+            &[(0, 0, 0)]
+        )
+        .is_err());
+    }
+
     fn nested_profile_batch(
         fields: &[DataField],
         columns: Vec<ArrayRef>,
diff --git a/crates/paimon/src/table/table_upsert.rs 
b/crates/paimon/src/table/table_upsert.rs
index 769a9734..8869e527 100644
--- a/crates/paimon/src/table/table_upsert.rs
+++ b/crates/paimon/src/table/table_upsert.rs
@@ -406,6 +406,140 @@ mod tests {
     use super::*;
     use crate::spec::{DataType as PaimonType, DecimalType, TimestampType, 
VarCharType};
 
+    #[tokio::test]
+    async fn upsert_normalizes_string_offsets_before_matching_and_writing() {
+        use crate::catalog::Identifier;
+        use crate::io::FileIOBuilder;
+        use crate::spec::{IntType, Schema, TableSchema};
+        use crate::table::TableCommit;
+        use arrow_array::{Array, Int32Array, LargeStringArray, StringArray};
+
+        let schema = Schema::builder()
+            .column("key", PaimonType::VarChar(VarCharType::default()))
+            .column("value", PaimonType::VarChar(VarCharType::default()))
+            .column("keep", PaimonType::Int(IntType::new()))
+            .option("data-evolution.enabled", "true")
+            .option("row-tracking.enabled", "true")
+            .build()
+            .unwrap();
+        let table = Table::new(
+            FileIOBuilder::new("memory").build().unwrap(),
+            Identifier::new("db", "upsert"),
+            "memory:/upsert".into(),
+            TableSchema::new(0, &schema),
+            None,
+        );
+        let mut writer = table
+            .new_write_builder()
+            .with_commit_user("test")
+            .unwrap()
+            .new_write()
+            .unwrap();
+        let initial = RecordBatch::try_from_iter([
+            (
+                "key",
+                Arc::new(StringArray::from(vec![Some("中文"), Some("中文"), 
None])) as ArrayRef,
+            ),
+            (
+                "value",
+                Arc::new(StringArray::from(vec!["old"; 3])) as ArrayRef,
+            ),
+            (
+                "keep",
+                Arc::new(Int32Array::from(vec![10, 11, 12])) as ArrayRef,
+            ),
+        ])
+        .unwrap();
+        writer.write_arrow_batch(&initial).await.unwrap();
+        TableCommit::new(table.clone(), "seed".into())
+            .commit(writer.prepare_commit().await.unwrap())
+            .await
+            .unwrap();
+        writer.close().await;
+
+        let source = RecordBatch::try_from_iter([
+            (
+                "value",
+                Arc::new(LargeStringArray::from(vec![
+                    Some("discarded"),
+                    Some(""),
+                    None,
+                    Some("final"),
+                ])) as ArrayRef,
+            ),
+            (
+                "key",
+                Arc::new(LargeStringArray::from(vec![
+                    Some("中文"),
+                    Some("new"),
+                    None,
+                    Some("中文"),
+                ])) as ArrayRef,
+            ),
+        ])
+        .unwrap();
+        let mut updater = table.new_write_builder().new_update().unwrap();
+        updater.with_update_type(vec!["value".into()]).unwrap();
+        let messages = updater
+            .upsert_by_arrow_with_key(
+                vec![source.slice(0, 1), source.slice(1, 3)],
+                vec!["key".into()],
+            )
+            .await
+            .unwrap();
+        TableCommit::new(table.clone(), "upsert".into())
+            .commit(messages)
+            .await
+            .unwrap();
+        let mut reader = table.new_read_builder();
+        reader
+            .with_projection(&["key", "value", "keep", "_ROW_ID"])
+            .unwrap();
+        let plan = reader.new_scan().plan().await.unwrap();
+        let mut batches = 
reader.new_read().unwrap().to_arrow(plan.splits()).unwrap();
+        let mut rows = Vec::new();
+        while let Some(batch) = batches.try_next().await.unwrap() {
+            let keys = batch
+                .column(0)
+                .as_any()
+                .downcast_ref::<StringArray>()
+                .unwrap();
+            let values = batch
+                .column(1)
+                .as_any()
+                .downcast_ref::<StringArray>()
+                .unwrap();
+            let keep = batch
+                .column(2)
+                .as_any()
+                .downcast_ref::<Int32Array>()
+                .unwrap();
+            let ids = batch
+                .column(3)
+                .as_any()
+                .downcast_ref::<Int64Array>()
+                .unwrap();
+            rows.extend((0..batch.num_rows()).map(|row| {
+                (
+                    ids.value(row),
+                    (!keys.is_null(row)).then(|| keys.value(row).to_string()),
+                    (!values.is_null(row)).then(|| 
values.value(row).to_string()),
+                    (!keep.is_null(row)).then(|| keep.value(row)),
+                )
+            }));
+        }
+        rows.sort();
+        assert_eq!(
+            rows,
+            vec![
+                (0, Some("中文".into()), Some("final".into()), Some(10)),
+                (1, Some("中文".into()), Some("final".into()), Some(11)),
+                (2, None, None, Some(12)),
+                (3, Some("new".into()), Some("".into()), None),
+            ]
+        );
+    }
+
     #[test]
     fn partition_identity_ignores_java_null_variable_storage() {
         let fields = vec![
diff --git a/crates/paimon/src/table/write_batch_normalize.rs 
b/crates/paimon/src/table/write_batch_normalize.rs
index ad5966cc..e7de7ec7 100644
--- a/crates/paimon/src/table/write_batch_normalize.rs
+++ b/crates/paimon/src/table/write_batch_normalize.rs
@@ -26,7 +26,7 @@
 use crate::{Error, Result};
 use arrow_array::{
     Array, ArrayRef, BinaryArray, Decimal128Array, FixedSizeBinaryArray, 
FixedSizeListArray,
-    ListArray, MapArray, StringArray, StructArray,
+    LargeStringArray, ListArray, MapArray, StringArray, StructArray,
 };
 use arrow_buffer::{NullBuffer, OffsetBuffer, ScalarBuffer};
 use arrow_schema::{DataType, Field};
@@ -230,6 +230,11 @@ fn normalize_visible_array(
     }
 
     match (array.data_type(), expected) {
+        // Java has one STRING value representation. Arrow offset widths do
+        // not change the logical values, including in keys and nested fields.
+        (DataType::LargeUtf8, DataType::Utf8) => {
+            normalize_large_string(array.as_any().downcast_ref().unwrap())
+        }
         (DataType::FixedSizeBinary(_), DataType::Binary) => {
             let fixed = array
                 .as_any()
@@ -343,6 +348,34 @@ fn normalize_visible_array(
     }
 }
 
+/// Narrow only the visible byte range. A small slice may start beyond i32::MAX
+/// in its backing buffer, so casting the original absolute offsets is wrong.
+/// The value buffer remains shared and only the offset buffer is rebuilt.
+fn normalize_large_string(array: &LargeStringArray) -> Result<ArrayRef> {
+    let source = array.value_offsets();
+    let start = source[0];
+    let end = source[source.len() - 1];
+    let offsets = source
+        .iter()
+        .map(|offset| {
+            i32::try_from(offset - start).map_err(|_| Error::DataInvalid {
+                message: "Arrow STRING byte range exceeds 32-bit 
offsets".into(),
+                source: None,
+            })
+        })
+        .collect::<Result<Vec<_>>>()?;
+    let values = array
+        .values()
+        .slice_with_length(start as usize, (end - start) as usize);
+    StringArray::try_new(
+        OffsetBuffer::new(offsets.into()),
+        values,
+        array.nulls().cloned(),
+    )
+    .map(|array| Arc::new(array) as ArrayRef)
+    .map_err(normalization_error)
+}
+
 /// Keep only children referenced by visible parents. Ordinary inputs and
 /// slices share their payload buffers; only disjoint ranges across NULL
 /// parents need concatenation. Hidden children must not fail NOT NULL checks
@@ -421,6 +454,182 @@ mod tests {
     use arrow_buffer::{OffsetBuffer, ScalarBuffer};
     use arrow_schema::Field;
 
+    #[cfg(all(unix, target_pointer_width = "64"))]
+    #[test]
+    fn large_string_slices_after_two_gib_keep_only_the_visible_byte_range() {
+        use arrow_array::LargeStringArray;
+        use arrow_buffer::Buffer;
+        use std::ptr::NonNull;
+
+        struct Mapping {
+            address: usize,
+            len: usize,
+        }
+        impl Drop for Mapping {
+            fn drop(&mut self) {
+                // SAFETY: the mapping owns this mmap region until the last
+                // Arrow buffer using it is dropped.
+                unsafe { libc::munmap(self.address as *mut libc::c_void, 
self.len) };
+            }
+        }
+        let start = i32::MAX as usize + 17;
+        let tail = "abc中文tail".as_bytes();
+        let len = start + tail.len();
+        // Reserve virtual memory without allocating/touching a 2 GiB payload.
+        // Only the small visible tail is initialized and read by this test.
+        let address = unsafe {
+            libc::mmap(
+                std::ptr::null_mut(),
+                len,
+                libc::PROT_READ | libc::PROT_WRITE,
+                libc::MAP_PRIVATE | libc::MAP_ANON,
+                -1,
+                0,
+            )
+        };
+        assert_ne!(address, libc::MAP_FAILED);
+        let owner = Arc::new(Mapping {
+            address: address as usize,
+            len,
+        });
+        // SAFETY: mmap returned len initialized bytes; the tail fits within
+        // the mapping and all writes finish before publishing the buffer.
+        let values = unsafe {
+            std::ptr::copy_nonoverlapping(
+                tail.as_ptr(),
+                address.cast::<u8>().add(start),
+                tail.len(),
+            );
+            
Buffer::from_custom_allocation(NonNull::new(address.cast()).unwrap(), len, 
owner)
+        };
+        let start = start as i64;
+        let input = LargeStringArray::new(
+            OffsetBuffer::new(vec![start, start + 3, start + 3, start + 9, 
start + 13].into()),
+            values,
+            Some(NullBuffer::from(vec![true, false, true, true])),
+        );
+        let input: ArrayRef = Arc::new(input.slice(0, 3));
+        let result = normalize_write_array(&input, &DataType::Utf8).unwrap();
+        assert_eq!(
+            result.to_data(),
+            StringArray::from(vec![Some("abc"), None, Some("中文")]).to_data()
+        );
+        let result = result.as_any().downcast_ref::<StringArray>().unwrap();
+        assert_eq!(result.value_offsets(), &[0, 3, 3, 9]);
+        assert_eq!(result.values().as_ptr(), unsafe {
+            address.cast::<u8>().add(start as usize)
+        });
+        let empty = normalize_write_array(&input.slice(3, 0), 
&DataType::Utf8).unwrap();
+        assert!(empty.is_empty());
+        // A visible range larger than i32::MAX still cannot be represented.
+        // SAFETY: the mapped bytes are zeroes followed by a valid UTF-8 tail;
+        // offsets and NULL bitmap are valid. Skip scanning the 2 GiB prefix.
+        let too_wide: ArrayRef = Arc::new(unsafe {
+            LargeStringArray::new_unchecked(
+                OffsetBuffer::new(vec![0, start + 13].into()),
+                input
+                    .as_any()
+                    .downcast_ref::<LargeStringArray>()
+                    .unwrap()
+                    .values()
+                    .clone(),
+                Some(NullBuffer::from(vec![false])),
+            )
+        });
+        assert!(normalize_write_array(&too_wide, &DataType::Utf8)
+            .unwrap_err()
+            .to_string()
+            .contains("32-bit offsets"));
+    }
+
+    #[test]
+    fn binary_offset_widths_are_not_implicitly_converted() {
+        let small: ArrayRef = 
Arc::new(BinaryArray::from(vec![Some(b"\xff\0".as_slice()), None]));
+        let large: ArrayRef = Arc::new(LargeBinaryArray::from(vec![
+            Some(b"\xff\0".as_slice()),
+            None,
+        ]));
+        assert!(normalize_write_array(&small, 
&DataType::LargeBinary).is_err());
+        assert!(normalize_write_array(&large, &DataType::Binary).is_err());
+    }
+
+    #[test]
+    fn large_strings_preserve_slices_unicode_and_nulls() {
+        let input: ArrayRef = Arc::new(
+            arrow_array::LargeStringArray::from(vec![
+                Some("outside"),
+                Some("中文"),
+                None,
+                Some(""),
+                Some("last"),
+            ])
+            .slice(1, 4),
+        );
+        let result = normalize_write_array(&input, &DataType::Utf8).unwrap();
+        assert_eq!(
+            result.to_data(),
+            StringArray::from(vec![Some("中文"), None, Some(""), 
Some("last")]).to_data()
+        );
+        let empty = normalize_write_array(&input.slice(0, 0), 
&DataType::Utf8).unwrap();
+        assert!(empty.is_empty());
+        assert_eq!(empty.data_type(), &DataType::Utf8);
+        // Layout compatibility must not become an implicit value cast.
+        assert!(normalize_write_array(&input, &DataType::Int32).is_err());
+        assert!(normalize_write_array(&input, &DataType::Binary).is_err());
+    }
+
+    #[test]
+    fn large_strings_inside_rows_and_arrays_keep_visible_nullability_checks() {
+        let text: ArrayRef = Arc::new(arrow_array::LargeStringArray::from(vec![
+            None,
+            Some("中文"),
+            None,
+        ]));
+        let input = Arc::new(StructArray::new(
+            vec![Field::new("text", DataType::LargeUtf8, true)].into(),
+            vec![text],
+            Some(NullBuffer::from(vec![false, true, true])),
+        )) as ArrayRef;
+        let target = DataType::Struct(vec![Field::new("text", DataType::Utf8, 
false)].into());
+        assert!(normalize_write_array(&input, &target).is_err());
+        // The first child's NULL is hidden by a NULL ROW, so it is allowed.
+        let normalized = normalize_write_array(&input.slice(0, 2), 
&target).unwrap();
+        let row = normalized.as_any().downcast_ref::<StructArray>().unwrap();
+        assert!(row.is_null(0));
+        assert_eq!(
+            row.column(0)
+                .as_any()
+                .downcast_ref::<StringArray>()
+                .unwrap()
+                .value(1),
+            "中文"
+        );
+        let list = Arc::new(
+            ListArray::new(
+                Arc::new(Field::new("item", DataType::LargeUtf8, true)),
+                OffsetBuffer::new(vec![0, 1, 3].into()),
+                Arc::new(arrow_array::LargeStringArray::from(vec![
+                    Some("outside"),
+                    Some("中文"),
+                    None,
+                ])),
+                None,
+            )
+            .slice(1, 1),
+        ) as ArrayRef;
+        let expected = DataType::List(Arc::new(Field::new("element", 
DataType::Utf8, true)));
+        let list = normalize_write_array(&list, &expected).unwrap();
+        assert_eq!(list.data_type(), &expected);
+        assert_eq!(
+            list.as_any()
+                .downcast_ref::<ListArray>()
+                .unwrap()
+                .value(0)
+                .to_data(),
+            StringArray::from(vec![Some("中文"), None]).to_data()
+        );
+    }
+
     #[test]
     fn vector_child_aliases_preserve_sliced_values_and_nulls() {
         for name in ["item", "", "element"] {

Reply via email to