JingsongLi commented on code in PR #681:
URL: https://github.com/apache/paimon-rust/pull/681#discussion_r3818245609


##########
crates/paimon/src/arrow/schema_evolution.rs:
##########
@@ -19,12 +19,373 @@
 //!
 //! Reference: 
[org.apache.paimon.schema.SchemaEvolutionUtil](https://github.com/apache/paimon/blob/release-1.3/paimon-core/src/main/java/org/apache/paimon/schema/SchemaEvolutionUtil.java)
 
-use crate::spec::DataField;
+use crate::arrow::paimon_type_to_arrow;
+use crate::spec::{DataField, DataType};
+use arrow_array::builder::{BinaryBuilder, StringBuilder};
+use arrow_array::types::{
+    ArrowPrimitiveType, Date32Type, Float32Type, Float64Type, Int16Type, 
Int32Type, Int64Type,
+    Int8Type, TimestampMicrosecondType, TimestampMillisecondType, 
TimestampNanosecondType,
+};
+use arrow_array::{Array, ArrayRef, BinaryArray, PrimitiveArray, StringArray};
+use arrow_cast::cast;
 use std::collections::HashMap;
+use std::sync::Arc;
 
 /// Sentinel value indicating a field does not exist in the data schema.
 pub const NULL_FIELD_INDEX: i32 = -1;
 
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+enum SchemaEvolutionCast {
+    Identity,
+    NumericPrimitive,
+    Decimal,
+    DateToTimestamp,
+    TimestampToDate,
+    CharacterString,
+    BinaryString,
+}
+
+/// Return whether a schema-evolution cast has a real executor.
+///
+/// This is deliberately narrower than SQL explicit casting. Schema admission
+/// uses this function in addition to the logical Paimon cast rules, so every
+/// accepted type change can be executed while reading old files.
+pub(crate) fn schema_evolution_cast_implemented(source: &DataType, target: 
&DataType) -> bool {
+    resolve_schema_evolution_cast(source, target).is_some()
+}
+
+fn resolve_schema_evolution_cast(
+    source: &DataType,
+    target: &DataType,
+) -> Option<SchemaEvolutionCast> {
+    if same_type_ignoring_nullability(source, target) {
+        return 
is_top_level_scalar(source).then_some(SchemaEvolutionCast::Identity);
+    }
+
+    match (source, target) {
+        (DataType::Char(_) | DataType::VarChar(_), DataType::Char(_) | 
DataType::VarChar(_)) => {
+            Some(SchemaEvolutionCast::CharacterString)
+        }
+        (
+            DataType::Binary(_) | DataType::VarBinary(_),
+            DataType::Binary(_) | DataType::VarBinary(_),
+        ) => Some(SchemaEvolutionCast::BinaryString),
+        (source, target) if is_numeric_primitive(source) && 
is_numeric_primitive(target) => {
+            Some(SchemaEvolutionCast::NumericPrimitive)
+        }
+        (source, DataType::Decimal(_))
+            if is_integer_numeric(source) || matches!(source, 
DataType::Decimal(_)) =>
+        {
+            Some(SchemaEvolutionCast::Decimal)
+        }
+        (DataType::Date(_), DataType::Timestamp(timestamp)) if 
timestamp.precision() <= 3 => {
+            Some(SchemaEvolutionCast::DateToTimestamp)
+        }
+        (DataType::Timestamp(_), DataType::Date(_)) => 
Some(SchemaEvolutionCast::TimestampToDate),
+        _ => None,
+    }
+}
+
+fn is_top_level_scalar(data_type: &DataType) -> bool {
+    matches!(
+        data_type,
+        DataType::Boolean(_)
+            | DataType::TinyInt(_)
+            | DataType::SmallInt(_)
+            | DataType::Int(_)
+            | DataType::BigInt(_)
+            | DataType::Decimal(_)
+            | DataType::Double(_)
+            | DataType::Float(_)
+            | DataType::Binary(_)
+            | DataType::VarBinary(_)
+            | DataType::Char(_)
+            | DataType::VarChar(_)
+            | DataType::Date(_)
+            | DataType::LocalZonedTimestamp(_)
+            | DataType::Time(_)
+            | DataType::Timestamp(_)
+    )
+}
+
+fn is_integer_numeric(data_type: &DataType) -> bool {
+    matches!(
+        data_type,
+        DataType::TinyInt(_) | DataType::SmallInt(_) | DataType::Int(_) | 
DataType::BigInt(_)
+    )
+}
+
+fn is_numeric_primitive(data_type: &DataType) -> bool {
+    matches!(
+        data_type,
+        DataType::TinyInt(_)
+            | DataType::SmallInt(_)
+            | DataType::Int(_)
+            | DataType::BigInt(_)
+            | DataType::Double(_)
+            | DataType::Float(_)
+    )
+}
+
+pub(crate) fn same_type_ignoring_nullability(source: &DataType, target: 
&DataType) -> bool {
+    match (
+        source.copy_with_nullable(true),
+        target.copy_with_nullable(true),
+    ) {
+        (Ok(source), Ok(target)) => source == target,
+        _ => false,
+    }
+}
+
+/// Cast one physical Arrow column according to Paimon schema-evolution
+/// semantics. Arrow is used only after the Paimon source/target pair has been
+/// resolved to a supported executor.
+pub(crate) fn cast_array_for_schema_evolution(
+    array: &ArrayRef,
+    source: &DataType,
+    target: &DataType,
+) -> crate::Result<ArrayRef> {
+    // Unchanged complex fields can still appear in an old file selected by a
+    // different table schema version. They need no ALTER TYPE executor, but
+    // nested Arrow fields may still carry file metadata which must be removed
+    // before constructing a batch with the current logical schema.
+    if same_type_ignoring_nullability(source, target) {
+        let target_arrow_type = paimon_type_to_arrow(target)?;
+        if array.data_type() == &target_arrow_type {
+            return Ok(array.clone());
+        }
+        return cast(array.as_ref(), &target_arrow_type).map_err(|error| {
+            crate::Error::UnexpectedError {
+                message: format!(
+                    "Failed schema evolution Arrow normalization from 
{source:?} to {target:?}: {error}"
+                ),
+                source: Some(Box::new(error)),
+            }
+        });
+    }
+    let executor =
+        resolve_schema_evolution_cast(source, target).ok_or_else(|| 
crate::Error::Unsupported {
+            message: format!(
+                "Schema evolution cast from {source:?} to {target:?} is not 
implemented"
+            ),
+        })?;
+
+    match executor {
+        SchemaEvolutionCast::Identity => Ok(array.clone()),
+        SchemaEvolutionCast::NumericPrimitive => cast_numeric_primitive(array, 
source, target),
+        SchemaEvolutionCast::Decimal => {
+            let target_arrow_type = paimon_type_to_arrow(target)?;
+            cast(array.as_ref(), &target_arrow_type).map_err(|error| {
+                crate::Error::UnexpectedError {
+                    message: format!(
+                        "Failed schema evolution cast from {source:?} to 
{target:?}: {error}"
+                    ),
+                    source: Some(Box::new(error)),
+                }
+            })
+        }
+        SchemaEvolutionCast::DateToTimestamp => cast_date_to_timestamp(array, 
target),
+        SchemaEvolutionCast::TimestampToDate => cast_timestamp_to_date(array, 
source),
+        SchemaEvolutionCast::CharacterString => cast_character_string(array, 
target),
+        SchemaEvolutionCast::BinaryString => cast_binary_string(array, target),
+    }
+}
+
+fn downcast_primitive<'a, T: ArrowPrimitiveType>(
+    array: &'a ArrayRef,
+    semantic_type: &str,
+) -> crate::Result<&'a PrimitiveArray<T>> {
+    array
+        .as_any()
+        .downcast_ref::<PrimitiveArray<T>>()
+        .ok_or_else(|| crate::Error::DataInvalid {
+            message: format!(
+                "Expected {semantic_type} array with physical type {:?}, found 
{:?}",
+                T::DATA_TYPE,
+                array.data_type()
+            ),
+            source: None,
+        })
+}
+
+macro_rules! cast_numeric_primitive_array {
+    ($array:expr, $source:ty, $target:expr) => {{
+        let values = downcast_primitive::<$source>($array, "numeric")?;
+        let result: ArrayRef = match $target {
+            DataType::TinyInt(_) => {
+                Arc::new(values.unary::<_, Int8Type>(|value| value as i32 as 
i8))
+            }
+            DataType::SmallInt(_) => {
+                Arc::new(values.unary::<_, Int16Type>(|value| value as i32 as 
i16))
+            }
+            DataType::Int(_) => Arc::new(values.unary::<_, Int32Type>(|value| 
value as i32)),
+            DataType::BigInt(_) => Arc::new(values.unary::<_, 
Int64Type>(|value| value as i64)),
+            DataType::Float(_) => Arc::new(values.unary::<_, 
Float32Type>(|value| value as f32)),
+            DataType::Double(_) => Arc::new(values.unary::<_, 
Float64Type>(|value| value as f64)),
+            _ => unreachable!("numeric primitive executor requires a primitive 
numeric target"),
+        };
+        Ok(result)
+    }};
+}
+
+fn cast_numeric_primitive(
+    array: &ArrayRef,
+    source: &DataType,
+    target: &DataType,
+) -> crate::Result<ArrayRef> {
+    // Java Number converts floating-point values to byte/short through int,
+    // combining saturating float-to-int conversion with wrapping integer
+    // narrowing. The macro uses that two-stage conversion for those targets.
+    match source {
+        DataType::TinyInt(_) => cast_numeric_primitive_array!(array, Int8Type, 
target),
+        DataType::SmallInt(_) => cast_numeric_primitive_array!(array, 
Int16Type, target),
+        DataType::Int(_) => cast_numeric_primitive_array!(array, Int32Type, 
target),
+        DataType::BigInt(_) => cast_numeric_primitive_array!(array, Int64Type, 
target),
+        DataType::Float(_) => cast_numeric_primitive_array!(array, 
Float32Type, target),
+        DataType::Double(_) => cast_numeric_primitive_array!(array, 
Float64Type, target),
+        _ => unreachable!("numeric primitive executor requires a primitive 
numeric source"),
+    }
+}
+
+fn cast_date_to_timestamp(array: &ArrayRef, target: &DataType) -> 
crate::Result<ArrayRef> {
+    let values = downcast_primitive::<Date32Type>(array, "DATE")?;
+    let DataType::Timestamp(timestamp) = target else {
+        unreachable!("date executor requires a TIMESTAMP target")
+    };
+    let result: ArrayRef = match timestamp.precision() {
+        0..=3 => Arc::new(
+            values.unary::<_, TimestampMillisecondType>(|value| 
i64::from(value) * 86_400_000),
+        ),
+        _ => unreachable!("DATE to TIMESTAMP precision above 3 is not 
admitted"),
+    };
+    Ok(result)
+}
+
+fn cast_timestamp_to_date(array: &ArrayRef, source: &DataType) -> 
crate::Result<ArrayRef> {
+    let DataType::Timestamp(timestamp) = source else {
+        unreachable!("timestamp executor requires a TIMESTAMP source")
+    };
+    let result: ArrayRef = match timestamp.precision() {
+        0..=3 => Arc::new(
+            downcast_primitive::<TimestampMillisecondType>(array, "TIMESTAMP")?
+                .unary::<_, Date32Type>(|value| (value / 86_400_000) as i32),

Review Comment:
   [P1] Use floor division for pre-epoch timestamps
   
   Rust integer division truncates toward zero, but Paimon Java 
`TimestampToDateCastRule` uses `Math.floorDiv`. Consequently `-1 ms` becomes 
epoch day `0` instead of `-1`, and `-86_400_001 ms` becomes `-1` instead of 
`-2`, silently shifting pre-1970 non-midnight values and residual-filter 
results by one day. Please use `div_euclid` in the millisecond, microsecond, 
and nanosecond branches and change the new test expectation to `[-1, -1, -2, 
null]`; the current test passes only because it codifies the wrong truncation.



##########
crates/paimon/src/catalog/rest/rest_catalog.rs:
##########
@@ -278,6 +279,25 @@ impl Catalog for RESTCatalog {
         changes: Vec<SchemaChange>,
         ignore_if_not_exists: bool,
     ) -> Result<()> {
+        if changes
+            .iter()
+            .any(|change| matches!(change, SchemaChange::UpdateColumnType { .. 
}))
+        {
+            let table = match self.get_table(identifier).await {
+                Ok(table) => table,
+                Err(Error::TableNotExist { .. }) if ignore_if_not_exists => 
return Ok(()),
+                Err(error) => return Err(error),
+            };
+            let new_schema = apply_schema_changes(table.schema(), &changes, 
identifier)?;
+            validate_type_evolution_precommit(
+                table.file_io(),
+                table.location(),
+                table.schema(),
+                &new_schema,
+            )
+            .await?;

Review Comment:
   [P1] Fence this preflight with the schema version
   
   The validation is based on a prior `get_table`, while `AlterTableRequest` 
carries only `changes` and no expected schema ID. For example, this client can 
validate `INT -> BIGINT`, a concurrent Java client can commit `INT -> DECIMAL` 
and write a Decimal file, and then this stale POST is applied by the server as 
`DECIMAL -> BIGINT` (supported by Java). The final history now requires 
`DECIMAL -> BIGINT`, which this PR's Rust executor does not implement, so Rust 
fails to read the intermediate file despite the preflight succeeding. The 
preflight must run atomically on the server, or the request must assert the 
schema ID and retry validation on conflict.



##########
crates/paimon/src/table/data_file_reader.rs:
##########
@@ -397,42 +420,45 @@ impl DataFileReader {
                             None
                         } else {
                             let data_field = 
&data_fields.as_ref().unwrap()[data_idx as usize];
-                            batch_schema
-                                .index_of(data_field.name())
-                                .ok()
-                                .map(|col_idx| batch.column(col_idx))
+                            match batch_schema.index_of(data_field.name()) {
+                                Ok(col_idx) => Some((
+                                    batch.column(col_idx),
+                                    decoded_data_type(data_field, 
&format_read_fields)?,
+                                )),
+                                Err(_) => None,
+                            }
                         }
                     } else if let Some(ref df) = data_fields {
-                        batch_schema
-                            .index_of(df[i].name())
-                            .ok()
-                            .map(|col_idx| batch.column(col_idx))
+                        let data_field = &df[i];
+                        match batch_schema.index_of(data_field.name()) {
+                            Ok(col_idx) => Some((
+                                batch.column(col_idx),
+                                decoded_data_type(data_field, 
&format_read_fields)?,
+                            )),
+                            Err(_) => None,
+                        }
                     } else {
                         batch_schema
                             .index_of(target_field.name())
                             .ok()
-                            .map(|col_idx| batch.column(col_idx))
+                            .map(|col_idx| (batch.column(col_idx), 
read_type[i].data_type()))
                     };
 
                     match source_col {
-                        Some(col) => {
-                            if col.data_type() == target_field.data_type() {
-                                columns.push(col.clone());
-                            } else {
-                                let casted = cast(col, 
target_field.data_type()).map_err(|e| {
-                                    Error::UnexpectedError {
-                                        message: format!(
-                                            "Failed to cast column '{}' from 
{:?} to {:?}: {e}",
-                                            target_field.name(),
-                                            col.data_type(),
-                                            target_field.data_type()
-                                        ),
-                                        source: Some(Box::new(e)),
-                                    }
-                                })?;
-                                columns.push(casted);
-                            }
-                        }
+                        Some((col, source_type)) => columns.push(
+                            cast_array_for_schema_evolution(

Review Comment:
   [P1] Keep in-flight readers able to read post-ALTER files
   
   This replacement of the generic Arrow cast assumes the table schema is 
always newer than the file schema, but long-lived providers keep a fixed 
`Table` while their scans plan the latest snapshot. A provider registered under 
`INT` can therefore see a new `DECIMAL` file after another client performs `INT 
-> DECIMAL`; here the source is Decimal and the target is the provider's Int 
schema, and the new one-way executor rejects `DECIMAL -> INT` even though the 
previous Arrow cast handled it. This breaks running readers/jobs across ALTER. 
Please either implement the reverse executors required by old-schema readers or 
fence/refresh providers so a fixed schema cannot scan newer file schemas.



##########
crates/paimon/src/catalog/filesystem.rs:
##########
@@ -458,29 +459,13 @@ impl Catalog for FileSystemCatalog {
                 full_name: identifier.full_name(),
             })?;
 
-        let new_schema = current
-            .apply_changes(changes)
-            .map_err(|e| fill_table_name(e, identifier))?;
+        let new_schema = apply_schema_changes(&current, &changes, identifier)?;
+        validate_type_evolution_precommit(&self.file_io, &table_path, 
&current, &new_schema)

Review Comment:
   [P1] Include the schema version in the index-build commit guard
   
   This preflight and schema save are not coordinated with global-index 
commits, and an ALTER does not advance the snapshot ID. An index build planned 
at snapshot S/schema N can therefore commit after this saves schema N+1 because 
`commit_if_latest_snapshot` still sees S; the commit stamps the index with N 
and also creates a new snapshot whose `schema_id` is the stale N. Fresh Rust 
drops the index, time travel/branch creation observes the wrong schema, and an 
old Java reader has no build-schema filter and may use the stale index. Please 
atomically guard index commits on both snapshot and schema ID (or serialize 
them with ALTER), and stamp snapshots from the latest schema as Java does.



##########
crates/paimon/src/spec/index_manifest.rs:
##########
@@ -71,7 +71,8 @@ pub const INDEX_MANIFEST_ENTRY_SCHEMA: &str = r#"{
                     {"name": "_INDEX_FIELD_ID", "type": "int"},
                     {"name": "_EXTRA_FIELD_IDS", "type": ["null", {"type": 
"array", "items": "int"}], "default": null},
                     {"name": "_INDEX_META", "type": ["null", "bytes"], 
"default": null},
-                    {"name": "_SOURCE_META", "type": ["null", "bytes"], 
"default": null}
+                    {"name": "_SOURCE_META", "type": ["null", "bytes"], 
"default": null},
+                    {"name": "_BUILD_SCHEMA_ID", "type": ["null", "long"], 
"default": null}

Review Comment:
   [P2] Do not let old Java rewrites erase this marker
   
   Current Java `GlobalIndexMeta` has six fields, and 
`IndexManifestFileHandler` reads all active entries and serializes a new 
combined manifest. An old Java writer therefore drops this seventh field on any 
index/DV manifest rewrite. Back on new Rust, a valid index built after type 
evolution is treated as legacy/incompatible and filtered out, while final 
overlap validation still retains the physical entry and blocks rebuilding it 
until an explicit drop. A trailing top-level field fixes the old-Rust decode 
issue but is still lost by an old serializer. Please gate emission on a Java 
version that preserves the marker, or encode the provenance in an opaque 
payload old writers round-trip unchanged.



-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: [email protected]

For queries about this service, please contact Infrastructure at:
[email protected]

Reply via email to