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 2825759f fix: repair native vector writes and data evolution read
ranges (#1090)
2825759f is described below
commit 2825759f5894534a7cf041498bc3c3914f448ec6
Author: Jingsong Lee <[email protected]>
AuthorDate: Sat Oct 10 15:04:58 2026 +0800
fix: repair native vector writes and data evolution read ranges (#1090)
---
crates/paimon/src/arrow/format/mod.rs | 31 ++++++
crates/paimon/src/table/data_evolution_reader.rs | 113 +++++++++++++++----
crates/paimon/src/table/table_write.rs | 23 ++++
crates/paimon/src/table/write_batch_normalize.rs | 134 ++++++++++++++++++++++-
4 files changed, 276 insertions(+), 25 deletions(-)
diff --git a/crates/paimon/src/arrow/format/mod.rs
b/crates/paimon/src/arrow/format/mod.rs
index dc42feaf..3f12a841 100644
--- a/crates/paimon/src/arrow/format/mod.rs
+++ b/crates/paimon/src/arrow/format/mod.rs
@@ -476,6 +476,21 @@ fn supported_write_formats() -> Vec<&'static str> {
]
}
+/// Reject unavailable factories before a table writer accepts any data.
+pub(crate) fn validate_write_format(format: &str) -> crate::Result<()> {
+ let extension = format!(".{}", format.to_ascii_lowercase());
+ let supported = supported_write_formats();
+ if supported.contains(&extension.as_str()) {
+ return Ok(());
+ }
+ Err(Error::Unsupported {
+ message: format!(
+ "unsupported write format '{format}': expected {}",
+ supported.join(", ")
+ ),
+ })
+}
+
/// Reusable factory for one rolling writer, mirroring Java
FormatWriterFactory.
/// Output and compression belong to each file; schema/options belong to the
factory.
#[async_trait]
@@ -712,6 +727,22 @@ mod tests {
use crate::io::FileIOBuilder;
use crate::spec::{DataType, IntType};
+ #[test]
+ fn write_format_validation_matches_compiled_factories() {
+ for extension in supported_write_formats() {
+ validate_write_format(extension.trim_start_matches('.')).unwrap();
+ }
+ validate_write_format("PARQUET").unwrap();
+ assert!(matches!(
+ validate_write_format("lance"),
+ Err(Error::Unsupported { .. })
+ ));
+ assert_eq!(
+ validate_write_format("vortex").is_ok(),
+ cfg!(feature = "vortex")
+ );
+ }
+
#[test]
fn format_selects_physical_or_projected_fields() {
let data_schema_fields = vec![
diff --git a/crates/paimon/src/table/data_evolution_reader.rs
b/crates/paimon/src/table/data_evolution_reader.rs
index b856bbc8..b1dbbddb 100644
--- a/crates/paimon/src/table/data_evolution_reader.rs
+++ b/crates/paimon/src/table/data_evolution_reader.rs
@@ -1824,17 +1824,9 @@ fn open_source_stream(
FieldSource::VectorBunch {
bunch, data_fields, ..
} => {
- let anchor =
crate::table::source::data_evolution_anchor_file(split.data_files())?;
- let first_row_id = anchor.first_row_id.ok_or_else(||
Error::DataInvalid {
- message: format!(
- "Data-evolution anchor file '{}' is missing first_row_id",
- anchor.file_name
- ),
- source: None,
- })?;
let selected_ranges = selected_absolute_row_ranges_for_file(
- first_row_id,
- anchor.row_count,
+ bunch.expected_first_row_id,
+ bunch.expected_row_count,
row_ranges.as_deref(),
anchor_deletion_vector.map(|context|
context.deletion_vector.as_ref()),
)?;
@@ -2469,6 +2461,7 @@ fn build_source_plan_with_row_id_pushdown(
let source_idx = sources.len();
sources.push(FieldSource::VectorBunch {
bunch: VectorBunch::new(
+ prepared_group.first_row_id,
prepared_group.logical_row_count,
file.schema_id,
format_suffix,
@@ -3015,6 +3008,7 @@ struct VectorBunch {
schema_id: i64,
format_suffix: String,
normalized_write_cols: Vec<String>,
+ expected_first_row_id: i64,
expected_row_count: i64,
latest_first_row_id: i64,
expected_next_first_row_id: i64,
@@ -3025,6 +3019,7 @@ struct VectorBunch {
impl VectorBunch {
fn new(
+ expected_first_row_id: i64,
expected_row_count: i64,
schema_id: i64,
format_suffix: String,
@@ -3035,6 +3030,7 @@ impl VectorBunch {
schema_id,
format_suffix,
normalized_write_cols,
+ expected_first_row_id,
expected_row_count,
latest_first_row_id: -1,
expected_next_first_row_id: -1,
@@ -3959,7 +3955,7 @@ mod tests {
#[test]
fn test_vector_bunch_aggregates_contiguous_segments() {
- let mut bunch = VectorBunch::new(30, 0, "parquet".to_string(),
vec!["emb".to_string()]);
+ let mut bunch = VectorBunch::new(0, 30, 0, "parquet".to_string(),
vec!["emb".to_string()]);
bunch
.add(
data_file("v1.vector.parquet", 0, 10, 1, Some(vec!["emb"])),
@@ -3992,7 +3988,7 @@ mod tests {
#[test]
fn test_vector_bunch_rejects_gap() {
- let mut bunch = VectorBunch::new(30, 0, "parquet".to_string(),
vec!["emb".to_string()]);
+ let mut bunch = VectorBunch::new(0, 30, 0, "parquet".to_string(),
vec!["emb".to_string()]);
bunch
.add(
data_file("v1.vector.parquet", 0, 10, 1, Some(vec!["emb"])),
@@ -4013,7 +4009,7 @@ mod tests {
#[test]
fn test_vector_bunch_ignores_same_first_row_id_lower_seq() {
- let mut bunch = VectorBunch::new(30, 0, "parquet".to_string(),
vec!["emb".to_string()]);
+ let mut bunch = VectorBunch::new(0, 30, 0, "parquet".to_string(),
vec!["emb".to_string()]);
bunch
.add(
data_file("v-high.vector.parquet", 0, 10, 3,
Some(vec!["emb"])),
@@ -4034,7 +4030,7 @@ mod tests {
#[test]
fn test_vector_bunch_rejects_same_first_row_id_higher_seq() {
- let mut bunch = VectorBunch::new(30, 0, "parquet".to_string(),
vec!["emb".to_string()]);
+ let mut bunch = VectorBunch::new(0, 30, 0, "parquet".to_string(),
vec!["emb".to_string()]);
bunch
.add(
data_file("v-low.vector.parquet", 0, 10, 2, Some(vec!["emb"])),
@@ -4052,7 +4048,7 @@ mod tests {
#[test]
fn test_vector_bunch_ignores_overlapping_lower_seq() {
- let mut bunch = VectorBunch::new(30, 0, "parquet".to_string(),
vec!["emb".to_string()]);
+ let mut bunch = VectorBunch::new(0, 30, 0, "parquet".to_string(),
vec!["emb".to_string()]);
bunch
.add(
data_file("v1.vector.parquet", 0, 10, 3, Some(vec!["emb"])),
@@ -4072,7 +4068,7 @@ mod tests {
#[test]
fn test_selected_vector_bunch_rejects_partial_higher_sequence_overlap() {
- let mut bunch = VectorBunch::new(30, 0, "parquet".to_string(),
vec!["emb".to_string()])
+ let mut bunch = VectorBunch::new(0, 30, 0, "parquet".to_string(),
vec!["emb".to_string()])
.with_row_id_pushdown(true);
bunch
.add(
@@ -4097,7 +4093,7 @@ mod tests {
#[test]
fn test_vector_bunch_rejects_row_count_overflow() {
- let mut bunch = VectorBunch::new(15, 0, "parquet".to_string(),
vec!["emb".to_string()]);
+ let mut bunch = VectorBunch::new(0, 15, 0, "parquet".to_string(),
vec!["emb".to_string()]);
bunch
.add(
data_file("v1.vector.parquet", 0, 10, 1, Some(vec!["emb"])),
@@ -4116,7 +4112,7 @@ mod tests {
#[test]
fn test_vector_bunch_rejects_key_identity_mismatch() {
// schema_id mismatch
- let mut bunch = VectorBunch::new(30, 0, "parquet".to_string(),
vec!["emb".to_string()]);
+ let mut bunch = VectorBunch::new(0, 30, 0, "parquet".to_string(),
vec!["emb".to_string()]);
bunch
.add(
data_file("v1.vector.parquet", 0, 10, 1, Some(vec!["emb"])),
@@ -4129,7 +4125,7 @@ mod tests {
assert!(matches!(err, Error::DataInvalid { .. }));
// format_suffix mismatch
- let mut bunch2 = VectorBunch::new(30, 0, "parquet".to_string(),
vec!["emb".to_string()]);
+ let mut bunch2 = VectorBunch::new(0, 30, 0, "parquet".to_string(),
vec!["emb".to_string()]);
bunch2
.add(
data_file("v1.vector.parquet", 0, 10, 1, Some(vec!["emb"])),
@@ -4145,7 +4141,7 @@ mod tests {
assert!(matches!(err2, Error::DataInvalid { .. }));
// normalized_write_cols mismatch
- let mut bunch3 = VectorBunch::new(30, 0, "parquet".to_string(),
vec!["emb".to_string()]);
+ let mut bunch3 = VectorBunch::new(0, 30, 0, "parquet".to_string(),
vec!["emb".to_string()]);
bunch3
.add(
data_file("v1.vector.parquet", 0, 10, 1, Some(vec!["emb"])),
@@ -4163,7 +4159,7 @@ mod tests {
#[test]
fn test_vector_bunch_rejects_non_vector_file() {
- let mut bunch = VectorBunch::new(30, 0, "parquet".to_string(),
vec!["emb".to_string()]);
+ let mut bunch = VectorBunch::new(0, 30, 0, "parquet".to_string(),
vec!["emb".to_string()]);
let err = bunch
.add(
data_file("v1.parquet", 0, 10, 1, Some(vec!["emb"])),
@@ -6093,6 +6089,81 @@ mod tests {
);
}
+ #[tokio::test]
+ async fn test_filtered_vector_read_keeps_each_merge_group_range() {
+ let tempdir = tempdir().unwrap();
+ let bucket_dir = tempdir.path().join("bucket-0");
+ fs::create_dir_all(&bucket_dir).unwrap();
+ let mut files = Vec::new();
+ for (index, first_row_id) in [100, 200].into_iter().enumerate() {
+ let ids = vec![index as i32 * 2 + 1, index as i32 * 2 + 2];
+ let normal_name = format!("data-{index}.parquet");
+ let vector_name = format!("data-{index}.vector.parquet");
+ let normal_path = bucket_dir.join(&normal_name);
+ let vector_path = bucket_dir.join(&vector_name);
+ write_int_parquet_file(&normal_path, vec![("id", ids)], None);
+ let vector = vec![index as f32 + 1.0, index as f32 + 2.0];
+ write_fixed_size_list_parquet(
+ &vector_path,
+ "embedding",
+ 2,
+ &[Some(vector.clone()), Some(vector)],
+ );
+ for (name, path, columns) in [
+ (normal_name, normal_path, vec!["id"]),
+ (vector_name, vector_path, vec!["embedding"]),
+ ] {
+ files.push(data_file_meta_with_path(
+ &name,
+ first_row_id,
+ 2,
+ 1,
+ path.metadata().unwrap().len() as i64,
+ Some(columns),
+ ));
+ }
+ }
+ let schema = Schema::builder()
+ .column("id", DataType::Int(IntType::new()))
+ .column("embedding", vector_float_type(2))
+ .option("data-evolution.enabled", "true")
+ .option("row-tracking.enabled", "true")
+ .build()
+ .unwrap();
+ let table = Table::new(
+ FileIOBuilder::new("file").build().unwrap(),
+ Identifier::new("default", "vector_groups"),
+ local_file_path(tempdir.path()),
+ TableSchema::new(0, &schema),
+ None,
+ );
+ // A Python plan can bundle multiple independent file groups in one
+ // split. Each group's vectors must use its own normal-file anchor.
+ let split = DataSplitBuilder::new()
+ .with_snapshot(1)
+ .with_partition(BinaryRow::new(0))
+ .with_bucket(0)
+ .with_bucket_path(local_file_path(&bucket_dir))
+ .with_total_buckets(1)
+ .with_data_files(files)
+ .with_row_ranges(vec![RowRange::new(100, 100), RowRange::new(201,
201)])
+ .build()
+ .unwrap();
+ let batches = TableRead::new(&table, table.schema().fields().to_vec(),
Vec::new())
+ .to_arrow(&[split])
+ .unwrap()
+ .try_collect::<Vec<_>>()
+ .await
+ .unwrap();
+ assert_eq!(collect_int_values(&batches, "id"), vec![1, 4]);
+ assert_fixed_size_list(
+ &batches,
+ "embedding",
+ 2,
+ &[Some(vec![1.0, 2.0]), Some(vec![2.0, 3.0])],
+ );
+ }
+
/// (2) Same shape but the dedicated vector file is `.vector.vortex`.
#[cfg(feature = "vortex")]
#[tokio::test]
diff --git a/crates/paimon/src/table/table_write.rs
b/crates/paimon/src/table/table_write.rs
index f4f055a8..5c153f25 100644
--- a/crates/paimon/src/table/table_write.rs
+++ b/crates/paimon/src/table/table_write.rs
@@ -486,6 +486,12 @@ impl TableWrite {
.fields()
.iter()
.any(|f| matches!(f.data_type(), DataType::Vector(_)));
+ if let Some(format) = vector_file_format
+ .as_deref()
+ .filter(|_| has_dedicated_vector_fields)
+ {
+ crate::arrow::format::validate_write_format(format)?;
+ }
let file_index_options = FileIndexOptions::parse(schema.options(),
schema.fields())?;
if file_index_options.is_some() && has_dedicated_vector_fields {
@@ -3507,6 +3513,23 @@ pub(in crate::table) mod tests {
.await;
}
+ #[test]
+ fn test_unsupported_vector_format_fails_before_accepting_data() {
+ let file_io = FileIO::from_path("memory:/").unwrap().build().unwrap();
+ let table = Table::new(
+ file_io,
+ Identifier::new("default", "unsupported_vector_format"),
+ "memory:/unsupported_vector_format".to_string(),
+ test_vector_table_schema("lance"),
+ None,
+ );
+ let error = TableWrite::new(&table, "test-user".to_string())
+ .err()
+ .unwrap();
+ assert!(matches!(error, crate::Error::Unsupported { .. }));
+ assert!(error.to_string().contains("lance"));
+ }
+
#[cfg(feature = "vortex")]
#[tokio::test]
async fn test_vector_write_uses_dedicated_vortex_file() {
diff --git a/crates/paimon/src/table/write_batch_normalize.rs
b/crates/paimon/src/table/write_batch_normalize.rs
index 0156133c..57fce025 100644
--- a/crates/paimon/src/table/write_batch_normalize.rs
+++ b/crates/paimon/src/table/write_batch_normalize.rs
@@ -25,8 +25,8 @@
use crate::{Error, Result};
use arrow_array::{
- Array, ArrayRef, BinaryArray, Decimal128Array, FixedSizeBinaryArray,
ListArray, MapArray,
- StringArray, StructArray,
+ Array, ArrayRef, BinaryArray, Decimal128Array, FixedSizeBinaryArray,
FixedSizeListArray,
+ ListArray, MapArray, StringArray, StructArray,
};
use arrow_buffer::{NullBuffer, OffsetBuffer, ScalarBuffer};
use arrow_schema::{DataType, Field};
@@ -218,7 +218,10 @@ fn normalize_visible_array(
if array.data_type() == expected
&& !matches!(
expected,
- DataType::List(_) | DataType::Struct(_) | DataType::Map(_, _)
+ DataType::List(_)
+ | DataType::FixedSizeList(_, _)
+ | DataType::Struct(_)
+ | DataType::Map(_, _)
)
{
return Ok(array.clone());
@@ -244,6 +247,26 @@ fn normalize_visible_array(
.map_err(normalization_error)?;
Ok(Arc::new(normalized))
}
+ (
+ DataType::FixedSizeList(_, actual_size),
+ DataType::FixedSizeList(expected_child, expected_size),
+ ) if actual_size == expected_size => {
+ let list =
array.as_any().downcast_ref::<FixedSizeListArray>().unwrap();
+ // VECTOR elements are positional, just like ARRAY elements. Reuse
+ // the values of sliced inputs and let Arrow validate NOT NULL
+ // elements against the combined vector/containing ROW validity.
+ let nulls = NullBuffer::union(list.nulls(), parent_nulls);
+ let values = normalize_write_array(list.values(),
expected_child.data_type())?;
+ let normalized = FixedSizeListArray::try_new_with_length(
+ expected_child.clone(),
+ *expected_size,
+ values,
+ nulls,
+ list.len(),
+ )
+ .map_err(normalization_error)?;
+ Ok(Arc::new(normalized))
+ }
(DataType::Struct(actual_fields), DataType::Struct(expected_fields))
if actual_fields.len() == expected_fields.len() =>
{
@@ -392,10 +415,113 @@ fn normalization_error(error: arrow_schema::ArrowError)
-> Error {
#[cfg(test)]
mod tests {
use super::*;
- use arrow_array::{Int32Array, LargeBinaryArray, StringArray};
+ use arrow_array::{FixedSizeListArray, Int32Array, LargeBinaryArray,
StringArray};
use arrow_buffer::{OffsetBuffer, ScalarBuffer};
use arrow_schema::Field;
+ #[test]
+ fn vector_child_aliases_preserve_sliced_values_and_nulls() {
+ for name in ["item", "", "element"] {
+ let input = FixedSizeListArray::new(
+ Arc::new(Field::new(name, DataType::Int32, true)),
+ 2,
+ Arc::new(Int32Array::from(vec![
+ Some(0),
+ Some(1),
+ Some(2),
+ Some(3),
+ None,
+ None,
+ Some(6),
+ Some(7),
+ ])),
+ Some(NullBuffer::from(vec![true, true, false, true])),
+ )
+ .slice(1, 3);
+ let input: ArrayRef = Arc::new(input);
+ let expected =
+ DataType::FixedSizeList(Arc::new(Field::new("element",
DataType::Int32, true)), 2);
+ let output = normalize_write_array(&input, &expected).unwrap();
+ output.to_data().validate_full().unwrap();
+ assert_eq!(output.data_type(), &expected);
+ assert_eq!(output.len(), 3);
+ assert_eq!(output.nulls(), input.nulls());
+ let source =
input.as_any().downcast_ref::<FixedSizeListArray>().unwrap();
+ let result = output
+ .as_any()
+ .downcast_ref::<FixedSizeListArray>()
+ .unwrap();
+ assert!(Arc::ptr_eq(result.values(), source.values()));
+ assert_eq!(
+ result.value(0).to_data(),
+ Int32Array::from(vec![2, 3]).to_data()
+ );
+ assert_eq!(
+ result.value(2).to_data(),
+ Int32Array::from(vec![6, 7]).to_data()
+ );
+ }
+ }
+
+ #[test]
+ fn vector_normalization_rejects_dimensions_and_element_type_changes() {
+ let input: ArrayRef = Arc::new(FixedSizeListArray::new(
+ Arc::new(Field::new("item", DataType::Int32, true)),
+ 2,
+ Arc::new(Int32Array::from(vec![1, 2])),
+ None,
+ ));
+ for expected in [
+ DataType::FixedSizeList(Arc::new(Field::new("element",
DataType::Int32, true)), 1),
+ DataType::FixedSizeList(Arc::new(Field::new("element",
DataType::Int64, true)), 2),
+ DataType::List(Arc::new(Field::new("element", DataType::Int32,
true))),
+ ] {
+ assert!(normalize_write_array(&input, &expected).is_err());
+ }
+ }
+
+ #[test]
+ fn vector_element_nullability_only_checks_visible_parents() {
+ let expected =
+ DataType::FixedSizeList(Arc::new(Field::new("element",
DataType::Int32, false)), 2);
+ let make_input = |nulls| {
+ Arc::new(FixedSizeListArray::new(
+ Arc::new(Field::new("item", DataType::Int32, true)),
+ 2,
+ Arc::new(Int32Array::from(vec![None, None, Some(3), Some(4)])),
+ nulls,
+ )) as ArrayRef
+ };
+ let hidden = make_input(Some(NullBuffer::from(vec![false, true])));
+ let output = normalize_write_array(&hidden, &expected).unwrap();
+ assert!(output.is_null(0));
+ assert!(!output.is_null(1));
+ assert!(normalize_write_array(&make_input(None), &expected).is_err());
+
+ let row: ArrayRef = Arc::new(StructArray::new(
+ vec![Field::new("embedding", hidden.data_type().clone(),
true)].into(),
+ vec![make_input(None)],
+ Some(NullBuffer::from(vec![false, true])),
+ ));
+ let row_type =
+ DataType::Struct(vec![Field::new("embedding", expected.clone(),
true)].into());
+ let output = normalize_write_array(&row, &row_type).unwrap();
+ let result = output.as_any().downcast_ref::<StructArray>().unwrap();
+ assert!(result.column(0).is_null(0));
+ assert!(!result.column(0).is_null(1));
+
+ // FFI inputs can claim NOT NULL elements while carrying visible NULLs.
+ let invalid = unsafe {
+ make_input(None)
+ .to_data()
+ .into_builder()
+ .data_type(expected.clone())
+ .build_unchecked()
+ };
+ let invalid = arrow_array::make_array(invalid);
+ assert!(normalize_write_array(&invalid, &expected).is_err());
+ }
+
fn blob_map_field(nullable_value: bool) -> Field {
Field::new(
"payload",