This is an automated email from the ASF dual-hosted git repository.

jerry-024 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 9f9739af feat(vindex): build indexes from streamed data splits (#707)
9f9739af is described below

commit 9f9739afcf8b993ac5fac1d95f5051c151272567
Author: jerry <[email protected]>
AuthorDate: Thu Aug 13 17:45:21 2026 +0800

    feat(vindex): build indexes from streamed data splits (#707)
---
 crates/paimon/Cargo.toml                           |   7 +-
 crates/paimon/src/table/table_commit.rs            |  46 +-
 .../paimon/src/table/vindex_index_build_builder.rs | 804 +++++++++++++++------
 crates/paimon/src/vindex/mod.rs                    | 113 ++-
 docs/src/sql.md                                    |   1 +
 5 files changed, 724 insertions(+), 247 deletions(-)

diff --git a/crates/paimon/Cargo.toml b/crates/paimon/Cargo.toml
index ee5beacf..5c3475a1 100644
--- a/crates/paimon/Cargo.toml
+++ b/crates/paimon/Cargo.toml
@@ -42,7 +42,7 @@ storage-all = [
     "storage-gcs",
     "storage-hdfs",
 ]
-fulltext = ["dep:paimon-ftindex-core", "dep:tempfile"]
+fulltext = ["dep:paimon-ftindex-core"]
 vortex = ["dep:vortex"]
 
 storage-memory = ["opendal/services-memory"]
@@ -103,7 +103,7 @@ arrow-select = { workspace = true }
 arrow-string = { workspace = true }
 futures = "0.3"
 crossbeam-channel = "0.5"
-tokio-util = { workspace = true, features = ["compat"] }
+tokio-util = { workspace = true, features = ["compat", "io-util"] }
 parquet = { workspace = true, features = ["async", "zstd", "lz4", "snap"] }
 orc-rust = "0.8.0"
 async-stream = "0.3.6"
@@ -120,7 +120,7 @@ uuid = { version = "1", features = ["v4"] }
 urlencoding = "2.1"
 paimon-mosaic-core = "0.2.0"
 paimon-ftindex-core = { version = "0.1.0", optional = true }
-tempfile = { version = "3", optional = true }
+tempfile = "3"
 paimon-vindex-core = "0.3.0"
 vortex = { version = "0.75.0", features = ["tokio"], optional = true }
 libloading = "0.9"
@@ -132,4 +132,3 @@ unicode-segmentation = "=1.13.2"
 [dev-dependencies]
 axum = { version = "0.7", features = ["macros", "tokio", "http1", "http2"] }
 rand = "0.8.5"
-tempfile = "3"
diff --git a/crates/paimon/src/table/table_commit.rs 
b/crates/paimon/src/table/table_commit.rs
index 809e45d9..96fb45f3 100644
--- a/crates/paimon/src/table/table_commit.rs
+++ b/crates/paimon/src/table/table_commit.rs
@@ -208,18 +208,28 @@ impl TableCommit {
         let changelog_entries = 
self.messages_to_changelog_entries(&commit_messages);
         let new_index_entries = 
self.messages_to_index_entries(&commit_messages);
         let check_from_snapshot = 
Self::min_check_from_snapshot(&commit_messages);
-        self.try_commit(
-            CommitEntriesPlan::Direct {
-                entries,
-                changelog_entries,
-                new_index_entries,
-                check_from_snapshot,
-            },
-            Some(expected_snapshot_id),
-            commit_identifier,
-            false,
-        )
-        .await
+        let result = self
+            .try_commit(
+                CommitEntriesPlan::Direct {
+                    entries,
+                    changelog_entries,
+                    new_index_entries,
+                    check_from_snapshot,
+                },
+                Some(expected_snapshot_id),
+                commit_identifier,
+                false,
+            )
+            .await;
+        if let Err(error) = result {
+            // Storage and REST errors can be indeterminate: the snapshot may
+            // already reference these files even though the response failed.
+            if matches!(&error, crate::Error::DataInvalid { .. }) {
+                let _ = self.abort(&commit_messages).await;
+            }
+            return Err(error);
+        }
+        Ok(())
     }
 
     /// Overwrite partitions with new data.
@@ -3588,6 +3598,17 @@ mod tests {
             .await
             .unwrap();
 
+        let index_path = format!("{table_path}/index/lumina-0.index");
+        file_io
+            .mkdirs(&format!("{table_path}/index/"))
+            .await
+            .unwrap();
+        file_io
+            .new_output(&index_path)
+            .unwrap()
+            .write(bytes::Bytes::from_static(b"index"))
+            .await
+            .unwrap();
         let mut message = CommitMessage::new(vec![], 0, vec![]);
         message.new_index_files = 
vec![test_global_index_file("lumina-0.index", 0, 0, 9)];
         let result = commit.commit_if_latest_snapshot(vec![message], 0).await;
@@ -3603,6 +3624,7 @@ mod tests {
         let snapshot = 
snap_manager.get_latest_snapshot().await.unwrap().unwrap();
         assert_eq!(snapshot.id(), 1);
         assert!(snapshot.index_manifest().is_none());
+        assert!(!file_io.exists(&index_path).await.unwrap());
     }
 
     #[tokio::test]
diff --git a/crates/paimon/src/table/vindex_index_build_builder.rs 
b/crates/paimon/src/table/vindex_index_build_builder.rs
index 11579c6e..bde7d0ab 100644
--- a/crates/paimon/src/table/vindex_index_build_builder.rs
+++ b/crates/paimon/src/table/vindex_index_build_builder.rs
@@ -21,18 +21,22 @@ use crate::spec::{
 };
 use crate::table::source::exclude_row_ranges;
 use crate::table::{
-    CommitMessage, DataSplitBuilder, RowRange, SnapshotManager, Table, 
TableCommit,
+    CommitMessage, DataSplit, DataSplitBuilder, RowRange, SnapshotManager, 
Table, TableCommit,
 };
 use crate::vindex::{is_vindex_index_type, VindexVectorIndexOptions};
 use crate::{Error, Result};
 use arrow_array::{Array, FixedSizeListArray, Float32Array, Int64Array, 
ListArray, RecordBatch};
-use bytes::Bytes;
+use arrow_buffer::MutableBuffer;
 use futures::TryStreamExt;
-use paimon_vindex_core::index::{VectorIndexConfig, VectorIndexTrainer, 
VectorIndexWriter};
+use paimon_vindex_core::index::{VectorIndexTrainer, VectorIndexWriter};
 use paimon_vindex_core::io::PosWriter;
 use std::collections::HashMap;
+use std::io::{Read, Seek, SeekFrom};
+use tokio::io::AsyncWriteExt;
+use tokio_util::io::SyncIoBridge;
 
 const INDEX_DIR: &str = "index";
+const VECTOR_BUFFER_BYTES: usize = 8 * 1024 * 1024;
 
 pub struct VindexIndexBuildBuilder<'a> {
     table: &'a Table,
@@ -155,35 +159,42 @@ impl<'a> VindexIndexBuildBuilder<'a> {
         )
         .await?;
 
+        let commit = TableCommit::new(
+            self.table.clone(),
+            format!(
+                "global-index-{}-create-{}",
+                self.index_type,
+                uuid::Uuid::new_v4()
+            ),
+        );
         let shard_count = shards.len();
         let mut messages = Vec::with_capacity(shard_count);
         for shard in shards {
-            let vectors = extract_vectors(self.table, &shard, index_column, 
dimension).await?;
-            let index_file = self
+            let index_file = match self
                 .build_index_file(
                     &shard,
-                    &vectors,
+                    index_column,
                     dimension,
                     index_field.id(),
-                    vindex_options.config.clone(),
+                    &vindex_options,
                     index_meta.clone(),
                 )
-                .await?;
+                .await
+            {
+                Ok(index_file) => index_file,
+                Err(error) => {
+                    let _ = commit.abort(&messages).await;
+                    return Err(error);
+                }
+            };
             let mut message = 
CommitMessage::new(shard.partition_bytes.clone(), 0, vec![]);
             message.new_index_files = vec![index_file];
             messages.push(message);
         }
 
-        TableCommit::new(
-            self.table.clone(),
-            format!(
-                "global-index-{}-create-{}",
-                self.index_type,
-                uuid::Uuid::new_v4()
-            ),
-        )
-        .commit_if_latest_snapshot(messages, snapshot.id())
-        .await?;
+        commit
+            .commit_if_latest_snapshot(messages, snapshot.id())
+            .await?;
 
         Ok(shard_count)
     }
@@ -191,42 +202,217 @@ impl<'a> VindexIndexBuildBuilder<'a> {
     async fn build_index_file(
         &self,
         shard: &VindexIndexShard,
-        vectors: &[f32],
+        index_column: &str,
         dimension: i32,
         index_field_id: i32,
-        config: VectorIndexConfig,
+        options: &VindexVectorIndexOptions,
         index_meta: Vec<u8>,
     ) -> Result<IndexFileMeta> {
         let row_count = checked_row_count(shard.row_range_start, 
shard.row_range_end)?;
-        validate_vector_buffer(vectors, row_count, dimension)?;
         let row_count_usize = usize::try_from(row_count).map_err(|e| 
Error::DataInvalid {
             message: format!("Invalid vindex row count: {row_count}"),
             source: Some(Box::new(e)),
         })?;
-        let ids = (0..i64::from(row_count)).collect::<Vec<_>>();
-
-        let training =
-            VectorIndexTrainer::train(config, vectors, 
row_count_usize).map_err(|e| {
-                Error::DataInvalid {
-                    message: format!("Failed to train vindex index: {e}"),
-                    source: Some(Box::new(e)),
-                }
+        let dimension_usize = usize::try_from(dimension).map_err(|e| 
Error::DataInvalid {
+            message: format!("Invalid vindex dimension: {dimension}"),
+            source: Some(Box::new(e)),
+        })?;
+        if dimension_usize == 0 {
+            return Err(Error::DataInvalid {
+                message: "vindex vector dimension must be 
positive".to_string(),
+                source: None,
+            });
+        }
+        let expected_bytes = checked_vector_bytes(row_count_usize, 
dimension_usize)?;
+        let training_vector_count =
+            checked_training_vector_count(row_count_usize, 
options.train_sample_ratio)?;
+        let training_buffer_rows =
+            (VECTOR_BUFFER_BYTES / checked_vector_bytes(1, 
dimension_usize)?).max(1);
+        let training_buffer_floats = training_buffer_rows
+            .checked_mul(dimension_usize)
+            .ok_or_else(|| Error::DataInvalid {
+                message: "vindex training buffer length overflows 
usize".to_string(),
+                source: None,
             })?;
-        let mut writer = VectorIndexWriter::new(training);
-        writer
-            .add_vectors(&ids, vectors, row_count_usize)
-            .map_err(|e| Error::DataInvalid {
-                message: format!("Failed to add vectors to vindex index: {e}"),
+
+        let mut trainer =
+            VectorIndexTrainer::new(options.config.clone()).map_err(|e| 
Error::DataInvalid {
+                message: format!("Failed to initialize vindex trainer: {e}"),
                 source: Some(Box::new(e)),
             })?;
-        let mut bytes = Vec::new();
+        let raw_file = tempfile::tempfile().map_err(|e| Error::UnexpectedError 
{
+            message: format!("Failed to create temporary vindex vector file: 
{e}"),
+            source: Some(Box::new(e)),
+        })?;
+        let mut raw_file = tokio::fs::File::from_std(raw_file);
+        let split = data_split_for_shard(shard)?;
+        let mut read_builder = self.table.new_read_builder();
+        read_builder.with_projection(&[index_column, ROW_ID_FIELD_NAME])?;
+        let read = read_builder.new_read()?;
+        let mut batches = read.to_arrow(&[split])?;
+        let mut expected_row_id = shard.row_range_start;
+        let mut rows_seen = 0usize;
+        let mut bytes_written = 0usize;
+        let mut next_training_sample = 0usize;
+        let mut training_buffer = Vec::with_capacity(training_buffer_floats);
+
+        while let Some(batch) = batches.try_next().await? {
+            let vectors =
+                validate_vector_batch(&batch, index_column, dimension_usize, 
&mut expected_row_id)?;
+            let batch_end =
+                rows_seen
+                    .checked_add(vectors.row_count)
+                    .ok_or_else(|| Error::DataInvalid {
+                        message: "vindex streamed row count overflows 
usize".to_string(),
+                        source: None,
+                    })?;
+
+            if training_vector_count == row_count_usize {
+                trainer
+                    .add_training_vectors_mut(vectors.values, 
vectors.row_count)
+                    .map_err(|e| Error::DataInvalid {
+                        message: format!("Failed to add vindex training 
vectors: {e}"),
+                        source: Some(Box::new(e)),
+                    })?;
+            } else {
+                while next_training_sample < training_vector_count {
+                    let sample_row = checked_training_sample_index(
+                        next_training_sample,
+                        row_count_usize,
+                        training_vector_count,
+                    )?;
+                    if sample_row >= batch_end {
+                        break;
+                    }
+                    let start = (sample_row - rows_seen) * dimension_usize;
+                    training_buffer
+                        .extend_from_slice(&vectors.values[start..start + 
dimension_usize]);
+                    next_training_sample += 1;
+                    if training_buffer.len() == training_buffer_floats {
+                        trainer
+                            .add_training_vectors_mut(
+                                &training_buffer,
+                                training_buffer.len() / dimension_usize,
+                            )
+                            .map_err(|e| Error::DataInvalid {
+                                message: format!("Failed to add vindex 
training vectors: {e}"),
+                                source: Some(Box::new(e)),
+                            })?;
+                        training_buffer.clear();
+                    }
+                }
+            }
+
+            raw_file
+                .write_all(vectors.bytes)
+                .await
+                .map_err(|e| Error::UnexpectedError {
+                    message: format!("Failed to spill vindex vectors: {e}"),
+                    source: Some(Box::new(e)),
+                })?;
+            bytes_written = bytes_written
+                .checked_add(vectors.bytes.len())
+                .ok_or_else(|| Error::DataInvalid {
+                    message: "vindex spilled byte count overflows 
usize".to_string(),
+                    source: None,
+                })?;
+            rows_seen = batch_end;
+        }
+
+        if !training_buffer.is_empty() {
+            trainer
+                .add_training_vectors_mut(&training_buffer, 
training_buffer.len() / dimension_usize)
+                .map_err(|e| Error::DataInvalid {
+                    message: format!("Failed to add vindex training vectors: 
{e}"),
+                    source: Some(Box::new(e)),
+                })?;
+        }
+        if rows_seen != row_count_usize
+            || expected_row_id
+                != shard
+                    .row_range_end
+                    .checked_add(1)
+                    .ok_or_else(|| Error::DataInvalid {
+                        message: "vindex row range end overflows 
i64".to_string(),
+                        source: None,
+                    })?
+            || (training_vector_count != row_count_usize
+                && next_training_sample != training_vector_count)
+            || bytes_written != expected_bytes
         {
-            let mut output = PosWriter::new(&mut bytes);
-            writer.write(&mut output).map_err(|e| Error::DataInvalid {
-                message: format!("Failed to serialize vindex index: {e}"),
+            return Err(Error::DataInvalid {
+                message: format!(
+                    "vindex streamed data mismatch: 
rows={rows_seen}/{row_count_usize}, 
training={next_training_sample}/{training_vector_count}, 
bytes={bytes_written}/{expected_bytes}"
+                ),
+                source: None,
+            });
+        }
+        raw_file.flush().await.map_err(|e| Error::UnexpectedError {
+            message: format!("Failed to flush temporary vindex vector file: 
{e}"),
+            source: Some(Box::new(e)),
+        })?;
+        let raw_file_len = raw_file
+            .metadata()
+            .await
+            .map_err(|e| Error::UnexpectedError {
+                message: format!("Failed to inspect temporary vindex vector 
file: {e}"),
                 source: Some(Box::new(e)),
-            })?;
+            })?
+            .len();
+        if raw_file_len != expected_bytes as u64 {
+            return Err(Error::DataInvalid {
+                message: format!(
+                    "temporary vindex vector file size mismatch: 
{raw_file_len}/{expected_bytes}"
+                ),
+                source: None,
+            });
         }
+        let raw_file = raw_file.into_std().await;
+
+        let writer = tokio::task::spawn_blocking(move || -> 
std::io::Result<VectorIndexWriter> {
+            let training = trainer.finish()?;
+            let mut writer = VectorIndexWriter::new(training);
+            let mut raw_file = raw_file;
+            raw_file.seek(SeekFrom::Start(0))?;
+            let batch_rows = training_buffer_rows.min(row_count_usize);
+            let batch_bytes = checked_std_vector_bytes(batch_rows, 
dimension_usize)?;
+            let mut buffer = MutableBuffer::new(batch_bytes);
+            let mut ids = Vec::with_capacity(batch_rows);
+            let mut rows_added = 0usize;
+            while rows_added < row_count_usize {
+                let rows = batch_rows.min(row_count_usize - rows_added);
+                buffer.resize(checked_std_vector_bytes(rows, 
dimension_usize)?, 0);
+                raw_file.read_exact(buffer.as_slice_mut())?;
+                ids.clear();
+                for row in rows_added..rows_added + rows {
+                    ids.push(i64::try_from(row).map_err(|_| {
+                        std::io::Error::new(
+                            std::io::ErrorKind::InvalidData,
+                            "vindex row id does not fit i64",
+                        )
+                    })?);
+                }
+                writer.add_vectors(&ids, buffer.typed_data::<f32>(), rows)?;
+                rows_added += rows;
+            }
+            let mut trailing = [0u8; 1];
+            if raw_file.read(&mut trailing)? != 0 {
+                return Err(std::io::Error::new(
+                    std::io::ErrorKind::InvalidData,
+                    "temporary vindex vector file contains trailing bytes",
+                ));
+            }
+            Ok(writer)
+        })
+        .await
+        .map_err(|e| Error::UnexpectedError {
+            message: format!("vindex training task failed: {e}"),
+            source: None,
+        })?
+        .map_err(|e| Error::UnexpectedError {
+            message: format!("Failed to train or add vectors to vindex index: 
{e}"),
+            source: Some(Box::new(e)),
+        })?;
 
         self.table
             .file_io()
@@ -245,13 +431,38 @@ impl<'a> VindexIndexBuildBuilder<'a> {
             self.table.location().trim_end_matches('/'),
             file_name
         );
-        self.table
-            .file_io()
-            .new_output(&index_path)?
-            .write(Bytes::from(bytes))
-            .await?;
-
-        let status = self.table.file_io().get_status(&index_path).await?;
+        let write_result = async {
+            let async_writer = self
+                .table
+                .file_io()
+                .new_output(&index_path)?
+                .async_writer()
+                .await?;
+            let mut output = SyncIoBridge::new(async_writer);
+            tokio::task::spawn_blocking(move || -> std::io::Result<()> {
+                let mut writer = writer;
+                writer.write(&mut PosWriter::new(&mut output))?;
+                output.shutdown()
+            })
+            .await
+            .map_err(|e| Error::UnexpectedError {
+                message: format!("vindex serialization task failed: {e}"),
+                source: None,
+            })?
+            .map_err(|e| Error::UnexpectedError {
+                message: format!("Failed to stream vindex index: {e}"),
+                source: Some(Box::new(e)),
+            })?;
+            self.table.file_io().get_status(&index_path).await
+        }
+        .await;
+        let status = match write_result {
+            Ok(status) => status,
+            Err(error) => {
+                let _ = self.table.file_io().delete_file(&index_path).await;
+                return Err(error);
+            }
+        };
         Ok(IndexFileMeta {
             index_type: self.index_type.clone(),
             file_name,
@@ -546,13 +757,8 @@ fn bucket_path(
     ))
 }
 
-async fn extract_vectors(
-    table: &Table,
-    shard: &VindexIndexShard,
-    index_column: &str,
-    dimension: i32,
-) -> Result<Vec<f32>> {
-    let split = DataSplitBuilder::new()
+fn data_split_for_shard(shard: &VindexIndexShard) -> Result<DataSplit> {
+    DataSplitBuilder::new()
         .with_snapshot(shard.snapshot_id)
         .with_partition(shard.partition.clone())
         .with_bucket(shard.source_bucket)
@@ -563,162 +769,209 @@ async fn extract_vectors(
             shard.row_range_start,
             shard.row_range_end,
         )])
-        .build()?;
-
-    let mut read_builder = table.new_read_builder();
-    read_builder.with_projection(&[index_column, ROW_ID_FIELD_NAME])?;
-    let read = read_builder.new_read()?;
-    let batches = read.to_arrow(&[split])?.try_collect::<Vec<_>>().await?;
-    extract_vectors_from_batches(
-        &batches,
-        index_column,
-        dimension,
-        shard.row_range_start,
-        i64::from(checked_row_count(
-            shard.row_range_start,
-            shard.row_range_end,
-        )?),
-    )
+        .build()
 }
 
-fn extract_vectors_from_batches(
-    batches: &[RecordBatch],
+struct ValidatedVectorBatch<'a> {
+    values: &'a [f32],
+    bytes: &'a [u8],
+    row_count: usize,
+}
+
+fn validate_vector_batch<'a>(
+    batch: &'a RecordBatch,
     index_column: &str,
-    dimension: i32,
-    row_range_start: i64,
-    expected_row_count: i64,
-) -> Result<Vec<f32>> {
-    let dimension = usize::try_from(dimension).map_err(|e| Error::DataInvalid {
-        message: format!("Invalid vindex dimension: {dimension}"),
-        source: Some(Box::new(e)),
-    })?;
-    let row_count = batches.iter().map(RecordBatch::num_rows).sum::<usize>();
-    let mut vectors = Vec::with_capacity(row_count * dimension);
-    let mut expected_row_id = row_range_start;
-    for batch in batches {
-        let vector_index =
-            batch
-                .schema()
-                .index_of(index_column)
-                .map_err(|e| Error::DataInvalid {
-                    message: format!("Vector column '{index_column}' not found 
in read batch: {e}"),
-                    source: None,
-                })?;
-        let row_id_index =
-            batch
-                .schema()
-                .index_of(ROW_ID_FIELD_NAME)
-                .map_err(|e| Error::DataInvalid {
-                    message: format!("_ROW_ID column not found in read batch: 
{e}"),
+    dimension: usize,
+    expected_row_id: &mut i64,
+) -> Result<ValidatedVectorBatch<'a>> {
+    let vector_index = batch
+        .schema()
+        .index_of(index_column)
+        .map_err(|e| Error::DataInvalid {
+            message: format!("Vector column '{index_column}' not found in read 
batch: {e}"),
+            source: None,
+        })?;
+    let row_id_index =
+        batch
+            .schema()
+            .index_of(ROW_ID_FIELD_NAME)
+            .map_err(|e| Error::DataInvalid {
+                message: format!("_ROW_ID column not found in read batch: 
{e}"),
+                source: None,
+            })?;
+    let column = batch.column(vector_index);
+    let (values, start, end) = if let Some(array) = 
column.as_any().downcast_ref::<ListArray>() {
+        if array.null_count() != 0 {
+            return Err(Error::DataInvalid {
+                message: "vindex vector extraction found null vector 
row".to_string(),
+                source: None,
+            });
+        }
+        let offsets = array.value_offsets();
+        for offsets in offsets.windows(2) {
+            let actual = offsets[1] - offsets[0];
+            if actual != dimension as i32 {
+                return Err(Error::DataInvalid {
+                    message: format!(
+                        "vindex vector dimension mismatch: expected 
{dimension}, got {actual}"
+                    ),
                     source: None,
-                })?;
-        let column = batch.column(vector_index);
-        enum VectorLayout<'a> {
-            List(&'a ListArray),
-            Fixed(&'a FixedSizeListArray),
+                });
+            }
         }
-        let layout = if let Some(a) = 
column.as_any().downcast_ref::<ListArray>() {
-            VectorLayout::List(a)
-        } else if let Some(a) = 
column.as_any().downcast_ref::<FixedSizeListArray>() {
-            VectorLayout::Fixed(a)
-        } else {
+        let start = usize::try_from(offsets[0]).map_err(|e| Error::DataInvalid 
{
+            message: "vindex vector offset is negative".to_string(),
+            source: Some(Box::new(e)),
+        })?;
+        let end = usize::try_from(offsets[offsets.len() - 1]).map_err(|e| 
Error::DataInvalid {
+            message: "vindex vector offset is negative".to_string(),
+            source: Some(Box::new(e)),
+        })?;
+        (array.values(), start, end)
+    } else if let Some(array) = 
column.as_any().downcast_ref::<FixedSizeListArray>() {
+        let actual = usize::try_from(array.value_length()).map_err(|e| 
Error::DataInvalid {
+            message: format!(
+                "Invalid vindex FixedSizeList dimension: {}",
+                array.value_length()
+            ),
+            source: Some(Box::new(e)),
+        })?;
+        if actual != dimension {
             return Err(Error::DataInvalid {
-                message:
-                    "vindex vector extraction requires Arrow List<Float32> or 
FixedSizeList<Float32>"
-                        .to_string(),
+                message: format!(
+                    "vindex vector dimension mismatch: expected {dimension}, 
got {actual}"
+                ),
                 source: None,
             });
-        };
-        let values = match layout {
-            VectorLayout::List(a) => a.values(),
-            VectorLayout::Fixed(a) => a.values(),
         }
+        if array.null_count() != 0 {
+            return Err(Error::DataInvalid {
+                message: "vindex vector extraction found null vector 
row".to_string(),
+                source: None,
+            });
+        }
+        let end = batch
+            .num_rows()
+            .checked_mul(dimension)
+            .ok_or_else(|| Error::DataInvalid {
+                message: "vindex batch vector length overflows 
usize".to_string(),
+                source: None,
+            })?;
+        (array.values(), 0, end)
+    } else {
+        return Err(Error::DataInvalid {
+            message:
+                "vindex vector extraction requires Arrow List<Float32> or 
FixedSizeList<Float32>"
+                    .to_string(),
+            source: None,
+        });
+    };
+    let values = values
         .as_any()
         .downcast_ref::<Float32Array>()
         .ok_or_else(|| Error::DataInvalid {
             message: "vindex vector extraction requires Float32 vector 
elements".to_string(),
             source: None,
         })?;
-        let row_ids = batch
-            .column(row_id_index)
-            .as_any()
-            .downcast_ref::<Int64Array>()
+    if values.null_count() != 0
+        && values
+            .nulls()
+            .is_some_and(|nulls| nulls.slice(start, end - start).null_count() 
!= 0)
+    {
+        return Err(Error::DataInvalid {
+            message: "vindex vector extraction found null vector 
element".to_string(),
+            source: None,
+        });
+    }
+    let row_ids = batch
+        .column(row_id_index)
+        .as_any()
+        .downcast_ref::<Int64Array>()
+        .ok_or_else(|| Error::DataInvalid {
+            message: "vindex vector extraction requires non-null Int64 
_ROW_ID".to_string(),
+            source: None,
+        })?;
+    if row_ids.null_count() != 0 {
+        return Err(Error::DataInvalid {
+            message: "vindex vector extraction found null _ROW_ID".to_string(),
+            source: None,
+        });
+    }
+    for row_id in row_ids.values() {
+        if *row_id != *expected_row_id {
+            return Err(Error::DataInvalid {
+                message: format!(
+                    "vindex vector extraction expected _ROW_ID {}, got {}",
+                    expected_row_id, row_id
+                ),
+                source: None,
+            });
+        }
+        *expected_row_id = expected_row_id
+            .checked_add(1)
             .ok_or_else(|| Error::DataInvalid {
-                message: "vindex vector extraction requires non-null Int64 
_ROW_ID".to_string(),
+                message: "vindex expected row id overflows i64".to_string(),
                 source: None,
             })?;
+    }
 
-        for row in 0..batch.num_rows() {
-            if row_ids.is_null(row) {
-                return Err(Error::DataInvalid {
-                    message: "vindex vector extraction found null 
_ROW_ID".to_string(),
-                    source: None,
-                });
-            }
-            let row_id = row_ids.value(row);
-            if row_id != expected_row_id {
-                return Err(Error::DataInvalid {
-                    message: format!(
-                        "vindex vector extraction expected _ROW_ID {}, got {}",
-                        expected_row_id, row_id
-                    ),
-                    source: None,
-                });
-            }
-            expected_row_id += 1;
+    let byte_start = checked_vector_bytes(start, 1)?;
+    let byte_end = checked_vector_bytes(end, 1)?;
+    Ok(ValidatedVectorBatch {
+        values: &values.values()[start..end],
+        bytes: &values.values().inner().as_slice()[byte_start..byte_end],
+        row_count: batch.num_rows(),
+    })
+}
 
-            let is_null = match layout {
-                VectorLayout::List(a) => a.is_null(row),
-                VectorLayout::Fixed(a) => a.is_null(row),
-            };
-            if is_null {
-                return Err(Error::DataInvalid {
-                    message: "vindex vector extraction found null vector 
row".to_string(),
-                    source: None,
-                });
-            }
-            let (start, end) = match layout {
-                VectorLayout::List(a) => {
-                    let offsets = a.value_offsets();
-                    (offsets[row] as usize, offsets[row + 1] as usize)
-                }
-                VectorLayout::Fixed(a) => {
-                    let len = a.value_length() as usize;
-                    (row * len, (row + 1) * len)
-                }
-            };
-            if end - start != dimension {
-                return Err(Error::DataInvalid {
-                    message: format!(
-                        "vindex vector dimension mismatch: expected {}, got 
{}",
-                        dimension,
-                        end - start
-                    ),
-                    source: None,
-                });
-            }
-            for value_index in start..end {
-                if values.is_null(value_index) {
-                    return Err(Error::DataInvalid {
-                        message: "vindex vector extraction found null vector 
element".to_string(),
-                        source: None,
-                    });
-                }
-                vectors.push(values.value(value_index));
-            }
-        }
-    }
-    let actual_row_count = expected_row_id - row_range_start;
-    if actual_row_count != expected_row_count {
+fn checked_vector_bytes(row_count: usize, dimension: usize) -> Result<usize> {
+    row_count
+        .checked_mul(dimension)
+        .and_then(|values| values.checked_mul(std::mem::size_of::<f32>()))
+        .ok_or_else(|| Error::DataInvalid {
+            message: format!(
+                "vindex vector byte length overflows: row_count={row_count}, 
dimension={dimension}"
+            ),
+            source: None,
+        })
+}
+
+fn checked_std_vector_bytes(row_count: usize, dimension: usize) -> 
std::io::Result<usize> {
+    row_count
+        .checked_mul(dimension)
+        .and_then(|values| values.checked_mul(std::mem::size_of::<f32>()))
+        .ok_or_else(|| {
+            std::io::Error::new(
+                std::io::ErrorKind::InvalidInput,
+                "vindex vector byte length overflows usize",
+            )
+        })
+}
+
+fn checked_training_vector_count(row_count: usize, ratio: f64) -> 
Result<usize> {
+    if row_count == 0 || !(ratio > 0.0 && ratio <= 1.0) {
         return Err(Error::DataInvalid {
             message: format!(
-                "vindex vector extraction expected {} rows, got {}",
-                expected_row_count, actual_row_count
+                "Invalid vindex training sample: row_count={row_count}, 
ratio={ratio}; expected a positive row count and ratio in (0, 1]"
             ),
             source: None,
         });
     }
-    Ok(vectors)
+    Ok(((row_count as f64 * ratio).ceil() as usize).clamp(1, row_count))
+}
+
+fn checked_training_sample_index(sample: usize, rows: usize, samples: usize) 
-> Result<usize> {
+    sample
+        .checked_mul(rows / samples)
+        .and_then(|base| {
+            sample
+                .checked_mul(rows % samples)
+                .and_then(|remainder| base.checked_add(remainder / samples))
+        })
+        .ok_or_else(|| Error::DataInvalid {
+            message: "vindex training sample index overflows 
usize".to_string(),
+            source: None,
+        })
 }
 
 fn checked_i32(value: u64, context: &str) -> Result<i32> {
@@ -742,49 +995,16 @@ fn checked_row_count(row_range_start: i64, row_range_end: 
i64) -> Result<i32> {
             source: None,
         });
     }
-    i32::try_from(row_range_end - row_range_start + 1).map_err(|_| 
Error::DataInvalid {
-        message: format!(
-            "vindex row count is too large for Rust IndexFileMeta: 
[{row_range_start}, {row_range_end}]"
-        ),
-        source: None,
-    })
-}
-
-fn validate_vector_buffer(vectors: &[f32], row_count: i32, dimension: i32) -> 
Result<()> {
-    if row_count <= 0 {
-        return Err(Error::DataInvalid {
-            message: format!("vindex shard row count must be positive, got: 
{row_count}"),
-            source: None,
-        });
-    }
-    if dimension <= 0 {
-        return Err(Error::DataInvalid {
-            message: format!("vindex vector dimension must be positive, got: 
{dimension}"),
-            source: None,
-        });
-    }
-    let row_count = row_count as usize;
-    let dimension = dimension as usize;
-    let expected_len = row_count
-        .checked_mul(dimension)
+    row_range_end
+        .checked_sub(row_range_start)
+        .and_then(|count| count.checked_add(1))
+        .and_then(|count| i32::try_from(count).ok())
         .ok_or_else(|| Error::DataInvalid {
             message: format!(
-                "vindex vector buffer length overflows: row_count={row_count}, 
dimension={dimension}"
+                "vindex row count is too large for Rust IndexFileMeta: 
[{row_range_start}, {row_range_end}]"
             ),
             source: None,
-        })?;
-    if vectors.len() != expected_len {
-        return Err(Error::DataInvalid {
-            message: format!(
-                "vindex vector buffer length {} does not match row_count={} 
and dimension={}",
-                vectors.len(),
-                row_count,
-                dimension
-            ),
-            source: None,
-        });
-    }
-    Ok(())
+        })
 }
 
 #[cfg(test)]
@@ -798,7 +1018,7 @@ mod tests {
     };
     use crate::table::TableWrite;
     use crate::vindex::IVF_FLAT_IDENTIFIER;
-    use arrow_array::builder::{Float32Builder, Int64Builder, ListBuilder};
+    use arrow_array::builder::{FixedSizeListBuilder, Float32Builder, 
Int64Builder, ListBuilder};
     use arrow_array::{ArrayRef, Int32Array};
     use arrow_schema::{DataType as ArrowDataType, Field as ArrowField, Schema 
as ArrowSchema};
     use chrono::{DateTime, Utc};
@@ -968,6 +1188,36 @@ mod tests {
         .unwrap()
     }
 
+    fn extract_vectors_from_batches(
+        batches: &[RecordBatch],
+        index_column: &str,
+        dimension: i32,
+        row_range_start: i64,
+        expected_row_count: i64,
+    ) -> Result<Vec<f32>> {
+        let dimension = usize::try_from(dimension).map_err(|e| 
Error::DataInvalid {
+            message: format!("Invalid vindex dimension: {dimension}"),
+            source: Some(Box::new(e)),
+        })?;
+        let mut expected_row_id = row_range_start;
+        let mut vectors = Vec::new();
+        for batch in batches {
+            vectors.extend_from_slice(
+                validate_vector_batch(batch, index_column, dimension, &mut 
expected_row_id)?.values,
+            );
+        }
+        if expected_row_id - row_range_start != expected_row_count {
+            return Err(Error::DataInvalid {
+                message: format!(
+                    "vindex vector extraction expected {expected_row_count} 
rows, got {}",
+                    expected_row_id - row_range_start
+                ),
+                source: None,
+            });
+        }
+        Ok(vectors)
+    }
+
     #[test]
     fn test_extract_vectors_accepts_list_float32_and_row_ids() {
         let batch = vector_batch(
@@ -995,6 +1245,72 @@ mod tests {
         );
     }
 
+    #[test]
+    fn test_extract_vectors_handles_sliced_list_offsets() {
+        let batch = vector_batch(
+            vec![
+                Some(vec![None, Some(0.0)]),
+                Some(vec![Some(1.0), Some(2.0)]),
+                Some(vec![Some(3.0), Some(4.0)]),
+            ],
+            vec![Some(9), Some(10), Some(11)],
+        )
+        .slice(1, 2);
+
+        let vectors = extract_vectors_from_batches(&[batch], "embedding", 2, 
10, 2).unwrap();
+
+        assert_eq!(vectors, vec![1.0, 2.0, 3.0, 4.0]);
+    }
+
+    #[test]
+    fn test_extract_vectors_handles_sliced_fixed_size_list() {
+        let mut vectors = FixedSizeListBuilder::new(Float32Builder::new(), 2);
+        for row in [[0.0, 0.0], [1.0, 2.0], [3.0, 4.0]] {
+            vectors.values().append_slice(&row);
+            vectors.append(true);
+        }
+        let schema = Arc::new(ArrowSchema::new(vec![
+            ArrowField::new(
+                "embedding",
+                ArrowDataType::FixedSizeList(
+                    Arc::new(ArrowField::new("item", ArrowDataType::Float32, 
true)),
+                    2,
+                ),
+                true,
+            ),
+            ArrowField::new(ROW_ID_FIELD_NAME, ArrowDataType::Int64, false),
+        ]));
+        let batch = RecordBatch::try_new(
+            schema,
+            vec![
+                Arc::new(vectors.finish()) as ArrayRef,
+                Arc::new(Int64Array::from(vec![9, 10, 11])) as ArrayRef,
+            ],
+        )
+        .unwrap()
+        .slice(1, 2);
+
+        let vectors = extract_vectors_from_batches(&[batch], "embedding", 2, 
10, 2).unwrap();
+
+        assert_eq!(vectors, vec![1.0, 2.0, 3.0, 4.0]);
+    }
+
+    #[test]
+    fn test_training_sample_count_and_indexes_match_java() {
+        assert_eq!(checked_training_vector_count(10, 0.01).unwrap(), 1);
+        assert_eq!(checked_training_vector_count(10, 0.25).unwrap(), 3);
+        assert_eq!(checked_training_vector_count(10, 1.0).unwrap(), 10);
+        assert_eq!(checked_training_vector_count(3, 0.9).unwrap(), 3);
+        assert_eq!(
+            (0..4)
+                .map(|sample| checked_training_sample_index(sample, 10, 
4).unwrap())
+                .collect::<Vec<_>>(),
+            vec![0, 2, 5, 7]
+        );
+        assert!(checked_vector_bytes(usize::MAX, 2).is_err());
+        assert!(checked_training_sample_index(usize::MAX, usize::MAX, 
1).is_err());
+    }
+
     fn test_table_with_io(file_io: FileIO, table_path: &str, schema: Schema) 
-> Table {
         Table::new(
             file_io,
@@ -1177,12 +1493,8 @@ mod tests {
         let table = vindex_e2e_table(table_path, "10");
         setup_dirs(table.file_io(), table_path).await;
 
-        write_vectors(
-            &table,
-            vec![1, 2, 3],
-            vec![vec![1.0, 0.0], vec![0.0, 1.0], vec![1.0, 1.0]],
-        )
-        .await;
+        write_vectors(&table, vec![1, 2], vec![vec![1.0, 0.0], vec![0.0, 
1.0]]).await;
+        write_vectors(&table, vec![3], vec![vec![1.0, 1.0]]).await;
 
         // Fully index the coverage via a synthetic manifest entry.
         let coverage = data_row_id_coverage(&table).await;
@@ -1240,6 +1552,10 @@ mod tests {
         let first_built = table
             .new_vindex_index_build_builder(IVF_FLAT_IDENTIFIER)
             .with_index_column("embedding")
+            .with_options(HashMap::from([(
+                "ivf-flat.train.sample-ratio".to_string(),
+                "0.9".to_string(),
+            )]))
             .execute()
             .await
             .unwrap();
@@ -1255,7 +1571,7 @@ mod tests {
             .iter()
             .map(|f| f.file_name.clone())
             .collect::<std::collections::BTreeSet<_>>();
-        assert!(!first_names.is_empty(), "build #1 must write index files");
+        assert_eq!(first_names.len(), 1, "one shard must write one index 
file");
 
         // Append a second batch (new row-ids [n..]).
         write_vectors(
@@ -1307,6 +1623,34 @@ mod tests {
         }
     }
 
+    #[tokio::test]
+    async fn vindex_build_cleans_written_shards_when_later_shard_fails() {
+        let table_path = "memory:/test_vindex_abort_written_shard";
+        let table = vindex_e2e_table(table_path, "2");
+        setup_dirs(table.file_io(), table_path).await;
+        write_vectors(
+            &table,
+            vec![1, 2, 3],
+            vec![vec![1.0, 0.0], vec![0.0, 1.0], vec![1.0]],
+        )
+        .await;
+
+        let error = table
+            .new_vindex_index_build_builder(IVF_FLAT_IDENTIFIER)
+            .with_index_column("embedding")
+            .execute()
+            .await
+            .expect_err("the second shard has an invalid vector dimension");
+
+        assert!(error.to_string().contains("dimension mismatch"));
+        assert!(table
+            .file_io()
+            .list_status(&format!("{table_path}/{INDEX_DIR}/"))
+            .await
+            .unwrap()
+            .is_empty());
+    }
+
     /// A field that already carries a DIFFERENT index type (`lumina`) over an
     /// overlapping row range must not block a vindex (`ivf-flat`) build on the
     /// same field: the two indexes have distinct identities and coexist. 
Before
diff --git a/crates/paimon/src/vindex/mod.rs b/crates/paimon/src/vindex/mod.rs
index c6db3adf..5b51ea21 100644
--- a/crates/paimon/src/vindex/mod.rs
+++ b/crates/paimon/src/vindex/mod.rs
@@ -33,6 +33,7 @@ const DEFAULT_METRIC: &str = "inner_product";
 const DEFAULT_NLIST: &str = "256";
 const DEFAULT_PQ_M: &str = "16";
 const DEFAULT_PQ_USE_OPQ: &str = "false";
+const DEFAULT_TRAIN_SAMPLE_RATIO: f64 = 1.0;
 
 pub fn is_vindex_index_type(index_type: &str) -> bool {
     matches!(index_type, IVF_FLAT_IDENTIFIER | IVF_PQ_IDENTIFIER)
@@ -50,6 +51,7 @@ pub(crate) fn native_index_type(index_type: &str) -> 
Option<&'static str> {
 pub(crate) struct VindexVectorIndexOptions {
     pub config: VectorIndexConfig,
     pub native_options: HashMap<String, String>,
+    pub train_sample_ratio: f64,
 }
 
 impl VindexVectorIndexOptions {
@@ -132,9 +134,12 @@ impl VindexVectorIndexOptions {
                 source: Some(Box::new(e)),
             }
         })?;
+        let train_sample_ratio =
+            resolve_train_sample_ratio(table_options, user_options, 
index_type, field.name())?;
         Ok(Self {
             config,
             native_options,
+            train_sample_ratio,
         })
     }
 
@@ -220,12 +225,52 @@ fn is_allowed_native_key(key: &str, index_type: &str) -> 
bool {
 
 fn is_allowed_paimon_suffix(suffix: &str, index_type: &str) -> bool {
     match suffix {
-        "dimension" | "nlist" | "distance.metric" => true,
+        "dimension" | "nlist" | "distance.metric" | "train.sample-ratio" => 
true,
         "pq.m" | "pq.use-opq" => index_type == IVF_PQ_IDENTIFIER,
         _ => false,
     }
 }
 
+fn resolve_train_sample_ratio(
+    table_options: &HashMap<String, String>,
+    user_options: &HashMap<String, String>,
+    index_type: &str,
+    field_name: &str,
+) -> crate::Result<f64> {
+    let mut value = None;
+    for options in [user_options, table_options] {
+        for key in [
+            format!("fields.{field_name}.train.sample-ratio"),
+            format!("{index_type}.train.sample-ratio"),
+        ] {
+            if let Some(candidate) = options.get(&key) {
+                value = Some(candidate.as_str());
+                break;
+            }
+        }
+        if value.is_some() {
+            break;
+        }
+    }
+
+    let Some(value) = value else {
+        return Ok(DEFAULT_TRAIN_SAMPLE_RATIO);
+    };
+    let ratio = value
+        .parse::<f64>()
+        .map_err(|_| crate::Error::ConfigInvalid {
+            message: format!("Invalid vindex train.sample-ratio: '{value}'"),
+        })?;
+    if !(ratio > 0.0 && ratio <= 1.0) {
+        return Err(crate::Error::ConfigInvalid {
+            message: format!(
+                "Invalid vindex train.sample-ratio: {value}; expected a finite 
value in (0, 1]"
+            ),
+        });
+    }
+    Ok(ratio)
+}
+
 fn resolve_dimension(
     table_options: &HashMap<String, String>,
     user_options: &HashMap<String, String>,
@@ -379,6 +424,72 @@ mod tests {
         );
     }
 
+    #[test]
+    fn test_vindex_options_train_sample_ratio_default_and_precedence() {
+        let defaults = VindexVectorIndexOptions::new(
+            &HashMap::new(),
+            &HashMap::new(),
+            IVF_FLAT_IDENTIFIER,
+            &array_float_field(),
+        )
+        .unwrap();
+        assert_eq!(defaults.train_sample_ratio, 1.0);
+
+        let table_options = HashMap::from([
+            ("ivf-flat.train.sample-ratio".to_string(), "0.5".to_string()),
+            (
+                "fields.embedding.train.sample-ratio".to_string(),
+                "0.25".to_string(),
+            ),
+        ]);
+        let user_options =
+            HashMap::from([("ivf-flat.train.sample-ratio".to_string(), 
"0.1".to_string())]);
+        let options = VindexVectorIndexOptions::new(
+            &table_options,
+            &user_options,
+            IVF_FLAT_IDENTIFIER,
+            &array_float_field(),
+        )
+        .unwrap();
+        assert_eq!(options.train_sample_ratio, 0.1);
+
+        let field_user_options = HashMap::from([
+            ("ivf-flat.train.sample-ratio".to_string(), "0.5".to_string()),
+            (
+                "fields.embedding.train.sample-ratio".to_string(),
+                "1.0".to_string(),
+            ),
+        ]);
+        let options = VindexVectorIndexOptions::new(
+            &HashMap::new(),
+            &field_user_options,
+            IVF_FLAT_IDENTIFIER,
+            &array_float_field(),
+        )
+        .unwrap();
+        assert_eq!(options.train_sample_ratio, 1.0);
+        assert!(!options.native_options.contains_key("train.sample-ratio"));
+    }
+
+    #[test]
+    fn test_vindex_options_reject_invalid_train_sample_ratio() {
+        for value in ["0", "-0.1", "1.1", "NaN", "inf", "not-a-number"] {
+            let user_options =
+                HashMap::from([("ivf-flat.train.sample-ratio".to_string(), 
value.to_string())]);
+            let err = VindexVectorIndexOptions::new(
+                &HashMap::new(),
+                &user_options,
+                IVF_FLAT_IDENTIFIER,
+                &array_float_field(),
+            )
+            .expect_err("invalid ratio should be rejected");
+            assert!(
+                matches!(err, crate::Error::ConfigInvalid { message } if 
message.contains("train.sample-ratio")),
+                "value {value} returned unexpected error"
+            );
+        }
+    }
+
     #[test]
     fn test_vindex_options_vector_type_uses_type_dimension() {
         let field = DataField::new(
diff --git a/docs/src/sql.md b/docs/src/sql.md
index 6eea6d22..f69635c0 100644
--- a/docs/src/sql.md
+++ b/docs/src/sql.md
@@ -1024,6 +1024,7 @@ Supported vindex options:
 | `<index-type>.dimension` | `128` | all vindex types | Vector dimension for 
`ARRAY<FLOAT>` columns. Existing `VECTOR<FLOAT,N>` columns use `N` from the 
type. |
 | `<index-type>.distance.metric` | `inner_product` | all vindex types | 
Distance metric: `inner_product`, `cosine`, or `l2`. |
 | `<index-type>.nlist` | `256` | all vindex types | Number of IVF lists. |
+| `<index-type>.train.sample-ratio` or `fields.<field>.train.sample-ratio` | 
`1.0` | all vindex types | Fraction of shard rows selected evenly for training. 
Must be in `(0, 1]`; all rows are still added to the index. The field-specific 
option takes precedence. |
 | `<index-type>.pq.m` | `16` | `ivf-pq` | Number of product-quantization 
sub-vectors. The dimension must be divisible by this value. |
 | `<index-type>.pq.use-opq` | `false` | `ivf-pq` | Whether to enable OPQ 
before PQ encoding. |
 

Reply via email to