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 717e3d39 fix(file_index): skip pruning for narrowing integer schema
changes (#806)
717e3d39 is described below
commit 717e3d39ca60cbcdb0c6f3090417813f04eacbc6
Author: QuakeWang <[email protected]>
AuthorDate: Fri Sep 11 14:25:09 2026 +0800
fix(file_index): skip pruning for narrowing integer schema changes (#806)
---
crates/paimon/src/file_index/evaluator.rs | 136 +++++++++++++++++++--
crates/paimon/src/table/data_file_reader.rs | 183 ++++++++++++++++++++++++++++
2 files changed, 312 insertions(+), 7 deletions(-)
diff --git a/crates/paimon/src/file_index/evaluator.rs
b/crates/paimon/src/file_index/evaluator.rs
index ce84aa75..51c5e82a 100644
--- a/crates/paimon/src/file_index/evaluator.rs
+++ b/crates/paimon/src/file_index/evaluator.rs
@@ -186,7 +186,9 @@ fn devolve_literals(
if same_type_ignoring_nullability(table_type, data_type) {
return Some(literals.to_vec());
}
- if !is_integer_type(table_type) || !is_integer_type(data_type) {
+ // Narrowing can turn non-null file values into NULL, so reject it even for
+ // predicates without literals, such as IS NULL and IS NOT NULL.
+ if integer_width(table_type)? <= integer_width(data_type)? {
return None;
}
literals
@@ -208,11 +210,14 @@ fn same_type_ignoring_nullability(left: &DataType, right:
&DataType) -> bool {
}
}
-fn is_integer_type(data_type: &DataType) -> bool {
- matches!(
- data_type,
- DataType::TinyInt(_) | DataType::SmallInt(_) | DataType::Int(_) |
DataType::BigInt(_)
- )
+fn integer_width(data_type: &DataType) -> Option<u8> {
+ match data_type {
+ DataType::TinyInt(_) => Some(8),
+ DataType::SmallInt(_) => Some(16),
+ DataType::Int(_) => Some(32),
+ DataType::BigInt(_) => Some(64),
+ _ => None,
+ }
}
fn integer_value(data_type: &DataType, datum: &Datum) -> Option<i64> {
@@ -262,7 +267,8 @@ mod tests {
use crate::io::FileIOBuilder;
use crate::spec::stats::BinaryTableStats;
use crate::spec::{
- BigIntType, FloatType, IntType, PredicateBuilder, PredicateOperator,
VarCharType,
+ BigIntType, FloatType, IntType, PredicateBuilder, PredicateOperator,
SmallIntType,
+ TinyIntType, VarCharType,
};
fn field(id: i32, name: &str, data_type: DataType) -> DataField {
@@ -337,6 +343,122 @@ mod tests {
));
}
+ #[test]
+ fn test_devolve_literals_preserves_safe_integer_schema_changes() {
+ let types = [
+ (
+ DataType::TinyInt(TinyIntType::new()),
+ i64::from(i8::MIN),
+ i64::from(i8::MAX),
+ ),
+ (
+ DataType::SmallInt(SmallIntType::new()),
+ i64::from(i16::MIN),
+ i64::from(i16::MAX),
+ ),
+ (
+ DataType::Int(IntType::new()),
+ i64::from(i32::MIN),
+ i64::from(i32::MAX),
+ ),
+ (DataType::BigInt(BigIntType::new()), i64::MIN, i64::MAX),
+ ];
+ for (data_index, (data_type, min, max)) in types.iter().enumerate() {
+ let literals = vec![
+ integer_datum(data_type, *min).unwrap(),
+ integer_datum(data_type, *max).unwrap(),
+ ];
+ for table_nullable in [false, true] {
+ for data_nullable in [false, true] {
+ let table_type =
data_type.copy_with_nullable(table_nullable).unwrap();
+ let data_type =
data_type.copy_with_nullable(data_nullable).unwrap();
+ assert_eq!(
+ devolve_literals(&table_type, &data_type, &literals),
+ Some(literals.clone())
+ );
+ assert_eq!(devolve_literals(&table_type, &data_type, &[]),
Some(vec![]));
+ }
+ }
+ for (table_type, _, _) in &types[data_index + 1..] {
+ let table_literals = vec![
+ integer_datum(table_type, *min).unwrap(),
+ integer_datum(table_type, *max).unwrap(),
+ ];
+ assert_eq!(
+ devolve_literals(table_type, data_type, &table_literals),
+ Some(literals.clone())
+ );
+ assert_eq!(devolve_literals(table_type, data_type, &[]),
Some(vec![]));
+ for overflow in [min - 1, max + 1] {
+ let mut mixed_literals = table_literals.clone();
+ mixed_literals.push(integer_datum(table_type,
overflow).unwrap());
+ assert_eq!(
+ devolve_literals(table_type, data_type,
&mixed_literals),
+ None
+ );
+ }
+ }
+ }
+ }
+
+ #[test]
+ fn test_remap_predicate_falls_back_for_narrowing_integer_schema_changes() {
+ let types = [
+ (DataType::TinyInt(TinyIntType::new()), Datum::TinyInt(127)),
+ (
+ DataType::SmallInt(SmallIntType::new()),
+ Datum::SmallInt(127),
+ ),
+ (DataType::Int(IntType::new()), Datum::Int(127)),
+ (DataType::BigInt(BigIntType::new()), Datum::Long(127)),
+ ];
+ for (table_index, (table_type, literal)) in types.iter().enumerate() {
+ let table_fields = vec![field(0, "value", table_type.clone())];
+ let builder = PredicateBuilder::new(&table_fields);
+ for (data_type, _) in &types[table_index + 1..] {
+ let data_fields = vec![field(0, "value", data_type.clone())];
+ for predicate in [
+ builder.equal("value", literal.clone()).unwrap(),
+ builder.is_null("value").unwrap(),
+ builder.is_not_null("value").unwrap(),
+ ] {
+ assert!(
+ remap_predicate(&table_fields, &data_fields,
&predicate).is_none(),
+ "{data_type:?} -> {table_type:?}: {predicate:?} must
fall back"
+ );
+ }
+ }
+ }
+ }
+
+ #[test]
+ fn
test_remap_narrowing_predicate_keeps_safe_and_child_but_rejects_or_and_not() {
+ let table_fields = vec![
+ field(0, "id", DataType::Int(IntType::new())),
+ field(1, "value", DataType::TinyInt(TinyIntType::new())),
+ ];
+ let data_fields = vec![
+ table_fields[0].clone(),
+ field(1, "value", DataType::Int(IntType::new())),
+ ];
+ let builder = PredicateBuilder::new(&table_fields);
+ let safe = builder.equal("id", Datum::Int(1)).unwrap();
+ let narrowing = builder.is_null("value").unwrap();
+ let combined = Predicate::and(vec![safe.clone(), narrowing.clone()]);
+
+ assert_eq!(
+ remap_predicate(&table_fields, &data_fields, &combined),
+ Some(safe.clone())
+ );
+ for predicate in [
+ Predicate::or(vec![safe, narrowing.clone()]),
+ Predicate::negate(narrowing),
+ Predicate::negate(combined),
+ ] {
+ assert!(remap_predicate(&table_fields, &data_fields,
&predicate).is_none());
+ }
+ }
+
#[test]
fn
test_remap_predicate_uses_field_id_for_rename_reorder_and_integer_devolution() {
let table_fields = vec![
diff --git a/crates/paimon/src/table/data_file_reader.rs
b/crates/paimon/src/table/data_file_reader.rs
index e6221cc0..667b681e 100644
--- a/crates/paimon/src/table/data_file_reader.rs
+++ b/crates/paimon/src/table/data_file_reader.rs
@@ -2437,6 +2437,189 @@ mod tests {
assert!(batches.is_empty());
}
+ #[tokio::test]
+ async fn
test_file_index_narrowing_integer_schema_changes_preserve_query_results() {
+ use std::collections::HashMap;
+
+ use apache_avro::types::Value;
+ use arrow_array::Int8Array;
+
+ use crate::catalog::Identifier;
+ use crate::spec::TinyIntType;
+ use crate::table::{Table, TableRead};
+
+ let old_schema = TableSchema::new(
+ 0,
+ &Schema::builder()
+ .column("value", DataType::Int(IntType::new()))
+ .build()
+ .unwrap(),
+ );
+ let current_schema = old_schema
+ .apply_changes(vec![SchemaChange::update_column_type(
+ "value".to_string(),
+ DataType::TinyInt(TinyIntType::new()),
+ )])
+ .unwrap();
+ let avro_schema = apache_avro::Schema::parse_str(
+
r#"{"type":"record","name":"row","fields":[{"name":"value","type":["null","int"]}]}"#,
+ )
+ .unwrap();
+ for (case, values, expected_values) in [
+ ("overflow_only", vec![Some(383)], vec![None]),
+ (
+ "mixed",
+ vec![Some(127), Some(383), None],
+ vec![Some(127), None, None],
+ ),
+ ] {
+ let mut avro_writer = apache_avro::Writer::new(&avro_schema,
Vec::new());
+ let mut index_writer = FileIndexerFactory::create_writer(
+ BITMAP_INDEX,
+ old_schema.fields()[0].data_type().clone(),
+ &Options::new(),
+ )
+ .unwrap();
+ for value in &values {
+ let (tag, avro_value) = match value {
+ Some(value) => (1, Value::Int(*value)),
+ None => (0, Value::Null),
+ };
+ avro_writer
+ .append(Value::Record(vec![(
+ "value".to_string(),
+ Value::Union(tag, Box::new(avro_value)),
+ )]))
+ .unwrap();
+ index_writer.write(value.map(Datum::Int).as_ref()).unwrap();
+ }
+ let data = Bytes::from(avro_writer.into_inner().unwrap());
+ let indexes = HashMap::from([(
+ "value".to_string(),
+ HashMap::from([(
+ BITMAP_INDEX.to_string(),
+ Some(index_writer.serialized_bytes().unwrap()),
+ )]),
+ )]);
+ let index =
write_column_indexes(&format!("memory:/narrowing_{case}_index"), indexes)
+ .await
+ .unwrap()
+ .to_input_file()
+ .read()
+ .await
+ .unwrap();
+
+ let file_io = FileIOBuilder::new("memory").build().unwrap();
+ let table_path = format!("memory:/file_index_narrowing_{case}");
+ let bucket_path = format!("{table_path}/bucket-0");
+ let file_name = "part-0.avro";
+ file_io
+ .new_output(&format!("{bucket_path}/{file_name}"))
+ .unwrap()
+ .write(data.clone())
+ .await
+ .unwrap();
+ let schema_manager = SchemaManager::new(file_io.clone(),
table_path.clone());
+ let schema_path = schema_manager.schema_path(old_schema.id());
+ file_io
+ .mkdirs(schema_path.rsplit_once('/').unwrap().0)
+ .await
+ .unwrap();
+ file_io
+ .new_output(&schema_path)
+ .unwrap()
+ .write(Bytes::from(serde_json::to_vec(&old_schema).unwrap()))
+ .await
+ .unwrap();
+ let mut file = data_file(
+ file_name,
+ data.len() as i64,
+ values.len() as i64,
+ old_schema.id(),
+ );
+ file.embedded_index = Some(index.to_vec());
+ 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![file])
+ .build()
+ .unwrap();
+ let table = Table::new(
+ file_io,
+ Identifier::new("default", "narrowing"),
+ table_path,
+ current_schema.clone(),
+ None,
+ );
+ let builder = PredicateBuilder::new(current_schema.fields());
+ for (query, predicate, expected) in [
+ ("all", Predicate::AlwaysTrue, expected_values.clone()),
+ (
+ "IS NULL",
+ builder.is_null("value").unwrap(),
+ expected_values
+ .iter()
+ .copied()
+ .filter(Option::is_none)
+ .collect(),
+ ),
+ (
+ "IS NOT NULL",
+ builder.is_not_null("value").unwrap(),
+ expected_values
+ .iter()
+ .copied()
+ .filter(Option::is_some)
+ .collect(),
+ ),
+ (
+ "= 127",
+ builder.equal("value", Datum::TinyInt(127)).unwrap(),
+ expected_values
+ .iter()
+ .copied()
+ .filter(|value| *value == Some(127))
+ .collect(),
+ ),
+ ] {
+ for enabled in [false, true] {
+ let table = table.copy_with_options(HashMap::from([(
+ "file-index.read.enabled".to_string(),
+ enabled.to_string(),
+ )]));
+ let batches = TableRead::new(
+ &table,
+ current_schema.fields().to_vec(),
+ vec![predicate.clone()],
+ )
+ .to_arrow(std::slice::from_ref(&split))
+ .unwrap()
+ .try_collect::<Vec<_>>()
+ .await
+ .unwrap();
+ let actual = batches
+ .iter()
+ .flat_map(|batch| {
+ batch
+ .column(0)
+ .as_any()
+ .downcast_ref::<Int8Array>()
+ .unwrap()
+ .iter()
+ })
+ .collect::<Vec<_>>();
+ assert_eq!(
+ actual, expected,
+ "case={case}, query={query}, enabled={enabled}"
+ );
+ }
+ }
+ }
+ }
+
#[tokio::test]
async fn test_file_index_nested_not_with_added_column_falls_back() {
let old_schema = TableSchema::new(