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(¤t, &changes, identifier)?; + validate_type_evolution_precommit(&self.file_io, &table_path, ¤t, &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]
