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 a48c01b1 perf(vindex): optimize IVF index build reads (#800)
a48c01b1 is described below
commit a48c01b1523da53335c245614701f1e7f9c316c4
Author: jerry <[email protected]>
AuthorDate: Mon Sep 21 10:25:15 2026 +0800
perf(vindex): optimize IVF index build reads (#800)
---
crates/paimon/src/arrow/format/parquet.rs | 545 ++++++-
.../paimon/src/table/vindex_index_build_builder.rs | 1 +
.../table/vindex_index_build_builder/extraction.rs | 108 +-
.../table/vindex_index_build_builder/pipeline.rs | 1670 ++++++++++++++++++++
.../src/table/vindex_index_build_builder/tests.rs | 379 ++++-
.../src/table/vindex_index_build_builder/timing.rs | 8 +-
.../src/table/vindex_index_build_builder/writer.rs | 149 +-
crates/paimon/src/vindex/mod.rs | 74 +
docs/src/sql.md | 1 +
9 files changed, 2839 insertions(+), 96 deletions(-)
diff --git a/crates/paimon/src/arrow/format/parquet.rs
b/crates/paimon/src/arrow/format/parquet.rs
index bc7403a3..34cd8335 100644
--- a/crates/paimon/src/arrow/format/parquet.rs
+++ b/crates/paimon/src/arrow/format/parquet.rs
@@ -98,6 +98,141 @@ impl ParquetFormatReader {
}
}
+#[derive(Debug, Clone)]
+pub(crate) struct ParquetGranule {
+ pub(crate) first_row: i64,
+ pub(crate) row_count: i64,
+ pub(crate) byte_ranges: Vec<Range<u64>>,
+}
+
+pub(crate) async fn parquet_granules(
+ reader: Box<dyn FileRead>,
+ file_size: u64,
+ column_name: &str,
+ page_index_enabled: bool,
+) -> crate::Result<(Vec<ParquetGranule>, bool)> {
+ let mut options = ArrowReaderOptions::new();
+ if page_index_enabled {
+ options = options.with_offset_index_policy(PageIndexPolicy::Optional);
+ }
+ let mut reader = ArrowFileReader::new(file_size, reader.into());
+ let metadata = reader.get_metadata(Some(&options)).await?;
+ let columns = metadata
+ .file_metadata()
+ .schema_descr()
+ .columns()
+ .iter()
+ .enumerate()
+ .filter_map(|(index, column)| {
+ column
+ .path()
+ .parts()
+ .first()
+ .is_some_and(|part| part == column_name)
+ .then_some(index)
+ })
+ .collect::<Vec<_>>();
+ if columns.is_empty() {
+ return Err(Error::DataInvalid {
+ message: format!("Parquet column '{column_name}' not found"),
+ source: None,
+ });
+ }
+ let Some(offset_index) = metadata
+ .offset_index()
+ .filter(|index| index.len() == metadata.row_groups().len())
+ else {
+ return Ok((row_group_granules(&metadata, &columns), false));
+ };
+
+ let mut granules = Vec::new();
+ let mut cursor = 0i64;
+ for (row_group, indexes) in metadata.row_groups().iter().zip(offset_index)
{
+ let Ok(row_count) = usize::try_from(row_group.num_rows()) else {
+ return Ok((row_group_granules(&metadata, &columns), false));
+ };
+ let Some(leaf_pages) = columns
+ .iter()
+ .map(|index| indexes.get(*index).map(|index|
index.page_locations()))
+ .collect::<Option<Vec<_>>>()
+ else {
+ return Ok((row_group_granules(&metadata, &columns), false));
+ };
+ let pages = leaf_pages[0];
+ if pages.is_empty()
+ || leaf_pages.iter().any(|other| {
+ !page_boundaries_valid(other, row_count)
+ || other.len() != pages.len()
+ || other
+ .iter()
+ .zip(pages)
+ .any(|(a, b)| a.first_row_index != b.first_row_index)
+ })
+ {
+ return Ok((row_group_granules(&metadata, &columns), false));
+ }
+ for (page_index, page) in pages.iter().enumerate() {
+ let next = pages
+ .get(page_index + 1)
+ .map_or(row_group.num_rows(), |page| page.first_row_index);
+ let mut byte_ranges = Vec::with_capacity(columns.len() * 2);
+ for (column_index, pages) in columns.iter().zip(&leaf_pages) {
+ let column = row_group.column(*column_index);
+ let (column_start, _) = column.byte_range();
+ if let Some(first) = pages.first() {
+ let Ok(first_page) = u64::try_from(first.offset) else {
+ return Ok((row_group_granules(&metadata, &columns),
false));
+ };
+ if column_start < first_page {
+ byte_ranges.push(column_start..first_page);
+ }
+ }
+ let page = &pages[page_index];
+ let (Ok(start), Ok(length)) = (
+ u64::try_from(page.offset),
+ u64::try_from(page.compressed_page_size),
+ ) else {
+ return Ok((row_group_granules(&metadata, &columns),
false));
+ };
+ let Some(end) = start.checked_add(length).filter(|end| *end <=
file_size) else {
+ return Ok((row_group_granules(&metadata, &columns),
false));
+ };
+ byte_ranges.push(start..end);
+ }
+ granules.push(ParquetGranule {
+ first_row: cursor + page.first_row_index,
+ row_count: next - page.first_row_index,
+ byte_ranges,
+ });
+ }
+ cursor += row_group.num_rows();
+ }
+ Ok((granules, true))
+}
+
+fn row_group_granules(metadata: &ParquetMetaData, columns: &[usize]) ->
Vec<ParquetGranule> {
+ let mut cursor = 0i64;
+ metadata
+ .row_groups()
+ .iter()
+ .map(|row_group| {
+ let granule = ParquetGranule {
+ first_row: cursor,
+ row_count: row_group.num_rows(),
+ byte_ranges: columns
+ .iter()
+ .map(|index| {
+ let (start, length) =
row_group.column(*index).byte_range();
+ start..start + length
+ })
+ .collect(),
+ };
+ cursor += row_group.num_rows();
+ granule
+ })
+ .collect()
+}
+
enum ParquetRowGroupMessage {
Batch(RecordBatch),
Error(Error),
@@ -631,12 +766,12 @@ impl FormatFileReader for ParquetFormatReader {
// predicate-free path and run a bounded number concurrently.
//
// Row-group receivers are consumed in order and buffer one batch each,
- // preserving positional `_ROW_ID`, sort order, and batch
backpressure. Reads
- // with predicates or an explicit row selection retain the original
- // single-stream path until their selections are split per row group.
- let read_budget = self.read_budget.as_ref().filter(|_| {
- preds.is_empty() && row_filter_factory.is_none() &&
row_selection.is_none()
- });
+ // preserving positional `_ROW_ID`, sort order, and batch backpressure.
+ // Reads with predicates retain the original single-stream path.
+ let read_budget = self
+ .read_budget
+ .as_ref()
+ .filter(|_| preds.is_empty() && row_filter_factory.is_none());
let row_group_parallelism = read_budget
.map(|budget| {
budget
@@ -644,39 +779,50 @@ impl FormatFileReader for ParquetFormatReader {
.min(batch_stream_builder.metadata().num_row_groups())
})
.unwrap_or(1);
- let projected_bytes = self
+ let selected_row_groups = self
.read_budget
.as_ref()
.filter(|budget| row_group_parallelism > 1 ||
budget.diagnostics_enabled())
.map(|budget| {
- let mut diagnostic_selection = combined_selection;
- let projected_bytes = batch_stream_builder
+ let mut row_group_selection = combined_selection;
+ let selected_row_groups = batch_stream_builder
.metadata()
.row_groups()
.iter()
- .filter(|row_group| {
- diagnostic_selection.as_mut().is_none_or(|selection| {
- selection
- .split_off(row_group.num_rows() as usize)
- .selects_any()
- })
+ .enumerate()
+ .filter_map(|(row_group_index, row_group)| {
+ let selection = row_group_selection
+ .as_mut()
+ .map(|selection|
selection.split_off(row_group.num_rows() as usize));
+ if selection
+ .as_ref()
+ .is_some_and(|selection| !selection.selects_any())
+ {
+ return None;
+ }
+ let projected_bytes =
projected_row_group_bytes(row_group, &mask);
+ Some((row_group_index, selection, projected_bytes))
})
- .map(|row_group| projected_row_group_bytes(row_group,
&mask))
+ .collect::<Vec<_>>();
+ let projected_bytes = selected_row_groups
+ .iter()
+ .map(|(_, _, projected_bytes)| *projected_bytes)
.collect::<Vec<_>>();
budget.record_projected_row_groups(&projected_bytes);
- projected_bytes
+ selected_row_groups
});
if row_group_parallelism > 1 {
- let row_group_count =
batch_stream_builder.metadata().num_row_groups();
+ let selected_row_groups =
+ selected_row_groups.expect("parallel row-group reads need a
selection plan");
+ let row_group_count = selected_row_groups.len();
let reader_metadata = ArrowReaderMetadata::try_new(
batch_stream_builder.metadata().clone(),
ArrowReaderOptions::new(),
)?;
- let projected_bytes = projected_bytes.expect("parallel row-group
reads need sizes");
let read_budget = Arc::clone(read_budget.expect("checked above"));
let (row_group_tx, mut row_group_rx) =
mpsc::channel(row_group_parallelism);
tokio::spawn(async move {
- for (row_group_index, projected_bytes) in
projected_bytes.into_iter().enumerate() {
+ for (row_group_index, selection, projected_bytes) in
selected_row_groups {
let Ok(slot) = row_group_tx.reserve().await else {
return;
};
@@ -701,6 +847,7 @@ impl FormatFileReader for ParquetFormatReader {
row_group_mask,
row_group_index,
batch_size,
+ selection,
permit,
batch_tx,
));
@@ -830,6 +977,7 @@ async fn read_row_group(
projection: ProjectionMask,
row_group_index: usize,
batch_size: Option<usize>,
+ selection: Option<RowSelection>,
_permit: ReadPermit,
sender: mpsc::Sender<ParquetRowGroupMessage>,
) {
@@ -839,6 +987,9 @@ async fn read_row_group(
)
.with_projection(projection)
.with_row_groups(vec![row_group_index]);
+ if let Some(selection) = selection {
+ builder = builder.with_row_selection(selection);
+ }
if let Some(size) = batch_size {
builder = builder.with_batch_size(size);
}
@@ -2359,6 +2510,13 @@ fn merge_byte_ranges(ranges: &[Range<u64>], coalesce:
u64) -> Vec<Range<u64>> {
merged
}
+pub(crate) fn coalesced_parquet_range_bytes(ranges: &[Range<u64>]) -> u64 {
+ merge_byte_ranges(ranges, RANGE_COALESCE_BYTES)
+ .into_iter()
+ .map(|range| range.end - range.start)
+ .sum()
+}
+
/// Split merged ranges into fixed-size batches to utilize concurrency,
/// Each merged range is divided into chunks of `expected_size`,
/// with the last chunk taking whatever remains.
@@ -2418,8 +2576,8 @@ fn split_ranges_for_concurrency(merged: Vec<Range<u64>>,
concurrency: usize) ->
mod tests {
use super::build_parquet_row_filter;
use super::{
- forward_row_group_batches, parse_compression, supported_compressions,
FilePredicates,
- ParquetFormatReader, ParquetFormatWriter, ParquetRowGroupMessage,
+ forward_row_group_batches, parquet_granules, parse_compression,
supported_compressions,
+ FilePredicates, ParquetFormatReader, ParquetFormatWriter,
ParquetRowGroupMessage,
};
use super::{
AsyncArrowWriter, Bytes, PageIndexPolicy, ParquetMetaDataReader,
Predicate,
@@ -3004,6 +3162,113 @@ mod tests {
);
}
+ #[tokio::test]
+ async fn test_sparse_row_groups_preserve_selection_order_and_budget() {
+ let data = write_multi_row_group_parquet(64, 384,
EnabledStatistics::Chunk, false).await;
+ let in_flight = Arc::new(AtomicUsize::new(0));
+ let max_in_flight = Arc::new(AtomicUsize::new(0));
+ let file_reader = ConcurrentTrackingFileRead {
+ data: Bytes::from(data),
+ in_flight,
+ max_in_flight,
+ };
+ let file_size = file_reader.data.len() as u64;
+ let ranges = vec![
+ RowRange::new(60, 68),
+ RowRange::new(130, 135),
+ RowRange::new(258, 263),
+ RowRange::new(380, 383),
+ ];
+ let budget = Arc::new(ReadBudget::new(2, 256 * 1024 * 1024).unwrap());
+ budget.enable_diagnostics();
+
+ let batches =
ParquetFormatReader::with_read_budget(Arc::clone(&budget))
+ .read_batch_stream(
+ Box::new(file_reader),
+ file_size,
+ &[int_field("id")],
+ None,
+ Some(32),
+ Some(ranges.clone()),
+ )
+ .await
+ .unwrap()
+ .try_collect::<Vec<_>>()
+ .await
+ .unwrap();
+ let actual = batches
+ .iter()
+ .flat_map(|batch| {
+ batch
+ .column(0)
+ .as_any()
+ .downcast_ref::<Int32Array>()
+ .unwrap()
+ .values()
+ .iter()
+ .copied()
+ .collect::<Vec<_>>()
+ })
+ .collect::<Vec<_>>();
+ let expected = ranges
+ .iter()
+ .flat_map(|range| range.from() as i32..=range.to() as i32)
+ .collect::<Vec<_>>();
+
+ assert_eq!(actual, expected);
+ let diagnostics = budget.diagnostics();
+ assert_eq!(diagnostics.row_group_count, 5);
+ assert_eq!(diagnostics.peak_inflight, 2);
+ assert_eq!(diagnostics.current_inflight, 0);
+ }
+
+ #[tokio::test]
+ async fn test_sparse_page_budget_charges_full_projected_row_group() {
+ const MIB: i64 = 1024 * 1024;
+
+ let bytes = write_multi_page_parquet(10, 80).await;
+ let metadata = load_metadata_with_page_index(&bytes, true);
+ let offset_index = &metadata.offset_index().unwrap()[0];
+ let page_locations = offset_index[0].page_locations();
+ let compressed_bytes = page_locations
+ .iter()
+ .map(|page| i64::from(page.compressed_page_size))
+ .sum();
+ let mut row_group = metadata.row_groups()[0].clone();
+ let column = row_group
+ .column(0)
+ .clone()
+ .into_builder()
+ .set_total_compressed_size(compressed_bytes)
+ .set_total_uncompressed_size(308 * MIB)
+ .set_data_page_offset(page_locations[0].offset)
+ .set_dictionary_page_offset(None)
+ .build()
+ .unwrap();
+ row_group.columns_mut()[0] = column;
+
+ let projection =
super::ProjectionMask::roots(row_group.schema_descr(), [0]);
+ let projected_bytes = super::projected_row_group_bytes(&row_group,
&projection);
+
+ assert_eq!(projected_bytes, 308 * MIB as u64);
+ let budget = ReadBudget::new(8, 256 * MIB as u64).unwrap();
+ budget.enable_diagnostics();
+ let permit = budget.acquire(projected_bytes).await.unwrap();
+ assert!(
+ tokio::time::timeout(Duration::from_millis(20), budget.acquire(1))
+ .await
+ .is_err()
+ );
+ drop(permit);
+ let permit = tokio::time::timeout(Duration::from_secs(1),
budget.acquire(projected_bytes))
+ .await
+ .unwrap()
+ .unwrap();
+ assert_eq!(budget.diagnostics().peak_inflight, 1);
+ drop(permit);
+ assert_eq!(budget.diagnostics().current_inflight, 0);
+ }
+
#[tokio::test]
async fn test_parquet_read_budget_is_shared_across_readers() {
const ROWS: i32 = 256;
@@ -3093,7 +3358,7 @@ mod tests {
#[tokio::test]
async fn test_parquet_diagnostics_include_reads_with_row_selection() {
- let data = write_multi_row_group_parquet(32, 64,
EnabledStatistics::Chunk).await;
+ let data = write_multi_row_group_parquet(32, 64,
EnabledStatistics::Chunk, false).await;
let budget = Arc::new(ReadBudget::new(8, 256 * 1024 * 1024).unwrap());
budget.enable_diagnostics();
let file_size = data.len() as u64;
@@ -3117,7 +3382,7 @@ mod tests {
let diagnostics = budget.diagnostics();
assert_eq!(diagnostics.row_group_count, 1);
assert!(diagnostics.projected_bytes_total > 0);
- assert_eq!(diagnostics.peak_inflight, 0);
+ assert_eq!(diagnostics.peak_inflight, 1);
}
#[tokio::test]
@@ -3706,11 +3971,13 @@ mod tests {
row_group_rows: usize,
total_rows: i32,
statistics: EnabledStatistics,
+ offset_index_disabled: bool,
) -> Vec<u8> {
let schema = writer_arrow_schema();
let props = parquet::file::properties::WriterProperties::builder()
.set_max_row_group_row_count(Some(row_group_rows))
.set_statistics_enabled(statistics)
+ .set_offset_index_disabled(offset_index_disabled)
.build();
let mut buf = Vec::new();
let mut writer = AsyncArrowWriter::try_new(&mut buf, schema.clone(),
Some(props)).unwrap();
@@ -3726,7 +3993,7 @@ mod tests {
#[tokio::test]
async fn test_row_group_selection_in_uses_min_max_without_page_index() {
- let bytes = write_multi_row_group_parquet(10, 20,
EnabledStatistics::Chunk).await;
+ let bytes = write_multi_row_group_parquet(10, 20,
EnabledStatistics::Chunk, false).await;
let metadata = load_metadata_with_page_index(&bytes, false);
assert_eq!(metadata.row_groups().len(), 2);
assert!(metadata.column_index().is_none());
@@ -3759,14 +4026,14 @@ mod tests {
let fields = vec![int_field("id"), int_field("value")];
let predicates = vec![id_leaf(PredicateOperator::In,
vec![Datum::Int(100)])];
- let bytes = write_multi_row_group_parquet(10, 10,
EnabledStatistics::None).await;
+ let bytes = write_multi_row_group_parquet(10, 10,
EnabledStatistics::None, false).await;
let metadata = load_metadata_with_page_index(&bytes, false);
let selection =
super::build_predicate_row_selection(metadata.row_groups(),
&predicates, &fields)
.unwrap();
assert!(selection.is_none(), "missing stats must fail open");
- let bytes = write_multi_row_group_parquet(10, 10,
EnabledStatistics::Chunk).await;
+ let bytes = write_multi_row_group_parquet(10, 10,
EnabledStatistics::Chunk, false).await;
let metadata = load_metadata_with_page_index(&bytes, false);
let mut damaged_row_group = metadata.row_groups()[0].clone();
let damaged_id_column = damaged_row_group
@@ -3831,6 +4098,78 @@ mod tests {
buf
}
+ #[tokio::test]
+ async fn
test_parquet_granules_prefers_pages_and_falls_back_to_row_groups() {
+ let bytes = Bytes::from(write_multi_page_parquet(10, 80).await);
+ let (granules, page_level) = parquet_granules(
+ Box::new(TrackingFileRead::new(bytes.clone())),
+ bytes.len() as u64,
+ "value",
+ true,
+ )
+ .await
+ .unwrap();
+ assert!(page_level);
+ assert!(granules.len() > 1);
+ assert_eq!(
+ granules
+ .iter()
+ .map(|granule| granule.row_count)
+ .sum::<i64>(),
+ 80
+ );
+ assert!(granules
+ .iter()
+ .all(|granule| !granule.byte_ranges.is_empty()));
+
+ let bytes = Bytes::from(
+ write_multi_row_group_parquet(10, 30, EnabledStatistics::Chunk,
true).await,
+ );
+ let (granules, page_level) = parquet_granules(
+ Box::new(TrackingFileRead::new(bytes.clone())),
+ bytes.len() as u64,
+ "value",
+ true,
+ )
+ .await
+ .unwrap();
+ assert!(!page_level);
+ assert_eq!(granules.len(), 3);
+ assert_eq!(
+ granules
+ .iter()
+ .map(|granule| granule.row_count)
+ .sum::<i64>(),
+ 30
+ );
+ }
+
+ #[tokio::test]
+ async fn test_parquet_granules_uses_row_groups_when_page_index_disabled() {
+ let bytes = Bytes::from(write_multi_page_parquet(10, 80).await);
+ let metadata = load_metadata_with_page_index(&bytes, true);
+ assert!(metadata.offset_index().is_some());
+ let (granules, page_level) = parquet_granules(
+ Box::new(TrackingFileRead::new(bytes.clone())),
+ bytes.len() as u64,
+ "value",
+ false,
+ )
+ .await
+ .unwrap();
+
+ assert!(!page_level);
+ assert_eq!(granules.len(), 1);
+ assert_eq!(granules[0].first_row, 0);
+ assert_eq!(granules[0].row_count, 80);
+ // Charge the complete projected column chunk, not individual selected
pages.
+ let (start, length) = metadata.row_group(0).column(1).byte_range();
+ assert_eq!(
+ granules[0].byte_ranges,
+ std::iter::once(start..start + length).collect::<Vec<_>>()
+ );
+ }
+
async fn write_page_pruning_io_parquet() -> Vec<u8> {
const ROWS: i32 = 1024;
let schema = Arc::new(ArrowSchema::new(vec![
@@ -3928,6 +4267,26 @@ mod tests {
struct TrackingFileRead {
data: Bytes,
ranges: Arc<std::sync::Mutex<Vec<std::ops::Range<u64>>>>,
+ resident_bytes: Arc<AtomicUsize>,
+ peak_resident_bytes: Arc<AtomicUsize>,
+ }
+
+ struct TrackedReadBuffer {
+ data: Box<[u8]>,
+ resident_bytes: Arc<AtomicUsize>,
+ }
+
+ impl AsRef<[u8]> for TrackedReadBuffer {
+ fn as_ref(&self) -> &[u8] {
+ &self.data
+ }
+ }
+
+ impl Drop for TrackedReadBuffer {
+ fn drop(&mut self) {
+ self.resident_bytes
+ .fetch_sub(self.data.len(), AtomicOrdering::SeqCst);
+ }
}
impl TrackingFileRead {
@@ -3935,6 +4294,8 @@ mod tests {
Self {
data,
ranges: Arc::new(std::sync::Mutex::new(Vec::new())),
+ resident_bytes: Arc::new(AtomicUsize::new(0)),
+ peak_resident_bytes: Arc::new(AtomicUsize::new(0)),
}
}
@@ -3956,7 +4317,135 @@ mod tests {
impl crate::io::FileRead for TrackingFileRead {
async fn read(&self, range: std::ops::Range<u64>) ->
crate::Result<Bytes> {
self.ranges.lock().unwrap().push(range.clone());
- Ok(self.data.slice(range.start as usize..range.end as usize))
+ // Count each source allocation until its last slice is dropped,
not slice lengths.
+ let data = self.data[range.start as usize..range.end as usize]
+ .to_vec()
+ .into_boxed_slice();
+ let current = self
+ .resident_bytes
+ .fetch_add(data.len(), AtomicOrdering::SeqCst)
+ + data.len();
+ self.peak_resident_bytes
+ .fetch_max(current, AtomicOrdering::SeqCst);
+ Ok(Bytes::from_owner(TrackedReadBuffer {
+ data,
+ resident_bytes: Arc::clone(&self.resident_bytes),
+ }))
+ }
+ }
+
+ #[tokio::test]
+ async fn test_sparse_read_buffer_owners_and_cancellation() {
+ use crate::io::FileRead;
+ use rand::{RngCore, SeedableRng};
+
+ const MIB: usize = 1024 * 1024;
+ const GROUP_ROWS: usize = 2 * MIB;
+ const PAGE_ROWS: usize = 64 * 1024;
+ const BUDGET: usize = 20 * MIB;
+
+ let schema = Arc::new(ArrowSchema::new(vec![ArrowField::new(
+ "id",
+ ArrowDataType::Int32,
+ false,
+ )]));
+ let props = parquet::file::properties::WriterProperties::builder()
+ .set_max_row_group_row_count(Some(GROUP_ROWS))
+ .set_data_page_size_limit(usize::MAX)
+ .set_data_page_row_count_limit(PAGE_ROWS)
+ .set_write_batch_size(PAGE_ROWS)
+ .set_dictionary_enabled(false)
+
.set_compression(parquet::basic::Compression::ZSTD(Default::default()))
+ .build();
+ let mut data = Vec::new();
+ let mut writer =
+ AsyncArrowWriter::try_new(&mut data, Arc::clone(&schema),
Some(props)).unwrap();
+ let mut rng = rand::rngs::StdRng::seed_from_u64(42);
+ for _ in 0..4 {
+ let values =
Int32Array::from_iter_values((0..GROUP_ROWS).map(|row| {
+ if (row / PAGE_ROWS).is_multiple_of(2) {
+ 0
+ } else {
+ rng.next_u32() as i32
+ }
+ }));
+ writer
+ .write(&RecordBatch::try_new(Arc::clone(&schema),
vec![Arc::new(values)]).unwrap())
+ .await
+ .unwrap();
+ }
+ writer.close().await.unwrap();
+ let metadata = load_metadata_with_page_index(&data, true);
+ assert_eq!(metadata.num_row_groups(), 4);
+ let pages = metadata.offset_index().unwrap()[0][0].page_locations();
+ assert!(
+ pages[1].compressed_page_size > pages[0].compressed_page_size *
100,
+ "adjacent equal-row-count pages must have very different
compression ratios"
+ );
+ let projection = super::ProjectionMask::all();
+ let projected =
super::projected_row_group_bytes(&metadata.row_groups()[0], &projection);
+ assert!(projected > 8 * MIB as u64 && projected < 9 * MIB as u64);
+
+ let data = Bytes::from(data);
+ let tracker = TrackingFileRead::new(data.clone());
+ let buffer = tracker.read(0..1024).await.unwrap();
+ let slice = buffer.slice(0..1);
+ drop(buffer);
+ assert_eq!(tracker.resident_bytes.load(AtomicOrdering::SeqCst), 1024);
+ drop(slice);
+ assert_eq!(tracker.resident_bytes.load(AtomicOrdering::SeqCst), 0);
+
+ let ranges = (0..4)
+ .flat_map(|group| {
+ (1..GROUP_ROWS / PAGE_ROWS).step_by(2).map(move |page| {
+ let start = (group * GROUP_ROWS + page * PAGE_ROWS) as i64;
+ RowRange::new(start, start + 255)
+ })
+ })
+ .collect::<Vec<_>>();
+ for cancel in [false, true] {
+ let tracker = TrackingFileRead::new(data.clone());
+ let budget = Arc::new(ReadBudget::new(8, BUDGET as u64).unwrap());
+ budget.enable_diagnostics();
+ let mut stream =
ParquetFormatReader::with_read_budget(Arc::clone(&budget))
+ .read_batch_stream(
+ Box::new(tracker.clone()),
+ data.len() as u64,
+ &[int_field("id")],
+ None,
+ Some(128),
+ Some(ranges.clone()),
+ )
+ .await
+ .unwrap();
+ let mut rows =
stream.try_next().await.unwrap().unwrap().num_rows();
+ if !cancel {
+ while let Some(batch) = stream.try_next().await.unwrap() {
+ rows += batch.num_rows();
+ }
+ assert_eq!(rows as i64,
ranges.iter().map(RowRange::count).sum::<i64>());
+ }
+ drop(stream);
+ tokio::time::timeout(Duration::from_secs(2), async {
+ while budget.diagnostics().current_inflight != 0
+ || tracker.resident_bytes.load(AtomicOrdering::SeqCst) != 0
+ {
+ tokio::task::yield_now().await;
+ }
+ })
+ .await
+ .expect("all buffer owners and row-group permits must be
released");
+ let peak =
tracker.peak_resident_bytes.load(AtomicOrdering::SeqCst);
+ assert!(
+ peak > MIB && peak <= BUDGET,
+ "resident source buffers: {peak}"
+ );
+ assert_eq!(budget.diagnostics().peak_inflight, 2);
+ let _permit =
+ tokio::time::timeout(Duration::from_secs(1),
budget.acquire(BUDGET as u64))
+ .await
+ .unwrap()
+ .unwrap();
}
}
diff --git a/crates/paimon/src/table/vindex_index_build_builder.rs
b/crates/paimon/src/table/vindex_index_build_builder.rs
index 9e06ff9a..6b255713 100644
--- a/crates/paimon/src/table/vindex_index_build_builder.rs
+++ b/crates/paimon/src/table/vindex_index_build_builder.rs
@@ -16,6 +16,7 @@
// under the License.
mod extraction;
+mod pipeline;
mod planning;
mod timing;
mod validation;
diff --git a/crates/paimon/src/table/vindex_index_build_builder/extraction.rs
b/crates/paimon/src/table/vindex_index_build_builder/extraction.rs
index cf7e910e..37a8bb51 100644
--- a/crates/paimon/src/table/vindex_index_build_builder/extraction.rs
+++ b/crates/paimon/src/table/vindex_index_build_builder/extraction.rs
@@ -23,6 +23,16 @@ use crate::{Error, Result};
use arrow_array::{Array, FixedSizeListArray, Float32Array, Int64Array,
ListArray, RecordBatch};
pub(super) fn data_split_for_shard(shard: &VindexIndexShard) ->
Result<DataSplit> {
+ data_split_for_shard_ranges(
+ shard,
+ vec![RowRange::new(shard.row_range_start, shard.row_range_end)],
+ )
+}
+
+pub(super) fn data_split_for_shard_ranges(
+ shard: &VindexIndexShard,
+ row_ranges: Vec<RowRange>,
+) -> Result<DataSplit> {
DataSplitBuilder::new()
.with_snapshot(shard.snapshot_id)
.with_partition(shard.partition.clone())
@@ -30,24 +40,97 @@ pub(super) fn data_split_for_shard(shard:
&VindexIndexShard) -> Result<DataSplit
.with_bucket_path(shard.bucket_path.clone())
.with_total_buckets(shard.total_buckets)
.with_data_files(shard.files.clone())
- .with_row_ranges(vec![RowRange::new(
- shard.row_range_start,
- shard.row_range_end,
- )])
+ .with_row_ranges(row_ranges)
.build()
}
pub(super) struct ValidatedVectorBatch<'a> {
pub(super) values: &'a [f32],
pub(super) bytes: &'a [u8],
+ pub(super) row_ids: &'a [i64],
pub(super) row_count: usize,
}
+pub(super) fn extract_vector_batch<'a>(
+ batch: &'a RecordBatch,
+ index_column: &str,
+ dimension: usize,
+) -> Result<ValidatedVectorBatch<'a>> {
+ validate_vector_batch_with(batch, index_column, dimension, |_| Ok(()))
+}
+
pub(super) fn validate_vector_batch<'a>(
batch: &'a RecordBatch,
index_column: &str,
dimension: usize,
expected_row_id: &mut i64,
+) -> Result<ValidatedVectorBatch<'a>> {
+ validate_vector_batch_with(batch, index_column, dimension, |row_id| {
+ 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 expected row id overflows i64".to_string(),
+ source: None,
+ })?;
+ Ok(())
+ })
+}
+
+pub(super) fn validate_vector_batch_ranges<'a>(
+ batch: &'a RecordBatch,
+ index_column: &str,
+ dimension: usize,
+ ranges: &[RowRange],
+ range_index: &mut usize,
+ expected_row_id: &mut i64,
+) -> Result<ValidatedVectorBatch<'a>> {
+ validate_vector_batch_with(batch, index_column, dimension, |row_id| {
+ let range = ranges.get(*range_index).ok_or_else(|| Error::DataInvalid {
+ message: format!("vindex vector extraction got unexpected _ROW_ID
{row_id}"),
+ source: None,
+ })?;
+ 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,
+ });
+ }
+ if row_id == range.to() {
+ *range_index += 1;
+ *expected_row_id = match ranges.get(*range_index) {
+ Some(next) => next.from(),
+ None => row_id.checked_add(1).ok_or_else(|| Error::DataInvalid
{
+ message: "vindex expected row id overflows
i64".to_string(),
+ source: None,
+ })?,
+ };
+ } else {
+ *expected_row_id = row_id.checked_add(1).ok_or_else(||
Error::DataInvalid {
+ message: "vindex expected row id overflows i64".to_string(),
+ source: None,
+ })?;
+ }
+ Ok(())
+ })
+}
+
+fn validate_vector_batch_with<'a>(
+ batch: &'a RecordBatch,
+ index_column: &str,
+ dimension: usize,
+ mut validate_row_id: impl FnMut(i64) -> Result<()>,
) -> Result<ValidatedVectorBatch<'a>> {
let vector_index = batch
.schema()
@@ -163,21 +246,7 @@ pub(super) fn validate_vector_batch<'a>(
});
}
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 expected row id overflows i64".to_string(),
- source: None,
- })?;
+ validate_row_id(*row_id)?;
}
let byte_start = checked_vector_bytes(start, 1)?;
@@ -185,6 +254,7 @@ pub(super) fn validate_vector_batch<'a>(
Ok(ValidatedVectorBatch {
values: &values.values()[start..end],
bytes: &values.values().inner().as_slice()[byte_start..byte_end],
+ row_ids: row_ids.values(),
row_count: batch.num_rows(),
})
}
diff --git a/crates/paimon/src/table/vindex_index_build_builder/pipeline.rs
b/crates/paimon/src/table/vindex_index_build_builder/pipeline.rs
new file mode 100644
index 00000000..ebd2b8b3
--- /dev/null
+++ b/crates/paimon/src/table/vindex_index_build_builder/pipeline.rs
@@ -0,0 +1,1670 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements. See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership. The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License. You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied. See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+use super::extraction::{
+ data_split_for_shard_ranges, extract_vector_batch,
validate_vector_batch_ranges,
+};
+use super::planning::VindexIndexShard;
+use super::timing::{vector_index_build_timing_enabled, VectorIndexBuildTiming};
+use super::validation::{
+ checked_row_count, checked_training_sample_index,
checked_training_vector_count,
+ checked_vector_bytes,
+};
+use super::writer::BuiltIndexFile;
+use super::VindexIndexBuildBuilder;
+use crate::arrow::format::parquet::{
+ coalesced_parquet_range_bytes, parquet_granules, ParquetGranule,
+};
+use crate::spec::ROW_ID_FIELD_NAME;
+use crate::table::data_file_reader::DataFileReadTiming;
+use crate::table::table_read::configured_parquet_read_budget;
+use crate::table::{merge_row_ranges, ArrowRecordBatchStream, RowRange};
+use crate::vindex::VindexVectorIndexOptions;
+use crate::{Error, Result};
+use arrow_array::RecordBatch;
+use arrow_buffer::MutableBuffer;
+use futures::{StreamExt, TryStreamExt};
+use paimon_vindex_core::autotune::default_training_vector_count;
+use paimon_vindex_core::index::{VectorIndexTrainer, VectorIndexTraining,
VectorIndexWriter};
+use std::collections::{HashMap, HashSet};
+use std::io::{BufReader, BufWriter, Read, Seek, SeekFrom, Write};
+use std::ops::Range;
+use std::sync::Arc;
+use std::time::{Duration, Instant};
+use tokio::sync::{mpsc, oneshot};
+use tokio::task::JoinHandle;
+
+const MIN_STRATA: usize = 256;
+const ROWS_PER_STRATUM: usize = 128;
+const FIRST_BYTES_NUMERATOR: u64 = 3;
+const FIRST_BYTES_DENOMINATOR: u64 = 10;
+const QUEUE_CAPACITY: usize = 2;
+const BUFFER_BYTES: usize = 8 * 1024 * 1024;
+const REPLAY_TARGET_BYTES: usize = 32 * 1024 * 1024;
+
+#[derive(Clone, Debug)]
+struct Granule {
+ range: RowRange,
+ file_index: usize,
+ byte_ranges: Vec<Range<u64>>,
+}
+
+#[derive(Debug)]
+pub(super) struct GranulePlan {
+ pub(super) first: Vec<RowRange>,
+ pub(super) rest: Vec<RowRange>,
+ first_rows: usize,
+ // None preserves full-spill's global stride sampling when first covers
the shard.
+ training: Option<Vec<i64>>,
+}
+
+struct SpillRecord {
+ ids: Vec<i64>,
+ bytes: Vec<u8>,
+}
+
+enum AddItem {
+ Batch(RecordBatch, Vec<i64>),
+ Spilled(Vec<i64>, MutableBuffer),
+}
+
+type SpillTask = oneshot::Receiver<std::io::Result<(std::fs::File, u64,
Duration)>>;
+type ConsumerTask = oneshot::Receiver<Result<(VectorIndexWriter, usize, usize,
Duration)>>;
+type TrainingTask = JoinHandle<std::io::Result<(VectorIndexTraining,
Duration)>>;
+type ReplayTask = JoinHandle<std::io::Result<(usize, Duration)>>;
+
+fn spawn_channel_worker<T, F>(name: &'static str, worker: F) ->
Result<oneshot::Receiver<T>>
+where
+ T: Send + 'static,
+ F: FnOnce() -> T + Send + 'static,
+{
+ let (sender, receiver) = oneshot::channel();
+ std::thread::Builder::new()
+ .name(name.to_string())
+ .spawn(move || {
+ let _ = sender.send(worker());
+ })
+ .map_err(|e| Error::UnexpectedError {
+ message: format!("Failed to spawn {name} worker: {e}"),
+ source: Some(Box::new(e)),
+ })?;
+ Ok(receiver)
+}
+
+struct SpillWriter {
+ sender: mpsc::Sender<SpillRecord>,
+ task: SpillTask,
+}
+
+impl SpillWriter {
+ async fn finish(self) -> Result<(std::fs::File, u64, Duration)> {
+ drop(self.sender);
+ join_spill(self.task).await
+ }
+}
+
+struct LivePipeline {
+ sender: mpsc::Sender<AddItem>,
+ consumer: ConsumerTask,
+ replay: ReplayTask,
+ spill_bytes: u64,
+ spill_write: Duration,
+}
+
+fn spawn_spill_writer(timing_enabled: bool) -> Result<SpillWriter> {
+ let file = tempfile::tempfile().map_err(|e| Error::UnexpectedError {
+ message: format!("Failed to create temporary vindex vector file: {e}"),
+ source: Some(Box::new(e)),
+ })?;
+ let (sender, mut receiver) = mpsc::channel::<SpillRecord>(QUEUE_CAPACITY);
+ let task = spawn_channel_worker("paimon-vindex-spill", move || ->
std::io::Result<_> {
+ let mut writer = BufWriter::with_capacity(BUFFER_BYTES, file);
+ let mut spill_bytes = 0u64;
+ let mut spill_write = Duration::ZERO;
+ while let Some(record) = receiver.blocking_recv() {
+ let write_start = timing_enabled.then(Instant::now);
+ let count = record.ids.len() as u64;
+ writer.write_all(&count.to_le_bytes())?;
+ for id in record.ids {
+ writer.write_all(&id.to_le_bytes())?;
+ }
+ writer.write_all(&record.bytes)?;
+ spill_bytes = spill_bytes
+ .saturating_add(8)
+ .saturating_add(count.saturating_mul(8))
+ .saturating_add(record.bytes.len() as u64);
+ if let Some(start) = write_start {
+ spill_write = spill_write.saturating_add(start.elapsed());
+ }
+ }
+ let write_start = timing_enabled.then(Instant::now);
+ writer.flush()?;
+ let file = writer.into_inner().map_err(|e| e.into_error())?;
+ if let Some(start) = write_start {
+ spill_write = spill_write.saturating_add(start.elapsed());
+ }
+ Ok((file, spill_bytes, spill_write))
+ })?;
+ Ok(SpillWriter { sender, task })
+}
+
+async fn join_spill(task: SpillTask) -> Result<(std::fs::File, u64, Duration)>
{
+ task.await
+ .map_err(|e| Error::UnexpectedError {
+ message: format!("vindex spill task failed: {e}"),
+ source: None,
+ })?
+ .map_err(|e| Error::UnexpectedError {
+ message: format!("Failed to spill vindex vectors: {e}"),
+ source: Some(Box::new(e)),
+ })
+}
+
+fn spawn_add_consumer(
+ writer: VectorIndexWriter,
+ mut receiver: mpsc::Receiver<AddItem>,
+ index_column: String,
+ dimension: usize,
+ timing_enabled: bool,
+) -> Result<ConsumerTask> {
+ spawn_channel_worker("paimon-vindex-add", move || -> Result<_> {
+ let mut writer = writer;
+ let mut rows_added = 0usize;
+ let mut replay_rows = 0usize;
+ let mut index_add = Duration::ZERO;
+ while let Some(item) = receiver.blocking_recv() {
+ let add_start = timing_enabled.then(Instant::now);
+ match item {
+ AddItem::Batch(batch, ids) => {
+ let vectors = extract_vector_batch(&batch, &index_column,
dimension)?;
+ if ids.len() != vectors.row_count {
+ return Err(Error::DataInvalid {
+ message: "vindex add batch id count
mismatch".to_string(),
+ source: None,
+ });
+ }
+ writer
+ .add_vectors(&ids, vectors.values, vectors.row_count)
+ .map_err(|e| Error::UnexpectedError {
+ message: format!("Failed to add vectors to vindex
index: {e}"),
+ source: Some(Box::new(e)),
+ })?;
+ rows_added += vectors.row_count;
+ }
+ AddItem::Spilled(ids, buffer) => {
+ let values = buffer.typed_data::<f32>();
+ if values.len() != ids.len() * dimension {
+ return Err(Error::DataInvalid {
+ message: "vindex spilled vector length
mismatch".to_string(),
+ source: None,
+ });
+ }
+ writer.add_vectors(&ids, values, ids.len()).map_err(|e| {
+ Error::UnexpectedError {
+ message: format!("Failed to add spilled vectors to
vindex index: {e}"),
+ source: Some(Box::new(e)),
+ }
+ })?;
+ rows_added += ids.len();
+ replay_rows += ids.len();
+ }
+ }
+ if let Some(start) = add_start {
+ index_add = index_add.saturating_add(start.elapsed());
+ }
+ }
+ Ok((writer, rows_added, replay_rows, index_add))
+ })
+}
+
+async fn join_consumer(task: ConsumerTask) -> Result<(VectorIndexWriter,
usize, usize, Duration)> {
+ task.await.map_err(|e| Error::UnexpectedError {
+ message: format!("vindex add task failed: {e}"),
+ source: None,
+ })?
+}
+
+async fn join_training(task: TrainingTask) -> Result<(VectorIndexTraining,
Duration)> {
+ task.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 vindex index: {e}"),
+ source: Some(Box::new(e)),
+ })
+}
+
+async fn start_live_pipeline(
+ training: TrainingTask,
+ spill: SpillWriter,
+ index_column: String,
+ dimension: usize,
+ timing_enabled: bool,
+) -> Result<(LivePipeline, Duration)> {
+ let trained = join_training(training).await;
+ let spilled = spill.finish().await;
+ let (trained, train_finish) = trained?;
+ let (file, spill_bytes, spill_write) = spilled?;
+ let (sender, receiver) = mpsc::channel(QUEUE_CAPACITY);
+ let consumer = spawn_add_consumer(
+ VectorIndexWriter::new(trained),
+ receiver,
+ index_column,
+ dimension,
+ timing_enabled,
+ )?;
+ let replay = spawn_replay(file, sender.clone(), dimension, timing_enabled);
+ Ok((
+ LivePipeline {
+ sender,
+ consumer,
+ replay,
+ spill_bytes,
+ spill_write,
+ },
+ train_finish,
+ ))
+}
+
+async fn finish_live_pipeline(
+ pipeline: LivePipeline,
+) -> (
+ Result<(VectorIndexWriter, usize, usize, Duration)>,
+ Result<(usize, Duration)>,
+ u64,
+ Duration,
+) {
+ let replay = pipeline
+ .replay
+ .await
+ .map_err(|e| Error::UnexpectedError {
+ message: format!("vindex replay task failed: {e}"),
+ source: None,
+ })
+ .and_then(|result| {
+ result.map_err(|e| Error::UnexpectedError {
+ message: format!("Failed to replay spilled vindex vectors:
{e}"),
+ source: Some(Box::new(e)),
+ })
+ });
+ drop(pipeline.sender);
+ let consumer = join_consumer(pipeline.consumer).await;
+ (consumer, replay, pipeline.spill_bytes, pipeline.spill_write)
+}
+
+fn spawn_replay(
+ mut file: std::fs::File,
+ sender: mpsc::Sender<AddItem>,
+ dimension: usize,
+ timing_enabled: bool,
+) -> ReplayTask {
+ tokio::task::spawn_blocking(move || -> std::io::Result<(usize, Duration)> {
+ let mut spill_read = Duration::ZERO;
+ let read_start = timing_enabled.then(Instant::now);
+ file.seek(SeekFrom::Start(0))?;
+ if let Some(start) = read_start {
+ spill_read = spill_read.saturating_add(start.elapsed());
+ }
+ let mut reader = BufReader::with_capacity(BUFFER_BYTES, file);
+ let mut rows = 0usize;
+ let mut ids = Vec::new();
+ let mut vectors = MutableBuffer::new(REPLAY_TARGET_BYTES);
+ loop {
+ let read_start = timing_enabled.then(Instant::now);
+ let mut header = [0u8; 8];
+ let read = reader.read(&mut header)?;
+ if read == 0 {
+ if let Some(start) = read_start {
+ spill_read = spill_read.saturating_add(start.elapsed());
+ }
+ break;
+ }
+ reader.read_exact(&mut header[read..])?;
+ let count =
usize::try_from(u64::from_le_bytes(header)).map_err(|_| {
+ std::io::Error::new(std::io::ErrorKind::InvalidData, "invalid
spill row count")
+ })?;
+ let id_bytes_len = count.checked_mul(8).ok_or_else(|| {
+ std::io::Error::new(std::io::ErrorKind::InvalidData, "spill id
length overflow")
+ })?;
+ let mut id_bytes = vec![0u8; id_bytes_len];
+ reader.read_exact(&mut id_bytes)?;
+ let (id_chunks, remainder) = id_bytes.as_chunks::<8>();
+ debug_assert!(remainder.is_empty());
+ ids.extend(id_chunks.iter().map(|bytes|
i64::from_le_bytes(*bytes)));
+ let vector_bytes = count
+ .checked_mul(dimension)
+ .and_then(|value| value.checked_mul(4))
+ .ok_or_else(|| {
+ std::io::Error::new(
+ std::io::ErrorKind::InvalidData,
+ "spill vector length overflow",
+ )
+ })?;
+ let offset = vectors.len();
+ vectors.resize(offset + vector_bytes, 0);
+ reader.read_exact(&mut vectors.as_slice_mut()[offset..])?;
+ if let Some(start) = read_start {
+ spill_read = spill_read.saturating_add(start.elapsed());
+ }
+ rows += count;
+ if vectors.len() >= REPLAY_TARGET_BYTES {
+ let item = AddItem::Spilled(
+ std::mem::take(&mut ids),
+ std::mem::replace(&mut vectors,
MutableBuffer::new(REPLAY_TARGET_BYTES)),
+ );
+ if sender.blocking_send(item).is_err() {
+ return Ok((rows, spill_read));
+ }
+ }
+ }
+ if !ids.is_empty() {
+ let _ = sender.blocking_send(AddItem::Spilled(ids, vectors));
+ }
+ Ok((rows, spill_read))
+ })
+}
+
+fn splitmix64(state: &mut u64) -> u64 {
+ *state = state.wrapping_add(0x9E37_79B9_7F4A_7C15);
+ let mut z = *state;
+ z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
+ z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
+ z ^ (z >> 31)
+}
+
+fn shard_seed(shard: &VindexIndexShard) -> u64 {
+ let mut seed = 0u64;
+ let mut absorb = |value: u64| {
+ let mut state = seed ^ value;
+ seed = splitmix64(&mut state);
+ };
+ absorb(shard.snapshot_id as u64);
+ absorb(shard.source_bucket as u64);
+ absorb(shard.row_range_start as u64);
+ absorb(shard.row_range_end as u64);
+ absorb(shard.partition_bytes.len() as u64);
+ for chunk in shard.partition_bytes.chunks(8) {
+ let mut word = [0u8; 8];
+ word[..chunk.len()].copy_from_slice(chunk);
+ absorb(u64::from_le_bytes(word));
+ }
+ seed
+}
+
+fn random_below(state: &mut u64, bound: usize) -> usize {
+ let bound = bound as u64;
+ let threshold = bound.wrapping_neg() % bound;
+ loop {
+ let value = splitmix64(state);
+ if value >= threshold {
+ return (value % bound) as usize;
+ }
+ }
+}
+
+fn pick_indices(total: usize, count: usize, state: &mut u64) -> impl
Iterator<Item = usize> + '_ {
+ (0..count).map(move |index| {
+ let start = (index as u128 * total as u128 / count as u128) as usize;
+ let end = ((index + 1) as u128 * total as u128 / count as u128) as
usize;
+ start + random_below(state, end - start)
+ })
+}
+
+// Boundaries depend only on the layout, never on which granules were sampled.
+// Keep original indices: a unit can cross files, but physical reads cannot.
+fn sampling_units(granules: &[Granule]) -> Vec<Range<usize>> {
+ let mut units: Vec<Range<usize>> = Vec::new();
+ let mut start = 0;
+ for end in 1..=granules.len() {
+ let rows = granules[end - 1].range.to() - granules[start].range.from()
+ 1;
+ if rows as usize >= 2 * ROWS_PER_STRATUM {
+ units.push(start..end);
+ start = end;
+ }
+ }
+ if start < granules.len() {
+ if let Some(last) = units.last_mut() {
+ last.end = granules.len();
+ } else {
+ units.push(0..granules.len());
+ }
+ }
+ units
+}
+
+fn unit_training_offsets(rows: usize, quota: usize, offset: usize) -> impl
Iterator<Item = usize> {
+ (0..quota).map(move |index| {
+ ((index as u128 * rows as u128 + offset as u128) / quota as u128) as
usize
+ })
+}
+
+fn granule_bytes(granules: &[Granule], selected: Option<&HashSet<usize>>) ->
u64 {
+ let mut files = HashMap::<usize, Vec<Range<u64>>>::new();
+ for (index, granule) in granules.iter().enumerate() {
+ if selected.is_none_or(|selected| selected.contains(&index)) {
+ files
+ .entry(granule.file_index)
+ .or_default()
+ .extend(granule.byte_ranges.iter().cloned());
+ }
+ }
+ files
+ .values()
+ .map(|ranges| coalesced_parquet_range_bytes(ranges))
+ .sum()
+}
+
+fn select_first(
+ granules: &[Granule],
+ eligible: usize,
+ retained: usize,
+ mut seed: u64,
+) -> Result<GranulePlan> {
+ // Callers have validated that granules partition one nonempty shard.
+ let range = RowRange::new(
+ granules[0].range.from(),
+ granules.last().unwrap().range.to(),
+ );
+ let rows = checked_row_count(range.from(), range.to())? as usize;
+ let whole_shard = || GranulePlan {
+ first: vec![range.clone()],
+ rest: Vec::new(),
+ first_rows: rows,
+ training: None,
+ };
+ if rows < 2 * ROWS_PER_STRATUM {
+ return Ok(whole_shard());
+ }
+ let units = sampling_units(granules);
+ let strata = retained
+ .div_ceil(ROWS_PER_STRATUM)
+ .max(MIN_STRATA)
+ .min(rows);
+ let candidates = strata
+ .checked_mul(ROWS_PER_STRATUM)
+ .ok_or_else(|| Error::DataInvalid {
+ message: "vindex training candidate count overflows
usize".to_string(),
+ source: None,
+ })?;
+ let mut quotas = vec![0usize; units.len()];
+ for row in pick_indices(rows, strata, &mut seed) {
+ let row_id = range.from() + row as i64;
+ let unit = units.partition_point(|unit| granules[unit.end -
1].range.to() < row_id);
+ quotas[unit] += ROWS_PER_STRATUM;
+ }
+ let mut selected = HashSet::new();
+ for (unit, quota) in units.iter().zip("as) {
+ let unit_rows = granules[unit.end - 1].range.to() -
granules[unit.start].range.from() + 1;
+ if *quota > unit_rows as usize {
+ return Ok(whole_shard());
+ }
+ if *quota > 0 {
+ selected.extend(unit.clone());
+ }
+ }
+ let first_bytes = granule_bytes(granules, Some(&selected));
+ let total_bytes = granule_bytes(granules, None);
+ if selected.len() == granules.len()
+ || selected.len() < MIN_STRATA.min(granules.len())
+ || total_bytes == 0
+ || first_bytes.saturating_mul(FIRST_BYTES_DENOMINATOR)
+ > total_bytes.saturating_mul(FIRST_BYTES_NUMERATOR)
+ {
+ return Ok(whole_shard());
+ }
+
+ let mut training = Vec::with_capacity(candidates);
+ for (unit, quota) in units.iter().zip(quotas) {
+ if quota == 0 {
+ continue;
+ }
+ let start = granules[unit.start].range.from();
+ let unit_rows = (granules[unit.end - 1].range.to() - start + 1) as
usize;
+ let offset = random_below(&mut seed, unit_rows);
+ training
+ .extend(unit_training_offsets(unit_rows, quota, offset).map(|row|
start + row as i64));
+ }
+ debug_assert_eq!(training.len(), candidates);
+ if eligible < candidates {
+ for index in 0..eligible {
+ let other = index + random_below(&mut seed, candidates - index);
+ training.swap(index, other);
+ }
+ training.truncate(eligible);
+ training.sort_unstable();
+ }
+ let mut first = Vec::with_capacity(selected.len());
+ let mut rest = Vec::with_capacity(granules.len() - selected.len());
+ let mut first_rows = 0;
+ for (index, granule) in granules.iter().enumerate() {
+ if selected.contains(&index) {
+ first_rows += granule.range.count() as usize;
+ first.push(granule.range.clone());
+ } else {
+ rest.push(granule.range.clone());
+ }
+ }
+ Ok(GranulePlan {
+ first: merge_row_ranges(first),
+ rest: merge_row_ranges(rest),
+ first_rows,
+ training: Some(training),
+ })
+}
+
+fn append_shard_granules(
+ granules: &mut Vec<Granule>,
+ file_index: usize,
+ file_start: i64,
+ shard_range: &RowRange,
+ file_granules: Vec<ParquetGranule>,
+) -> Result<()> {
+ for granule in file_granules {
+ let from = file_start
+ .checked_add(granule.first_row)
+ .ok_or_else(|| Error::DataInvalid {
+ message: "vindex granule row id overflows i64".to_string(),
+ source: None,
+ })?;
+ let to = from
+ .checked_add(granule.row_count - 1)
+ .ok_or_else(|| Error::DataInvalid {
+ message: "vindex granule row range overflows i64".to_string(),
+ source: None,
+ })?;
+ if let Some(range) = shard_range.intersect_inclusive(from, to) {
+ granules.push(Granule {
+ range,
+ file_index,
+ byte_ranges: granule.byte_ranges,
+ });
+ }
+ }
+ Ok(())
+}
+
+fn granules_partition_shard(granules: &[Granule], shard_range: &RowRange) ->
bool {
+ granules
+ .first()
+ .is_some_and(|granule| granule.range.from() == shard_range.from())
+ && granules
+ .last()
+ .is_some_and(|granule| granule.range.to() == shard_range.to())
+ && granules
+ .windows(2)
+ .all(|pair| pair[0].range.to().checked_add(1) ==
Some(pair[1].range.from()))
+}
+
+fn local_ids(row_ids: &[i64], start: i64, row_count: usize) ->
Result<Vec<i64>> {
+ let end = start
+ .checked_add(i64::try_from(row_count).map_err(|e| Error::DataInvalid {
+ message: "vindex row count does not fit i64".to_string(),
+ source: Some(Box::new(e)),
+ })?)
+ .ok_or_else(|| Error::DataInvalid {
+ message: "vindex row range overflows i64".to_string(),
+ source: None,
+ })?;
+ row_ids
+ .iter()
+ .map(|row_id| {
+ if *row_id < start || *row_id >= end {
+ Err(Error::DataInvalid {
+ message: format!("vindex row id {row_id} is outside shard
[{start}, {end})"),
+ source: None,
+ })
+ } else {
+ Ok(*row_id - start)
+ }
+ })
+ .collect()
+}
+
+impl<'a> VindexIndexBuildBuilder<'a> {
+ fn open_vector_stream(
+ &self,
+ shard: &VindexIndexShard,
+ ranges: Vec<RowRange>,
+ index_column: &str,
+ read_timing: Option<&Arc<DataFileReadTiming>>,
+ parquet_read_budget: Option<&Arc<crate::arrow::ReadBudget>>,
+ ) -> Result<ArrowRecordBatchStream> {
+ let split = data_split_for_shard_ranges(shard, ranges)?;
+ 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 read = match read_timing {
+ Some(timing) =>
read.with_data_file_read_timing(Arc::clone(timing)),
+ None => read,
+ };
+ let read = match parquet_read_budget {
+ Some(budget) => read.with_parquet_read_budget(Arc::clone(budget)),
+ None => read,
+ };
+ read.to_arrow(&[split])
+ }
+
+ pub(super) async fn plan_granules(
+ &self,
+ shard: &VindexIndexShard,
+ index_column: &str,
+ eligible: usize,
+ retained: usize,
+ ) -> Result<GranulePlan> {
+ let shard_range = RowRange::new(shard.row_range_start,
shard.row_range_end);
+ let mut granules = Vec::new();
+ let mut use_whole_shard = false;
+ let mut parquet_files = Vec::new();
+
+ for (file_index, file) in shard.files.iter().enumerate() {
+ if file
+ .write_cols
+ .as_ref()
+ .is_some_and(|columns| !columns.iter().any(|column| column ==
index_column))
+ {
+ continue;
+ }
+ let Some((file_start, file_end)) = file.row_id_range() else {
+ use_whole_shard = true;
+ break;
+ };
+ let Some(range) = shard_range.intersect_inclusive(file_start,
file_end) else {
+ continue;
+ };
+ let file_size = u64::try_from(file.file_size).map_err(|e|
Error::DataInvalid {
+ message: format!(
+ "Invalid data file size for '{}': {}",
+ file.file_name, file.file_size
+ ),
+ source: Some(Box::new(e)),
+ })?;
+ let path = file.data_file_path(&shard.bucket_path);
+ if path.to_ascii_lowercase().ends_with(".parquet") {
+ parquet_files.push((file_index, path, file_size, file_start,
file_end));
+ } else {
+ granules.push(Granule {
+ range,
+ file_index,
+ byte_ranges: std::iter::once(0..file_size).collect(),
+ });
+ }
+ }
+
+ if !use_whole_shard && !parquet_files.is_empty() {
+ let page_index_enabled = self
+ .table
+ .schema()
+ .core_options()
+ .parquet_filter_column_index_enabled()?;
+ let concurrency = self
+ .table
+ .schema()
+ .core_options()
+ .parquet_row_group_parallelism()?
+ .max(1);
+ let file_io = self.table.file_io();
+ let mut results = futures::stream::iter(parquet_files)
+ .map(
+ |(file_index, path, file_size, file_start, file_end)|
async move {
+ let input = file_io.new_input(&path)?;
+ let reader = Box::new(input.reader().await?);
+ let (granules, _) =
+ parquet_granules(reader, file_size, index_column,
page_index_enabled)
+ .await?;
+ Ok::<_, Error>((file_index, file_start, file_end,
granules))
+ },
+ )
+ .buffer_unordered(concurrency);
+ while let Some(result) = results.next().await {
+ match result {
+ Ok((file_index, file_start, file_end, file_granules)) => {
+ let covered = file_granules
+ .iter()
+ .map(|granule| granule.row_count)
+ .sum::<i64>();
+ if covered != file_end - file_start + 1 {
+ use_whole_shard = true;
+ break;
+ }
+ append_shard_granules(
+ &mut granules,
+ file_index,
+ file_start,
+ &shard_range,
+ file_granules,
+ )?;
+ }
+ Err(error) => {
+ log::warn!(
+ "vindex granule metadata read failed; using the
whole shard as the first granule: {error}"
+ );
+ use_whole_shard = true;
+ break;
+ }
+ }
+ }
+ }
+
+ granules.sort_by_key(|granule| granule.range.from());
+ if !use_whole_shard {
+ // Data Evolution may retain multiple physical providers for the
same logical rows.
+ // Let the reader choose the provider instead of counting those
rows twice here.
+ use_whole_shard = !granules_partition_shard(&granules,
&shard_range);
+ }
+ if use_whole_shard || granules.is_empty() {
+ granules = vec![Granule {
+ range: shard_range,
+ file_index: 0,
+ byte_ranges: std::iter::once(
+ 0..shard
+ .files
+ .iter()
+ .map(|file| file.file_size.max(0) as u64)
+ .sum(),
+ )
+ .collect(),
+ }];
+ }
+
+ select_first(&granules, eligible, retained, shard_seed(shard))
+ }
+
+ #[allow(clippy::too_many_arguments)]
+ pub(super) async fn build_index_file_granule(
+ &self,
+ shard: &VindexIndexShard,
+ index_column: &str,
+ dimension: i32,
+ index_field_id: i32,
+ options: &VindexVectorIndexOptions,
+ index_meta: Vec<u8>,
+ ) -> Result<BuiltIndexFile> {
+ let timing_enabled = vector_index_build_timing_enabled();
+ let total_start = timing_enabled.then(Instant::now);
+ let mut source_batch_wait = Duration::ZERO;
+ let mut batch_count = 0usize;
+ let read_timing = timing_enabled.then(||
Arc::new(DataFileReadTiming::default()));
+ let parquet_read_budget = if timing_enabled {
+ let budget = configured_parquet_read_budget(self.table)?;
+ budget.enable_diagnostics();
+ Some(budget)
+ } else {
+ None
+ };
+ let row_count = checked_row_count(shard.row_range_start,
shard.row_range_end)?;
+ 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 dimension = usize::try_from(dimension).map_err(|e|
Error::DataInvalid {
+ message: format!("Invalid vindex dimension: {dimension}"),
+ source: Some(Box::new(e)),
+ })?;
+ if dimension == 0 {
+ return Err(Error::DataInvalid {
+ message: "vindex vector dimension must be
positive".to_string(),
+ source: None,
+ });
+ }
+ checked_vector_bytes(row_count_usize, dimension)?;
+ let eligible = checked_training_vector_count(row_count_usize,
options.train_sample_ratio)?;
+ let retained =
+ default_training_vector_count(eligible,
options.config.nlist()).unwrap_or(eligible);
+ let plan = self
+ .plan_granules(shard, index_column, eligible, retained)
+ .await?;
+
+ 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 spill = Some(spawn_spill_writer(timing_enabled)?);
+ let training_rows = plan.training.as_ref().map_or(eligible, Vec::len);
+ let training_buffer_rows = (BUFFER_BYTES / checked_vector_bytes(1,
dimension)?).max(1);
+ let training_buffer_floats = training_buffer_rows * dimension;
+ let mut training_buffer = Vec::with_capacity(training_buffer_floats);
+ let mut next_training_sample = 0usize;
+ let mut first_rows = 0usize;
+ let mut range_index = 0usize;
+ let first_result: Result<()> = async {
+ let mut stream = self.open_vector_stream(
+ shard,
+ plan.first.clone(),
+ index_column,
+ read_timing.as_ref(),
+ parquet_read_budget.as_ref(),
+ )?;
+ let mut expected_row_id = plan.first[0].from();
+ loop {
+ let source_start = timing_enabled.then(Instant::now);
+ let batch = stream.try_next().await?;
+ if let Some(start) = source_start {
+ source_batch_wait =
source_batch_wait.saturating_add(start.elapsed());
+ }
+ let Some(batch) = batch else { break };
+ batch_count += 1;
+ let vectors = validate_vector_batch_ranges(
+ &batch,
+ index_column,
+ dimension,
+ &plan.first,
+ &mut range_index,
+ &mut expected_row_id,
+ )?;
+ let batch_end =
first_rows.checked_add(vectors.row_count).ok_or_else(|| {
+ Error::DataInvalid {
+ message: "vindex first-batch row count overflows
usize".to_string(),
+ source: None,
+ }
+ })?;
+ while next_training_sample < training_rows {
+ let row = if let Some(training) = &plan.training {
+ let sample = training[next_training_sample];
+ match vectors.row_ids.binary_search(&sample) {
+ Ok(row) => row,
+ Err(row) if row == vectors.row_count => break,
+ Err(_) => return Err(Error::DataInvalid {
+ message: format!("Missing vindex training row
{sample}"),
+ source: None,
+ }),
+ }
+ } else {
+ let sample = checked_training_sample_index(
+ next_training_sample,
+ plan.first_rows,
+ training_rows,
+ )?;
+ if sample >= batch_end {
+ break;
+ }
+ sample - first_rows
+ };
+ let offset = row * dimension;
+
training_buffer.extend_from_slice(&vectors.values[offset..offset + dimension]);
+ next_training_sample += 1;
+ if training_buffer.len() == training_buffer_floats {
+ trainer
+ .add_training_vectors_mut(
+ &training_buffer,
+ training_buffer.len() / dimension,
+ )
+ .map_err(|e| Error::DataInvalid {
+ message: format!("Failed to add vindex
training vectors: {e}"),
+ source: Some(Box::new(e)),
+ })?;
+ training_buffer.clear();
+ }
+ }
+ let record = SpillRecord {
+ ids: local_ids(vectors.row_ids, shard.row_range_start,
row_count_usize)?,
+ bytes: vectors.bytes.to_vec(),
+ };
+ first_rows = batch_end;
+ if spill.as_ref().unwrap().sender.send(record).await.is_err() {
+ return Err(Error::UnexpectedError {
+ message: "vindex spill writer stopped
unexpectedly".to_string(),
+ source: None,
+ });
+ }
+ }
+ if !training_buffer.is_empty() {
+ trainer
+ .add_training_vectors_mut(&training_buffer,
training_buffer.len() / dimension)
+ .map_err(|e| Error::DataInvalid {
+ message: format!("Failed to add vindex training
vectors: {e}"),
+ source: Some(Box::new(e)),
+ })?;
+ }
+ if first_rows != plan.first_rows
+ || range_index != plan.first.len()
+ || next_training_sample != training_rows
+ {
+ return Err(Error::DataInvalid {
+ message: format!(
+ "vindex first-batch mismatch: rows={first_rows}/{},
ranges={range_index}/{}, training={next_training_sample}/{training_rows}",
+ plan.first_rows,
+ plan.first.len()
+ ),
+ source: None,
+ });
+ }
+ Ok(())
+ }
+ .await;
+ if let Err(error) = first_result {
+ return match spill.take().unwrap().finish().await {
+ Ok(_) => Err(error),
+ Err(spill_error) => Err(spill_error),
+ };
+ }
+
+ let mut training: Option<TrainingTask> =
Some(tokio::task::spawn_blocking(
+ move || -> std::io::Result<_> {
+ let start = timing_enabled.then(Instant::now);
+ let training = trainer.finish()?;
+ Ok((
+ training,
+ start.map_or(Duration::ZERO, |start| start.elapsed()),
+ ))
+ },
+ ));
+ let mut live: Option<LivePipeline> = None;
+ let mut train_finish = Duration::ZERO;
+ let index_column = index_column.to_string();
+
+ macro_rules! go_live {
+ () => {{
+ let (pipeline, duration) = start_live_pipeline(
+ training.take().expect("training task"),
+ spill.take().expect("spill writer"),
+ index_column.clone(),
+ dimension,
+ timing_enabled,
+ )
+ .await?;
+ train_finish = duration;
+ live = Some(pipeline);
+ }};
+ }
+
+ let mut rest_rows = 0usize;
+ let producer_result: Result<()> = async {
+ if !plan.rest.is_empty() {
+ let mut stream = self.open_vector_stream(
+ shard,
+ plan.rest.clone(),
+ &index_column,
+ read_timing.as_ref(),
+ parquet_read_budget.as_ref(),
+ )?;
+ let mut range_index = 0usize;
+ let mut expected_row_id = plan.rest[0].from();
+ loop {
+ let source_start = timing_enabled.then(Instant::now);
+ let batch = stream.try_next().await?;
+ if let Some(start) = source_start {
+ source_batch_wait =
source_batch_wait.saturating_add(start.elapsed());
+ }
+ let Some(batch) = batch else { break };
+ batch_count += 1;
+ if live.is_none() && training.as_ref().is_some_and(|task|
task.is_finished()) {
+ go_live!();
+ }
+ let vectors = validate_vector_batch_ranges(
+ &batch,
+ &index_column,
+ dimension,
+ &plan.rest,
+ &mut range_index,
+ &mut expected_row_id,
+ )?;
+ let ids = local_ids(vectors.row_ids,
shard.row_range_start, row_count_usize)?;
+ rest_rows =
rest_rows.checked_add(vectors.row_count).ok_or_else(|| {
+ Error::DataInvalid {
+ message: "vindex remaining row count overflows
usize".to_string(),
+ source: None,
+ }
+ })?;
+ if let Some(pipeline) = &live {
+ if pipeline
+ .sender
+ .send(AddItem::Batch(batch, ids))
+ .await
+ .is_err()
+ {
+ return Err(Error::UnexpectedError {
+ message: "vindex add consumer stopped
unexpectedly".to_string(),
+ source: None,
+ });
+ }
+ } else {
+ let record = SpillRecord {
+ ids,
+ bytes: vectors.bytes.to_vec(),
+ };
+ if
spill.as_ref().unwrap().sender.send(record).await.is_err() {
+ return Err(Error::UnexpectedError {
+ message: "vindex spill writer stopped
unexpectedly".to_string(),
+ source: None,
+ });
+ }
+ }
+ }
+ if rest_rows != row_count_usize - plan.first_rows
+ || range_index != plan.rest.len()
+ {
+ return Err(Error::DataInvalid {
+ message: format!(
+ "vindex remaining-batch mismatch:
rows={rest_rows}/{}, ranges={range_index}/{}",
+ row_count_usize - plan.first_rows,
+ plan.rest.len()
+ ),
+ source: None,
+ });
+ }
+ }
+ Ok(())
+ }
+ .await;
+
+ if let Err(producer_error) = producer_result {
+ if let Some(pipeline) = live.take() {
+ let (consumer, replay, _, _) =
finish_live_pipeline(pipeline).await;
+ consumer?;
+ replay?;
+ } else {
+ let spill_result = match spill.take() {
+ Some(spill) => spill.finish().await.map(|_| ()),
+ None => Ok(()),
+ };
+ let training_result = match training.take() {
+ Some(training) => join_training(training).await.map(|_|
()),
+ None => Ok(()),
+ };
+ spill_result?;
+ training_result?;
+ }
+ return Err(producer_error);
+ }
+ if live.is_none() {
+ go_live!();
+ }
+
+ let (consumer, replay, granule_spill_bytes, granule_spill_write) =
+ finish_live_pipeline(live.unwrap()).await;
+ let (writer, rows_added, consumer_replay_rows, index_add) = consumer?;
+ let (replay_rows, granule_spill_read) = replay?;
+ if rows_added != row_count_usize || replay_rows !=
consumer_replay_rows {
+ return Err(Error::DataInvalid {
+ message: format!(
+ "vindex pipelined add mismatch:
rows={rows_added}/{row_count_usize},
replay={consumer_replay_rows}/{replay_rows}"
+ ),
+ source: None,
+ });
+ }
+
+ let serialize_upload_start = timing_enabled.then(Instant::now);
+ let meta = self
+ .finish_index_file(writer, shard, index_field_id, index_meta,
row_count)
+ .await?;
+ let serialize_upload =
+ serialize_upload_start.map_or(Duration::ZERO, |start|
start.elapsed());
+ let (oss_read, parquet_decode) = read_timing
+ .as_ref()
+ .map_or((Duration::ZERO, Duration::ZERO), |timing| {
+ (timing.file_read(), timing.parquet_decode())
+ });
+ let (file_schema_open, first_batch_wait, remaining_batch_wait) =
read_timing
+ .as_ref()
+ .map_or((Duration::ZERO, Duration::ZERO, Duration::ZERO), |timing|
{
+ timing.file_waits()
+ });
+ let parquet_diagnostics = parquet_read_budget
+ .as_ref()
+ .map_or_else(Default::default, |budget| budget.diagnostics());
+ let timing = total_start.map(|start| VectorIndexBuildTiming {
+ total_without_commit: start.elapsed(),
+ source_batch_wait,
+ oss_read,
+ parquet_decode,
+ file_schema_open,
+ first_batch_wait,
+ remaining_batch_wait,
+ parquet_row_group_count: parquet_diagnostics.row_group_count,
+ parquet_projected_bytes_min:
parquet_diagnostics.projected_bytes_min,
+ parquet_projected_bytes_max:
parquet_diagnostics.projected_bytes_max,
+ parquet_projected_bytes_total:
parquet_diagnostics.projected_bytes_total,
+ parquet_peak_inflight_row_groups:
parquet_diagnostics.peak_inflight,
+ raw_temp_write: Duration::ZERO,
+ granule_spill_write,
+ train_finish,
+ raw_temp_reread: Duration::ZERO,
+ granule_spill_read,
+ index_add,
+ serialize_upload,
+ rows: row_count_usize,
+ training_rows_seen: training_rows,
+ training_rows_retained: retained,
+ batch_count,
+ raw_temp_bytes: 0,
+ granule_spill_bytes,
+ index_bytes: meta.file_size as u64,
+ data_file_count: shard.files.len(),
+ file_name: meta.file_name.clone(),
+ });
+ Ok(BuiltIndexFile { meta, timing })
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ fn granules_with_rows(rows: impl IntoIterator<Item = usize>) ->
Vec<Granule> {
+ let mut start = 0;
+ rows.into_iter()
+ .enumerate()
+ .map(|(file_index, rows)| {
+ let range = RowRange::new(start, start + rows as i64 - 1);
+ start += rows as i64;
+ Granule {
+ range,
+ file_index,
+ byte_ranges: std::iter::once(0..rows as u64).collect(),
+ }
+ })
+ .collect()
+ }
+
+ #[test]
+ fn a_single_large_granule_is_not_excluded_from_training() {
+ for large_index in [0, 2_048, 4_095] {
+ let granules = granules_with_rows((0..4_096).map(|index| {
+ if index == large_index {
+ 524_288
+ } else {
+ 128
+ }
+ }));
+ for seed in 0..32 {
+ let plan = select_first(&granules, 65_536, 65_536,
seed).unwrap();
+ // Row-proportional bytes force whole-shard when the large
granule is hit.
+ assert!(plan.training.is_none(), "index {large_index}, seed
{seed}");
+ assert_eq!(plan.first_rows, 4_095 * 128 + 524_288);
+ assert!(plan.rest.is_empty());
+ }
+ }
+ }
+
+ #[test]
+ fn static_units_merge_short_pages_and_the_trailing_remainder() {
+ for page_rows in [64, 85, 128, 170, 256, 341] {
+ let granules = granules_with_rows(vec![page_rows; 101]);
+ let units = sampling_units(&granules);
+ assert_eq!(units.first().unwrap().start, 0);
+ assert_eq!(units.last().unwrap().end, granules.len());
+ assert!(units.windows(2).all(|pair| pair[0].end == pair[1].start));
+ assert!(units.iter().all(|unit| unit.len() * page_rows >= 256));
+ }
+ // A row-group tail can join the next file's first page; retain both
indices.
+ let granules = granules_with_rows([341, 40, 341, 40]);
+ assert_eq!(sampling_units(&granules), vec![0..1, 1..4]);
+ assert_ne!(granules[1].file_index, granules[2].file_index);
+
+ // 170-row pages need two pages per unit, doubling first-pass rows
here.
+ let granules = granules_with_rows(vec![170; 16_384]);
+ for seed in 0..3 {
+ let plan = select_first(&granules, 65_536, 65_536, seed).unwrap();
+ assert_eq!(plan.first_rows, 512 * 340);
+ assert_eq!(plan.training.unwrap().len(), 65_536);
+ }
+ }
+
+ #[test]
+ fn systematic_unit_rows_have_equal_marginal_probability() {
+ for (rows, quota) in [(341, 128), (256, 256), (129, 128)] {
+ let mut counts = vec![0; rows];
+ for offset in 0..rows {
+ let selected = unit_training_offsets(rows, quota,
offset).collect::<Vec<_>>();
+ assert_eq!(selected.len(), quota);
+ assert!(selected.windows(2).all(|pair| pair[0] < pair[1]));
+ for row in selected {
+ assert!(row < rows);
+ counts[row] += 1;
+ }
+ }
+ assert!(counts.iter().all(|count| *count == quota));
+ }
+ }
+
+ #[test]
+ fn training_shrink_preserves_exact_counts_and_shard_offsets() {
+ let mut granules = granules_with_rows(vec![256; 4_096]);
+ for granule in &mut granules {
+ granule.range = RowRange::new(granule.range.from() + 17,
granule.range.to() + 17);
+ }
+ for eligible in [1, 100, 127, 128, 129, 32_767, 32_768] {
+ for seed in 0..3 {
+ let plan = select_first(&granules, eligible, eligible,
seed).unwrap();
+ let training = plan.training.unwrap();
+ assert_eq!(training.len(), eligible);
+ assert!(training.windows(2).all(|pair| pair[0] < pair[1]));
+ assert!(training
+ .iter()
+ .all(|row| (17..17 + 4_096 * 256).contains(row)));
+ assert!(training.iter().all(|row| plan
+ .first
+ .iter()
+ .any(|range| { range.from() <= *row && *row <= range.to()
})));
+ }
+ }
+ // Three or more hits can exceed a 256-row unit: do not truncate
quotas.
+ let granules = granules_with_rows(vec![256; 64]);
+ let plan = select_first(&granules, 128, 128, 0).unwrap();
+ assert!(plan.training.is_none());
+ assert_eq!(plan.first_rows, 16_384);
+ }
+
+ #[test]
+ fn large_granule_training_is_row_weighted_when_the_byte_gate_allows_it() {
+ let mut granules =
+ granules_with_rows((0..4_096).map(|index| if index == 2_048 {
524_288 } else { 128 }));
+ for granule in &mut granules {
+ granule.byte_ranges = std::iter::once(0..1).collect();
+ }
+ let large = &granules[2_048].range;
+ for seed in 0..32 {
+ let training = select_first(&granules, 65_536, 65_536, seed)
+ .unwrap()
+ .training
+ .unwrap();
+ let large_rows = training
+ .iter()
+ .filter(|row| large.from() <= **row && **row <= large.to())
+ .count();
+ assert!(
+ (32_512..=33_024).contains(&large_rows),
+ "seed {seed}: {large_rows}"
+ );
+ }
+ }
+
+ #[test]
+ fn minimum_granule_count_can_force_whole_shard_without_the_byte_gate() {
+ let mut granules =
+ granules_with_rows((0..4_096).map(|index| if index == 0 {
1_048_576 } else { 256 }));
+ for granule in &mut granules {
+ granule.byte_ranges = std::iter::once(0..1).collect();
+ }
+ // All units are single granules. Prove quota and byte gates pass,
while
+ // many row strata hit the same large granule and the count gate fails.
+ let rows = granules.last().unwrap().range.to() as usize + 1;
+ let mut quotas = vec![0; granules.len()];
+ for row in pick_indices(rows, MIN_STRATA, &mut 7) {
+ let index = granules.partition_point(|granule| granule.range.to()
< row as i64);
+ quotas[index] += ROWS_PER_STRATUM;
+ }
+ let selected = quotas
+ .iter()
+ .enumerate()
+ .filter_map(|(index, quota)| (*quota > 0).then_some(index))
+ .collect::<HashSet<_>>();
+ assert!(quotas
+ .iter()
+ .zip(&granules)
+ .all(|(quota, granule)| { *quota <= granule.range.count() as usize
}));
+ assert!(selected.len() < MIN_STRATA);
+ assert!(
+ granule_bytes(&granules, Some(&selected)) * FIRST_BYTES_DENOMINATOR
+ <= granule_bytes(&granules, None) * FIRST_BYTES_NUMERATOR
+ );
+ assert!(select_first(&granules, 32_768, 32_768, 7)
+ .unwrap()
+ .training
+ .is_none());
+ }
+
+ #[test]
+ fn stratified_first_and_rest_cover_every_granule_once() {
+ let granules = granules_with_rows(vec![256; 4_096]);
+ let plan = select_first(&granules, 65_536, 32_768, 7).unwrap();
+ let training = plan.training.as_ref().unwrap();
+ assert_eq!(training.len(), 32_768);
+ assert!(training.windows(2).all(|pair| pair[0] < pair[1]));
+ assert!(training.iter().all(|row| plan
+ .first
+ .iter()
+ .any(|range| range.from() <= *row && *row <= range.to())));
+ let mut all = plan.first.clone();
+ all.extend(plan.rest.clone());
+ assert_eq!(
+ merge_row_ranges(all),
+ vec![RowRange::new(0, 4_096 * 256 - 1)]
+ );
+ assert_eq!(
+ plan.first_rows
+ + plan
+ .rest
+ .iter()
+ .map(|range| range.count() as usize)
+ .sum::<usize>(),
+ 4_096 * 256
+ );
+ for stratum in 0..256 {
+ let (start, end) = (stratum * 4_096, (stratum + 1) * 4_096);
+ assert_eq!(
+ training
+ .iter()
+ .filter(|row| start <= **row && **row < end)
+ .count(),
+ 128,
+ "stratum {stratum}"
+ );
+ }
+ assert_eq!(
+ plan.training,
+ select_first(&granules, 65_536, 32_768, 7).unwrap().training
+ );
+ assert_ne!(
+ plan.training,
+ select_first(&granules, 65_536, 32_768, 8).unwrap().training
+ );
+ }
+
+ #[test]
+ fn shard_seed_follows_the_whole_shard_identity() {
+ let shard = VindexIndexShard {
+ partition: crate::spec::BinaryRow::new(0),
+ partition_bytes: vec![1, 2, 3, 4, 5, 6, 7, 8, 9],
+ files: Vec::new(),
+ row_range_start: 0,
+ row_range_end: 999,
+ snapshot_id: 3,
+ source_bucket: 0,
+ total_buckets: 2,
+ bucket_path: "memory:/t/bucket-0".to_string(),
+ };
+ let picks = |shard: &VindexIndexShard| {
+ pick_indices(4_096, 512, &mut
shard_seed(shard)).collect::<Vec<_>>()
+ };
+ assert_eq!(picks(&shard), picks(&shard.clone()));
+
+ let mut others = vec![shard.clone(); 5];
+ others[0].partition_bytes = vec![1, 2, 3, 4, 5, 6, 7, 8, 10];
+ others[1].partition_bytes = Vec::new();
+ others[2].source_bucket = 1;
+ others[3].snapshot_id = 4;
+ others[4].row_range_end = 1_999;
+ for other in &others {
+ assert_ne!(picks(&shard), picks(other), "{other:?}");
+ }
+ }
+
+ #[test]
+ fn first_picks_do_not_alias_with_periodic_granule_order() {
+ let granules = granules_with_rows(vec![256; 4_096]);
+ for seed in 0..32u64 {
+ let training = select_first(&granules, 65_536, 65_536, seed)
+ .unwrap()
+ .training
+ .unwrap();
+ for period in [2usize, 4, 8, 16] {
+ let mut hits = vec![0usize; period];
+ for row in &training {
+ hits[*row as usize / 256 % period] += 1;
+ }
+ let expected = training.len() / period;
+ assert!(
+ hits.iter()
+ .all(|count| *count * 4 > expected && *count <
expected * 2),
+ "seed {seed} period {period}: {hits:?}"
+ );
+ }
+ }
+ }
+
+ #[test]
+ fn unequal_granules_are_sampled_in_proportion() {
+ let granules =
+ granules_with_rows((0..4_096).map(|index| if index % 8 == 4 { 16 }
else { 256 }));
+ let short_share = 16.0 / (16.0 + 7.0 * 256.0);
+ for seed in 0..32u64 {
+ let training = select_first(&granules, 65_536, 65_536, seed)
+ .unwrap()
+ .training
+ .unwrap();
+ assert_eq!(training.len(), 65_536);
+ let short_rows = training
+ .iter()
+ .filter(|row| granules.partition_point(|g| g.range.to() <
**row) % 8 == 4)
+ .count();
+ let share = short_rows as f64 / training.len() as f64;
+ assert!(
+ share > short_share / 2.0 && share < short_share * 2.0,
+ "seed {seed}: {share}"
+ );
+ }
+ }
+
+ #[test]
+ fn short_granules_use_static_units_instead_of_filling_from_the_head() {
+ let granules = granules_with_rows(vec![64; 4_096]);
+ let plan = select_first(&granules, 32_768, 32_768, 7).unwrap();
+ assert_eq!(plan.first_rows, 65_536);
+ let training = plan.training.unwrap();
+ for quarter in 0..4 {
+ let picked = training
+ .iter()
+ .filter(|row| **row / 65_536 == quarter)
+ .count();
+ assert_eq!(picked, 8_192, "quarter {quarter}");
+ }
+ }
+
+ #[test]
+ fn coalesced_page_holes_force_all_first() {
+ let mut granules = granules_with_rows(vec![256; 4_096]);
+ assert!(select_first(&granules, 65_536, 65_536, 7)
+ .unwrap()
+ .training
+ .is_some());
+ for (index, granule) in granules.iter_mut().enumerate() {
+ granule.file_index = 0;
+ granule.byte_ranges =
+ std::iter::once(index as u64 * 512..index as u64 * 512 +
256).collect();
+ }
+ // Same rows, but coalescing reads the holes too: the byte gate alone
changes the path.
+ assert!(select_first(&granules, 65_536, 65_536, 7)
+ .unwrap()
+ .training
+ .is_none());
+ }
+
+ #[test]
+ fn coarse_granules_read_all_first() {
+ let granules = (0..16)
+ .map(|row| Granule {
+ range: RowRange::new(row, row),
+ file_index: 0,
+ byte_ranges: std::iter::once(row as u64..row as u64 +
1).collect(),
+ })
+ .collect::<Vec<_>>();
+ assert!(select_first(&granules, 8, 8, 7).unwrap().training.is_none());
+ }
+
+ #[test]
+ fn near_full_shard_in_shared_file_reads_all_first() {
+ let file_granules = (0..1_200)
+ .map(|row| ParquetGranule {
+ first_row: row * 256,
+ row_count: 256,
+ byte_ranges: std::iter::once(
+ row as u64 * 2 * 1024 * 1024..row as u64 * 2 * 1024 * 1024
+ 1,
+ )
+ .collect(),
+ })
+ .collect();
+ let shard_range = RowRange::new(400 * 256, 700 * 256 - 1);
+ let mut granules = Vec::new();
+
+ append_shard_granules(&mut granules, 0, 0, &shard_range,
file_granules).unwrap();
+
+ assert_eq!(granules.len(), 300);
+ assert!(select_first(&granules, 32_768, 32_768, 7)
+ .unwrap()
+ .training
+ .is_none());
+ }
+
+ #[test]
+ fn granule_partition_requires_exact_contiguous_coverage() {
+ for (ranges, shard, expected) in [
+ (vec![], (0, 9), false),
+ (vec![(0, 9)], (0, 9), true),
+ (vec![(0, 4), (5, 9)], (0, 9), true),
+ (vec![(0, 4), (6, 9)], (0, 9), false),
+ (vec![(0, 5), (5, 9)], (0, 9), false),
+ (vec![(1, 9)], (0, 9), false),
+ (vec![(0, 8)], (0, 9), false),
+ (vec![(0, 9), (0, 9)], (0, 9), false),
+ (vec![(0, 2), (6, 7), (3, 5), (8, 9)], (0, 9), false),
+ (vec![(i64::MAX, i64::MAX)], (i64::MAX, i64::MAX), true),
+ (
+ vec![(i64::MAX - 1, i64::MAX - 1), (i64::MAX, i64::MAX)],
+ (i64::MAX - 1, i64::MAX),
+ true,
+ ),
+ (
+ vec![(i64::MAX, i64::MAX), (i64::MAX, i64::MAX)],
+ (i64::MAX, i64::MAX),
+ false,
+ ),
+ ] {
+ let granules = ranges
+ .iter()
+ .map(|&(from, to)| Granule {
+ range: RowRange::new(from, to),
+ file_index: 0,
+ byte_ranges: vec![],
+ })
+ .collect::<Vec<_>>();
+ assert_eq!(
+ granules_partition_shard(&granules, &RowRange::new(shard.0,
shard.1)),
+ expected,
+ "ranges={ranges:?}, shard={shard:?}"
+ );
+ }
+ }
+
+ #[test]
+ fn overlapping_data_evolution_providers_do_not_partition_shard() {
+ let shard_range = RowRange::new(0, 99);
+ let mut granules = Vec::new();
+ for file_index in 0..2 {
+ append_shard_granules(
+ &mut granules,
+ file_index,
+ 0,
+ &shard_range,
+ vec![ParquetGranule {
+ first_row: 0,
+ row_count: 100,
+ byte_ranges: std::iter::once(0..1).collect(),
+ }],
+ )
+ .unwrap();
+ }
+ granules.sort_by_key(|granule| granule.range.from());
+
+ assert!(!granules_partition_shard(&granules, &shard_range));
+ granules.truncate(1);
+ assert!(granules_partition_shard(&granules, &shard_range));
+ }
+
+ #[test]
+ fn live_pipeline_completes_with_one_blocking_thread() {
+ let runtime = tokio::runtime::Builder::new_multi_thread()
+ .worker_threads(1)
+ .max_blocking_threads(1)
+ .enable_all()
+ .build()
+ .unwrap();
+
+ runtime.block_on(async {
+ tokio::time::timeout(Duration::from_secs(5), async {
+ let vectors = vec![0.0f32, 0.0, 1.0, 1.0, 2.0, 2.0];
+ let ids = vec![0, 1, 2];
+ let config =
+
paimon_vindex_core::index::VectorIndexConfig::from_options(&HashMap::from([
+ ("index.type".to_string(), "ivf_flat".to_string()),
+ ("dimension".to_string(), "2".to_string()),
+ ("nlist".to_string(), "1".to_string()),
+ ("metric".to_string(), "l2".to_string()),
+ ]))
+ .unwrap();
+ let mut trainer = VectorIndexTrainer::new(config).unwrap();
+ trainer
+ .add_training_vectors_mut(&vectors, ids.len())
+ .unwrap();
+
+ let spill = spawn_spill_writer(false).unwrap();
+ for _ in 0..2 {
+ spill
+ .sender
+ .send(SpillRecord {
+ ids: Vec::new(),
+ bytes: Vec::new(),
+ })
+ .await
+ .unwrap();
+ }
+ spill
+ .sender
+ .send(SpillRecord {
+ ids,
+ bytes: vectors
+ .iter()
+ .flat_map(|value| value.to_ne_bytes())
+ .collect(),
+ })
+ .await
+ .unwrap();
+ let training = tokio::task::spawn_blocking(move || ->
std::io::Result<_> {
+ Ok((trainer.finish()?, Duration::ZERO))
+ });
+
+ let (pipeline, _) =
+ start_live_pipeline(training, spill,
"embedding".to_string(), 2, false)
+ .await
+ .unwrap();
+ let (consumer, replay, _, _) =
finish_live_pipeline(pipeline).await;
+ let (_, rows_added, consumer_replay_rows, _) =
consumer.unwrap();
+ let (replay_rows, _) = replay.unwrap();
+ assert_eq!((rows_added, consumer_replay_rows, replay_rows),
(3, 3, 3));
+ })
+ .await
+ .expect("vindex live pipeline deadlocked on the shared blocking
pool");
+ });
+ }
+
+ #[tokio::test]
+ async fn spill_reports_written_bytes() {
+ let spill = spawn_spill_writer(true).unwrap();
+ spill
+ .sender
+ .send(SpillRecord {
+ ids: vec![1, 2],
+ bytes: vec![0; 16],
+ })
+ .await
+ .unwrap();
+
+ let (file, bytes, _) = spill.finish().await.unwrap();
+ assert_eq!(bytes, 40);
+
+ let (sender, mut receiver) = mpsc::channel(1);
+ let (rows, _) = spawn_replay(file, sender, 2,
true).await.unwrap().unwrap();
+ assert_eq!(rows, 2);
+ assert!(matches!(
+ receiver.recv().await,
+ Some(AddItem::Spilled(ids, _)) if ids == vec![1, 2]
+ ));
+ }
+}
diff --git a/crates/paimon/src/table/vindex_index_build_builder/tests.rs
b/crates/paimon/src/table/vindex_index_build_builder/tests.rs
index a4636b57..fb5c1499 100644
--- a/crates/paimon/src/table/vindex_index_build_builder/tests.rs
+++ b/crates/paimon/src/table/vindex_index_build_builder/tests.rs
@@ -15,7 +15,7 @@
// specific language governing permissions and limitations
// under the License.
-use super::extraction::validate_vector_batch;
+use super::extraction::{validate_vector_batch, validate_vector_batch_ranges};
use super::planning::{plan_vindex_shards, VindexIndexShard};
use super::validation::{
checked_training_sample_index, checked_training_vector_count,
checked_vector_bytes,
@@ -252,6 +252,70 @@ fn test_extract_vectors_accepts_list_float32_and_row_ids()
{
assert_eq!(vectors, vec![1.0, 2.0, 3.0, 4.0]);
}
+#[test]
+fn test_ranged_vector_validation_accepts_gaps_across_batches() {
+ let ranges = vec![RowRange::new(10, 11), RowRange::new(15, 16)];
+ let batches = [
+ vector_batch(
+ vec![
+ Some(vec![Some(1.0), Some(2.0)]),
+ Some(vec![Some(3.0), Some(4.0)]),
+ ],
+ vec![Some(10), Some(11)],
+ ),
+ vector_batch(
+ vec![
+ Some(vec![Some(5.0), Some(6.0)]),
+ Some(vec![Some(7.0), Some(8.0)]),
+ ],
+ vec![Some(15), Some(16)],
+ ),
+ ];
+ let mut range_index = 0;
+ let mut expected_row_id = ranges[0].from();
+
+ for batch in &batches {
+ validate_vector_batch_ranges(
+ batch,
+ "embedding",
+ 2,
+ &ranges,
+ &mut range_index,
+ &mut expected_row_id,
+ )
+ .unwrap();
+ }
+
+ assert_eq!(range_index, ranges.len());
+ assert_eq!(expected_row_id, 17);
+}
+
+#[test]
+fn test_ranged_vector_validation_rejects_bad_row_ids() {
+ let ranges = vec![RowRange::new(10, 11), RowRange::new(15, 16)];
+ for row_ids in [
+ vec![Some(10), Some(10)],
+ vec![Some(10), Some(15)],
+ vec![Some(9), Some(10)],
+ vec![Some(10), Some(11), Some(16), Some(15)],
+ vec![Some(10), Some(11), Some(15), Some(16), Some(17)],
+ ] {
+ let rows = row_ids.len();
+ let batch = vector_batch(vec![Some(vec![Some(1.0), Some(2.0)]); rows],
row_ids);
+ let mut range_index = 0;
+ let mut expected_row_id = ranges[0].from();
+ assert!(validate_vector_batch_ranges(
+ &batch,
+ "embedding",
+ 2,
+ &ranges,
+ &mut range_index,
+ &mut expected_row_id,
+ )
+ .is_err());
+ }
+}
+
#[test]
fn test_extract_vectors_rejects_dimension_mismatch() {
let batch = vector_batch(vec![Some(vec![Some(1.0)])], vec![Some(0)]);
@@ -605,6 +669,10 @@ async fn vindex_incremental_build_indexes_only_new_rows() {
let second_built = table
.new_vindex_index_build_builder(IVF_FLAT_IDENTIFIER)
.with_index_column("embedding")
+ .with_options(HashMap::from([(
+ "vindex.build.granule.enabled".to_string(),
+ "false".to_string(),
+ )]))
.execute()
.await
.unwrap();
@@ -643,6 +711,315 @@ async fn vindex_incremental_build_indexes_only_new_rows()
{
}
}
+#[tokio::test]
+async fn vindex_small_training_sample_preserves_tail_cluster_recall() {
+ let table_path = "memory:/test_vindex_small_sample_recall";
+ let mut options = table_options("1000");
+ for (key, value) in [
+ ("ivf-sq.dimension", "1"),
+ ("ivf-sq.nlist", "1"),
+ ("ivf-sq.metric", "l2"),
+ ("ivf-sq.train.sample-ratio", "0.1"),
+ ] {
+ options.insert(key.to_string(), value.to_string());
+ }
+ let table = test_table_with_io(
+ FileIOBuilder::new("memory").build().unwrap(),
+ table_path,
+ vindex_schema_builder(options).build().unwrap(),
+ );
+ setup_dirs(table.file_io(), table_path).await;
+ write_vectors(
+ &table,
+ (0..1000).collect(),
+ (0..1000)
+ .map(|id| {
+ vec![if id < 450 {
+ 0.0
+ } else if id < 900 {
+ 1.0
+ } else {
+ 100.0
+ }]
+ })
+ .collect(),
+ )
+ .await;
+ assert_eq!(
+ table
+ .new_vindex_index_build_builder(crate::vindex::IVF_SQ_IDENTIFIER)
+ .with_index_column("embedding")
+ .execute()
+ .await
+ .unwrap(),
+ 1
+ );
+
+ let result = table
+ .new_vector_search_builder()
+ .with_vector_column("embedding")
+ .with_query_vector(vec![100.0])
+ .with_limit(10)
+ .with_options(HashMap::from([(
+ "ivf-sq.nprobe".to_string(),
+ "1".to_string(),
+ )]))
+ .execute()
+ .await
+ .unwrap();
+ let row_ids = &result.row_ids().unwrap().row_ids;
+ assert_eq!(row_ids.len(), 10);
+ // Equal-distance IDs need not have a stable order; all hits must be in
the tail cluster.
+ assert!(
+ row_ids.iter().all(|row_id| (900..1000).contains(row_id)),
+ "{result:?}"
+ );
+}
+
+/// Every eighth data file holds a cluster the other files do not. With 4,096
one-page
+/// files the granule planner reads 512 of them first, one per stratum of
eight, so a
+/// fixed position in the stratum would train without ever seeing that cluster.
+#[tokio::test]
+async fn vindex_granule_training_sees_file_periodic_cluster() {
+ const FILES: usize = 4_096;
+ const ROWS_PER_FILE: usize = 256;
+ let table_path = "memory:/test_vindex_granule_periodic_cluster";
+ let mut options = table_options("2000000");
+ for (key, value) in [
+ ("ivf-sq.dimension", "1"),
+ ("ivf-sq.nlist", "1"),
+ ("ivf-sq.metric", "l2"),
+ ("target-file-size", "1b"),
+ ] {
+ options.insert(key.to_string(), value.to_string());
+ }
+ let table = test_table_with_io(
+ FileIOBuilder::new("memory").build().unwrap(),
+ table_path,
+ vindex_schema_builder(options).build().unwrap(),
+ );
+ setup_dirs(table.file_io(), table_path).await;
+ let mut table_write = TableWrite::new(&table,
"test-user".to_string()).unwrap();
+ for file in 0..FILES {
+ let value = if file % 8 == 7 {
+ 100.0
+ } else {
+ (file % 2) as f32
+ };
+ let first_id = (file * ROWS_PER_FILE) as i32;
+ table_write
+ .write_arrow_batch(&build_vector_batch(
+ (first_id..first_id + ROWS_PER_FILE as i32).collect(),
+ vec![vec![value]; ROWS_PER_FILE],
+ ))
+ .await
+ .unwrap();
+ }
+ let messages = table_write.prepare_commit().await.unwrap();
+ TableCommit::new(table.clone(), "test-user".to_string())
+ .commit(messages)
+ .await
+ .unwrap();
+
+ assert_eq!(
+ table
+ .new_vindex_index_build_builder(crate::vindex::IVF_SQ_IDENTIFIER)
+ .with_index_column("embedding")
+ .execute()
+ .await
+ .unwrap(),
+ 1
+ );
+
+ let result = table
+ .new_vector_search_builder()
+ .with_vector_column("embedding")
+ .with_query_vector(vec![100.0])
+ .with_limit(10)
+ .with_options(HashMap::from([(
+ "ivf-sq.nprobe".to_string(),
+ "1".to_string(),
+ )]))
+ .execute()
+ .await
+ .unwrap();
+ let row_ids = &result.row_ids().unwrap().row_ids;
+ assert_eq!(row_ids.len(), 10);
+ // Equal-distance IDs need not have a stable order; all hits must be in
the periodic cluster.
+ assert!(
+ row_ids
+ .iter()
+ .all(|row_id| (*row_id as usize / ROWS_PER_FILE) % 8 == 7),
+ "{result:?}"
+ );
+}
+
+#[tokio::test]
+async fn vindex_granule_training_sees_one_oversized_row_group() {
+ use crate::arrow::format::parquet::parquet_granules;
+ use parquet::arrow::AsyncArrowWriter;
+ use parquet::basic::Compression;
+ use parquet::file::properties::WriterProperties;
+
+ const FILES: usize = 4_096;
+ const SMALL_ROWS: usize = 128;
+ const LARGE_ROWS: usize = 524_288;
+ let total_rows = (FILES - 1) * SMALL_ROWS + LARGE_ROWS;
+ // Reuse physical file contents; logical row IDs are assigned by the
commit.
+ let mut contents = Vec::new();
+ for (rows, value) in [(SMALL_ROWS, 0.0), (SMALL_ROWS, 1.0), (LARGE_ROWS,
100.0)] {
+ let batch = build_vector_batch((0..rows as i32).collect(),
vec![vec![value]; rows]);
+ let props = WriterProperties::builder()
+ .set_max_row_group_row_count(Some(rows))
+ .set_offset_index_disabled(true)
+ .set_dictionary_enabled(false)
+ .set_compression(Compression::UNCOMPRESSED)
+ .build();
+ let mut bytes = Vec::new();
+ let mut writer =
+ AsyncArrowWriter::try_new(&mut bytes, batch.schema(),
Some(props)).unwrap();
+ writer.write(&batch).await.unwrap();
+ writer.close().await.unwrap();
+ contents.push(bytes::Bytes::from(bytes));
+ }
+
+ // The control uses full spill. Three real snapshot identities exercise the
+ // production seed derivation, without adding a seed override to the
builder.
+ for (granule_enabled, snapshots) in [(false, 1), (true, 1), (true, 2),
(true, 3)] {
+ let table_path =
format!("memory:/oversized_granule_{granule_enabled}_{snapshots}");
+ let mut options = table_options("2000000");
+ for (key, value) in [
+ ("ivf-sq.dimension", "1"),
+ ("ivf-sq.nlist", "1"),
+ ("ivf-sq.metric", "l2"),
+ ] {
+ options.insert(key.to_string(), value.to_string());
+ }
+ let table = test_table_with_io(
+ FileIOBuilder::new("memory").build().unwrap(),
+ &table_path,
+ vindex_schema_builder(options).build().unwrap(),
+ );
+ setup_dirs(table.file_io(), &table_path).await;
+ let mut files = Vec::new();
+ for index in 0..FILES {
+ let large = index == FILES - 1;
+ let bytes = &contents[if large { 2 } else { index % 2 }];
+ let name = format!("data-{index:04}.parquet");
+ table
+ .file_io()
+ .new_output(&format!("{table_path}/bucket-0/{name}"))
+ .unwrap()
+ .write(bytes.clone())
+ .await
+ .unwrap();
+ let mut file = data_file(
+ &name,
+ None,
+ if large { LARGE_ROWS } else { SMALL_ROWS } as i64,
+ );
+ file.file_size = bytes.len() as i64;
+ file.file_source = Some(0); // APPEND: the commit assigns row IDs.
+ files.push(file);
+ }
+ for group in files.chunks(FILES.div_ceil(snapshots)) {
+ TableCommit::new(table.clone(), "test-user".to_string())
+ .commit(vec![CommitMessage::new(
+ BinaryRow::new(0).to_serialized_bytes(),
+ 0,
+ group.to_vec(),
+ )])
+ .await
+ .unwrap();
+ }
+ let snapshot = SnapshotManager::new(table.file_io().clone(),
table_path.clone())
+ .get_latest_snapshot()
+ .await
+ .unwrap()
+ .unwrap();
+ assert_eq!(snapshot.id(), snapshots as i64);
+ let entries = table
+ .new_read_builder()
+ .new_scan()
+ .with_scan_all_files()
+ .plan_manifest_entries(&snapshot)
+ .await
+ .unwrap();
+ let large_file = entries
+ .iter()
+ .find(|entry| entry.file().row_count == LARGE_ROWS as i64)
+ .unwrap()
+ .file();
+ let (large_start, large_end) = large_file.row_id_range().unwrap();
+ let input = table
+ .file_io()
+
.new_input(&large_file.data_file_path(&format!("{table_path}/bucket-0")))
+ .unwrap();
+ let (physical_granules, has_offset_index) = parquet_granules(
+ Box::new(input.reader().await.unwrap()),
+ large_file.file_size as u64,
+ "embedding",
+ true,
+ )
+ .await
+ .unwrap();
+ assert!(!has_offset_index);
+ assert_eq!(physical_granules.len(), 1);
+ assert_eq!(physical_granules[0].row_count, LARGE_ROWS as i64);
+
+ let mut builder =
table.new_vindex_index_build_builder(crate::vindex::IVF_SQ_IDENTIFIER);
+ builder
+ .with_index_column("embedding")
+ .with_options(HashMap::from([(
+ "vindex.build.granule.enabled".to_string(),
+ granule_enabled.to_string(),
+ )]));
+ if granule_enabled {
+ let shards = plan_vindex_shards(
+ table.location(),
+ table.schema().partition_keys(),
+ table.schema().fields(),
+ &CoreOptions::new(table.schema().options()),
+ snapshot.id(),
+ entries,
+ 2_000_000,
+ &[],
+ )
+ .unwrap();
+ assert_eq!(shards.len(), 1);
+ let plan = builder
+ .plan_granules(&shards[0], "embedding", total_rows, 65_536)
+ .await
+ .unwrap();
+ // The independently trained cluster occupies half the logical rows
+ // and projected bytes, so selecting it must trigger the byte gate.
+ assert!(plan.rest.is_empty(), "snapshot {}", snapshot.id());
+ assert_eq!(plan.first, vec![RowRange::new(0, total_rows as i64 -
1)]);
+ }
+ assert_eq!(builder.execute().await.unwrap(), 1);
+ let result = table
+ .new_vector_search_builder()
+ .with_vector_column("embedding")
+ .with_query_vector(vec![100.0])
+ .with_limit(10)
+ .with_options(HashMap::from([(
+ "ivf-sq.nprobe".to_string(),
+ "1".to_string(),
+ )]))
+ .execute()
+ .await
+ .unwrap();
+ let row_ids = &result.row_ids().unwrap().row_ids;
+ assert_eq!(row_ids.len(), 10);
+ assert!(
+ row_ids
+ .iter()
+ .all(|row| (large_start as u64..=large_end as
u64).contains(row)),
+ "granule={granule_enabled}, snapshot={snapshots}: {result:?}"
+ );
+ }
+}
+
#[tokio::test]
async fn vindex_build_cleans_written_shards_when_later_shard_fails() {
let table_path = "memory:/test_vindex_abort_written_shard";
diff --git a/crates/paimon/src/table/vindex_index_build_builder/timing.rs
b/crates/paimon/src/table/vindex_index_build_builder/timing.rs
index 3221dc4c..ae43568f 100644
--- a/crates/paimon/src/table/vindex_index_build_builder/timing.rs
+++ b/crates/paimon/src/table/vindex_index_build_builder/timing.rs
@@ -41,8 +41,10 @@ pub(super) struct VectorIndexBuildTiming {
pub(super) parquet_projected_bytes_total: u64,
pub(super) parquet_peak_inflight_row_groups: usize,
pub(super) raw_temp_write: Duration,
+ pub(super) granule_spill_write: Duration,
pub(super) train_finish: Duration,
pub(super) raw_temp_reread: Duration,
+ pub(super) granule_spill_read: Duration,
pub(super) index_add: Duration,
pub(super) serialize_upload: Duration,
pub(super) rows: usize,
@@ -50,6 +52,7 @@ pub(super) struct VectorIndexBuildTiming {
pub(super) training_rows_retained: usize,
pub(super) batch_count: usize,
pub(super) raw_temp_bytes: usize,
+ pub(super) granule_spill_bytes: u64,
pub(super) index_bytes: u64,
pub(super) data_file_count: usize,
pub(super) file_name: String,
@@ -68,7 +71,7 @@ impl VectorIndexBuildTiming {
.saturating_add(commit);
let unattributed = total.saturating_sub(accounted);
eprintln!(
- "event=paimon_vector_index_build index_type={} file={} rows={}
training_rows_seen={} training_rows_retained={} batch_count={}
raw_temp_bytes={} index_bytes={} source_batch_wait_ms={:.3} oss_read_ms={:.3}
parquet_decode_ms={:.3} file_schema_open_ms={:.3} first_batch_wait_ms={:.3}
remaining_batch_wait_ms={:.3} parquet_row_group_count={}
parquet_projected_bytes_min={} parquet_projected_bytes_max={}
parquet_projected_bytes_total={} parquet_peak_inflight_row_groups={}
raw_temp_wri [...]
+ "event=paimon_vector_index_build index_type={} file={} rows={}
training_rows_seen={} training_rows_retained={} batch_count={}
raw_temp_bytes={} granule_spill_bytes={} index_bytes={}
source_batch_wait_ms={:.3} oss_read_ms={:.3} parquet_decode_ms={:.3}
file_schema_open_ms={:.3} first_batch_wait_ms={:.3}
remaining_batch_wait_ms={:.3} parquet_row_group_count={}
parquet_projected_bytes_min={} parquet_projected_bytes_max={}
parquet_projected_bytes_total={} parquet_peak_inflight_row [...]
index_type,
self.file_name,
self.rows,
@@ -76,6 +79,7 @@ impl VectorIndexBuildTiming {
self.training_rows_retained,
self.batch_count,
self.raw_temp_bytes,
+ self.granule_spill_bytes,
self.index_bytes,
self.source_batch_wait.as_secs_f64() * 1000.0,
self.oss_read.as_secs_f64() * 1000.0,
@@ -89,8 +93,10 @@ impl VectorIndexBuildTiming {
self.parquet_projected_bytes_total,
self.parquet_peak_inflight_row_groups,
self.raw_temp_write.as_secs_f64() * 1000.0,
+ self.granule_spill_write.as_secs_f64() * 1000.0,
self.train_finish.as_secs_f64() * 1000.0,
self.raw_temp_reread.as_secs_f64() * 1000.0,
+ self.granule_spill_read.as_secs_f64() * 1000.0,
self.index_add.as_secs_f64() * 1000.0,
self.serialize_upload.as_secs_f64() * 1000.0,
commit.as_secs_f64() * 1000.0,
diff --git a/crates/paimon/src/table/vindex_index_build_builder/writer.rs
b/crates/paimon/src/table/vindex_index_build_builder/writer.rs
index 803c0888..8c7fc126 100644
--- a/crates/paimon/src/table/vindex_index_build_builder/writer.rs
+++ b/crates/paimon/src/table/vindex_index_build_builder/writer.rs
@@ -26,7 +26,7 @@ use super::VindexIndexBuildBuilder;
use crate::spec::{GlobalIndexMeta, IndexFileMeta, ROW_ID_FIELD_NAME};
use crate::table::data_file_reader::DataFileReadTiming;
use crate::table::table_read::configured_parquet_read_budget;
-use crate::vindex::VindexVectorIndexOptions;
+use crate::vindex::{VindexVectorIndexOptions, DISKANN_IDENTIFIER};
use crate::{Error, Result};
use arrow_buffer::MutableBuffer;
use futures::TryStreamExt;
@@ -41,6 +41,7 @@ use tokio_util::io::SyncIoBridge;
const INDEX_DIR: &str = "index";
const VECTOR_BUFFER_BYTES: usize = 8 * 1024 * 1024;
+
pub(super) struct BuiltIndexFile {
pub(super) meta: IndexFileMeta,
pub(super) timing: Option<VectorIndexBuildTiming>,
@@ -55,6 +56,44 @@ impl<'a> VindexIndexBuildBuilder<'a> {
index_field_id: i32,
options: &VindexVectorIndexOptions,
index_meta: Vec<u8>,
+ ) -> Result<BuiltIndexFile> {
+ let use_granule = self.index_type != DISKANN_IDENTIFIER &&
options.granule_build_enabled;
+ log::info!(
+ "vindex build strategy: index_type={}, strategy={}",
+ self.index_type,
+ if use_granule { "granule" } else { "full-spill" }
+ );
+ if use_granule {
+ return self
+ .build_index_file_granule(
+ shard,
+ index_column,
+ dimension,
+ index_field_id,
+ options,
+ index_meta,
+ )
+ .await;
+ }
+ self.build_full_spill_index_file(
+ shard,
+ index_column,
+ dimension,
+ index_field_id,
+ options,
+ index_meta,
+ )
+ .await
+ }
+
+ async fn build_full_spill_index_file(
+ &self,
+ shard: &VindexIndexShard,
+ index_column: &str,
+ dimension: i32,
+ index_field_id: i32,
+ options: &VindexVectorIndexOptions,
+ index_meta: Vec<u8>,
) -> Result<BuiltIndexFile> {
let timing_enabled = vector_index_build_timing_enabled();
let total_start = timing_enabled.then(Instant::now);
@@ -328,6 +367,65 @@ impl<'a> VindexIndexBuildBuilder<'a> {
})?;
let serialize_upload_start = timing_enabled.then(Instant::now);
+ let meta = self
+ .finish_index_file(writer, shard, index_field_id, index_meta,
row_count)
+ .await?;
+ let serialize_upload =
+ serialize_upload_start.map_or(Duration::ZERO, |start|
start.elapsed());
+ let (oss_read, parquet_decode) = read_timing
+ .as_ref()
+ .map_or((Duration::ZERO, Duration::ZERO), |timing| {
+ (timing.file_read(), timing.parquet_decode())
+ });
+ let (file_schema_open, first_batch_wait, remaining_batch_wait) =
read_timing
+ .as_ref()
+ .map_or((Duration::ZERO, Duration::ZERO, Duration::ZERO), |timing|
{
+ timing.file_waits()
+ });
+ let parquet_diagnostics = parquet_read_budget
+ .as_ref()
+ .map_or_else(Default::default, |budget| budget.diagnostics());
+ let timing = total_start.map(|start| VectorIndexBuildTiming {
+ total_without_commit: start.elapsed(),
+ source_batch_wait,
+ oss_read,
+ parquet_decode,
+ file_schema_open,
+ first_batch_wait,
+ remaining_batch_wait,
+ parquet_row_group_count: parquet_diagnostics.row_group_count,
+ parquet_projected_bytes_min:
parquet_diagnostics.projected_bytes_min,
+ parquet_projected_bytes_max:
parquet_diagnostics.projected_bytes_max,
+ parquet_projected_bytes_total:
parquet_diagnostics.projected_bytes_total,
+ parquet_peak_inflight_row_groups:
parquet_diagnostics.peak_inflight,
+ raw_temp_write,
+ granule_spill_write: Duration::ZERO,
+ train_finish,
+ raw_temp_reread,
+ granule_spill_read: Duration::ZERO,
+ index_add,
+ serialize_upload,
+ rows: row_count_usize,
+ training_rows_seen: training_vector_count,
+ training_rows_retained,
+ batch_count,
+ raw_temp_bytes: bytes_written,
+ granule_spill_bytes: 0,
+ index_bytes: meta.file_size as u64,
+ data_file_count: shard.files.len(),
+ file_name: meta.file_name.clone(),
+ });
+ Ok(BuiltIndexFile { meta, timing })
+ }
+
+ pub(super) async fn finish_index_file(
+ &self,
+ writer: VectorIndexWriter,
+ shard: &VindexIndexShard,
+ index_field_id: i32,
+ index_meta: Vec<u8>,
+ row_count: i64,
+ ) -> Result<IndexFileMeta> {
self.table
.file_io()
.mkdirs(&format!(
@@ -377,11 +475,9 @@ impl<'a> VindexIndexBuildBuilder<'a> {
return Err(error);
}
};
- let serialize_upload =
- serialize_upload_start.map_or(Duration::ZERO, |start|
start.elapsed());
- let meta = IndexFileMeta {
+ Ok(IndexFileMeta {
index_type: self.index_type.clone(),
- file_name: file_name.clone(),
+ file_name,
file_size: checked_i64(
status.size,
"Index file is too large for Rust IndexFileMeta",
@@ -397,47 +493,6 @@ impl<'a> VindexIndexBuildBuilder<'a> {
source_meta: None,
index_meta: Some(index_meta),
}),
- };
- let (oss_read, parquet_decode) = read_timing
- .as_ref()
- .map_or((Duration::ZERO, Duration::ZERO), |timing| {
- (timing.file_read(), timing.parquet_decode())
- });
- let (file_schema_open, first_batch_wait, remaining_batch_wait) =
read_timing
- .as_ref()
- .map_or((Duration::ZERO, Duration::ZERO, Duration::ZERO), |timing|
{
- timing.file_waits()
- });
- let parquet_diagnostics = parquet_read_budget
- .as_ref()
- .map_or_else(Default::default, |budget| budget.diagnostics());
- let timing = total_start.map(|start| VectorIndexBuildTiming {
- total_without_commit: start.elapsed(),
- source_batch_wait,
- oss_read,
- parquet_decode,
- file_schema_open,
- first_batch_wait,
- remaining_batch_wait,
- parquet_row_group_count: parquet_diagnostics.row_group_count,
- parquet_projected_bytes_min:
parquet_diagnostics.projected_bytes_min,
- parquet_projected_bytes_max:
parquet_diagnostics.projected_bytes_max,
- parquet_projected_bytes_total:
parquet_diagnostics.projected_bytes_total,
- parquet_peak_inflight_row_groups:
parquet_diagnostics.peak_inflight,
- raw_temp_write,
- train_finish,
- raw_temp_reread,
- index_add,
- serialize_upload,
- rows: row_count_usize,
- training_rows_seen: training_vector_count,
- training_rows_retained,
- batch_count,
- raw_temp_bytes: bytes_written,
- index_bytes: status.size,
- data_file_count: shard.files.len(),
- file_name,
- });
- Ok(BuiltIndexFile { meta, timing })
+ })
}
}
diff --git a/crates/paimon/src/vindex/mod.rs b/crates/paimon/src/vindex/mod.rs
index d3c807f0..90d6db1f 100644
--- a/crates/paimon/src/vindex/mod.rs
+++ b/crates/paimon/src/vindex/mod.rs
@@ -40,6 +40,7 @@ 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;
+const VINDEX_BUILD_GRANULE_ENABLED: &str = "vindex.build.granule.enabled";
const VECTOR_SEARCH_TIMING_ENV: &str = "PAIMON_LOG_VECTOR_SEARCH_TIMING";
const DISKANN_OPTION_KEYS: &[(&str, &str)] = &[
("deployment-profile", "deployment-profile"),
@@ -114,6 +115,7 @@ pub(crate) struct VindexVectorIndexOptions {
pub config: VectorIndexConfig,
pub native_options: HashMap<String, String>,
pub train_sample_ratio: f64,
+ pub granule_build_enabled: bool,
}
impl VindexVectorIndexOptions {
@@ -247,10 +249,12 @@ impl VindexVectorIndexOptions {
})?;
let train_sample_ratio =
resolve_train_sample_ratio(table_options, user_options,
index_type, field.name())?;
+ let granule_build_enabled =
resolve_granule_build_enabled(table_options, user_options)?;
Ok(Self {
config,
native_options,
train_sample_ratio,
+ granule_build_enabled,
})
}
@@ -309,6 +313,9 @@ fn is_supported_user_option_key(key: &str, index_type:
&str, field_name: &str) -
if key == "index.type" {
return true;
}
+ if key == VINDEX_BUILD_GRANULE_ENABLED {
+ return index_type != DISKANN_IDENTIFIER;
+ }
if is_allowed_native_key(key, index_type) {
return true;
}
@@ -326,6 +333,25 @@ fn is_supported_user_option_key(key: &str, index_type:
&str, field_name: &str) -
false
}
+fn resolve_granule_build_enabled(
+ table_options: &HashMap<String, String>,
+ user_options: &HashMap<String, String>,
+) -> crate::Result<bool> {
+ match user_options
+ .get(VINDEX_BUILD_GRANULE_ENABLED)
+ .or_else(|| table_options.get(VINDEX_BUILD_GRANULE_ENABLED))
+ {
+ Some(value) => value
+ .parse::<bool>()
+ .map_err(|_| crate::Error::ConfigInvalid {
+ message: format!(
+ "Invalid vindex option
{VINDEX_BUILD_GRANULE_ENABLED}='{value}'; expected true or false"
+ ),
+ }),
+ None => Ok(true),
+ }
+}
+
fn is_allowed_native_key(key: &str, index_type: &str) -> bool {
match key {
"dimension" | "metric" => true,
@@ -926,6 +952,53 @@ mod tests {
assert_eq!(diskann.train_sample_ratio, 0.5);
}
+ #[test]
+ fn test_vindex_options_granule_build_enabled() {
+ let field = array_float_field();
+ let defaults = VindexVectorIndexOptions::new(
+ &HashMap::new(),
+ &HashMap::new(),
+ IVF_FLAT_IDENTIFIER,
+ &field,
+ )
+ .unwrap();
+ assert!(defaults.granule_build_enabled);
+
+ let table_options = HashMap::from([(
+ VINDEX_BUILD_GRANULE_ENABLED.to_string(),
+ "false".to_string(),
+ )]);
+ let disabled = VindexVectorIndexOptions::new(
+ &table_options,
+ &HashMap::new(),
+ IVF_FLAT_IDENTIFIER,
+ &field,
+ )
+ .unwrap();
+ assert!(!disabled.granule_build_enabled);
+
+ let user_options =
+ HashMap::from([(VINDEX_BUILD_GRANULE_ENABLED.to_string(),
"true".to_string())]);
+ let overridden = VindexVectorIndexOptions::new(
+ &table_options,
+ &user_options,
+ IVF_FLAT_IDENTIFIER,
+ &field,
+ )
+ .unwrap();
+ assert!(overridden.granule_build_enabled);
+
+ let invalid =
+ HashMap::from([(VINDEX_BUILD_GRANULE_ENABLED.to_string(),
"yes".to_string())]);
+ assert!(VindexVectorIndexOptions::new(
+ &HashMap::new(),
+ &invalid,
+ IVF_FLAT_IDENTIFIER,
+ &field,
+ )
+ .is_err());
+ }
+
#[test]
fn test_vindex_options_reject_invalid_train_sample_ratio() {
for value in ["0", "-0.1", "1.1", "NaN", "inf", "not-a-number"] {
@@ -1049,6 +1122,7 @@ mod tests {
),
(IVF_FLAT_IDENTIFIER, "diskann.max-degree"),
(DISKANN_IDENTIFIER, "diskann.nlist"),
+ (DISKANN_IDENTIFIER, VINDEX_BUILD_GRANULE_ENABLED),
(DISKANN_IDENTIFIER, "diskann.ivf.coarse-assignment"),
(
DISKANN_IDENTIFIER,
diff --git a/docs/src/sql.md b/docs/src/sql.md
index 7598ee53..7848f91c 100644
--- a/docs/src/sql.md
+++ b/docs/src/sql.md
@@ -1264,6 +1264,7 @@ Supported vindex options:
| `<index-type>.ivf.coarse-assignment` | `auto` | all IVF types | Build-time
list assignment mode: `auto` uses Vamana for large centroid matrices; `exact`
always uses exact assignment. |
| `<index-type>.ivf.train.max-points-per-centroid` | `256` | all IVF types |
Positive limit on coarse K-means training data: at most `nlist × value`
vectors. |
| `<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. |
+| `vindex.build.granule.enabled` | `true` | all IVF types | Use the granule
build pipeline. Set to `false` to use the full-spill build path for future
index builds. |
| `<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. |
| `ivf-pq.ivf.pq-encoding` | `auto` | `ivf-pq` | Build-time PQ encoding mode:
`auto` selects an accelerated backend when supported; `canonical` uses the
canonical encoder. |