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 f0bbd0a feat: support core Lumina index build (#347)
f0bbd0a is described below
commit f0bbd0a602c3891866b4cfab2471e46834ff0209
Author: QuakeWang <[email protected]>
AuthorDate: Tue Jun 16 13:54:01 2026 +0800
feat: support core Lumina index build (#347)
---
.github/workflows/ci.yml | 10 +
crates/paimon/src/spec/core_options.rs | 47 +
.../paimon/src/table/lumina_index_build_builder.rs | 1353 ++++++++++++++++++++
crates/paimon/src/table/mod.rs | 6 +
crates/paimon/src/table/table_commit.rs | 502 +++++++-
5 files changed, 1866 insertions(+), 52 deletions(-)
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 3fd54d1..0e556d4 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -120,6 +120,16 @@ jobs:
pip install lumina-data
echo "LUMINA_LIB_PATH=$(python3 -c 'import lumina_data;
print(lumina_data.__path__[0])')/lib/liblumina_py.so" >> $GITHUB_ENV
+ - name: Core Lumina Native Build Test
+ run: >
+ cargo test -p paimon
+
table::lumina_index_build_builder::tests::test_execute_writes_lumina_index_manifest
+ --features fulltext,vortex
+ -- --ignored --exact
+ env:
+ RUST_LOG: DEBUG
+ RUST_BACKTRACE: full
+
- name: DataFusion Integration Test
run: cargo test -p paimon-datafusion --all-targets
env:
diff --git a/crates/paimon/src/spec/core_options.rs
b/crates/paimon/src/spec/core_options.rs
index 2a8b686..305e098 100644
--- a/crates/paimon/src/spec/core_options.rs
+++ b/crates/paimon/src/spec/core_options.rs
@@ -20,6 +20,7 @@ use std::collections::{HashMap, HashSet};
const DELETION_VECTORS_ENABLED_OPTION: &str = "deletion-vectors.enabled";
const DATA_EVOLUTION_ENABLED_OPTION: &str = "data-evolution.enabled";
const GLOBAL_INDEX_ENABLED_OPTION: &str = "global-index.enabled";
+const GLOBAL_INDEX_ROW_COUNT_PER_SHARD_OPTION: &str =
"global-index.row-count-per-shard";
const SOURCE_SPLIT_TARGET_SIZE_OPTION: &str = "source.split.target-size";
const SOURCE_SPLIT_OPEN_FILE_COST_OPTION: &str = "source.split.open-file-cost";
const PARTITION_DEFAULT_NAME_OPTION: &str = "partition.default-name";
@@ -64,6 +65,7 @@ const DEFAULT_TARGET_FILE_SIZE: i64 = 256 * 1024 * 1024;
const DEFAULT_WRITE_PARQUET_BUFFER_SIZE: i64 = 256 * 1024 * 1024;
const DYNAMIC_BUCKET_TARGET_ROW_NUM_OPTION: &str =
"dynamic-bucket.target-row-num";
const DEFAULT_DYNAMIC_BUCKET_TARGET_ROW_NUM: i64 = 200_000;
+const DEFAULT_GLOBAL_INDEX_ROW_COUNT_PER_SHARD: i64 = 100_000;
const BLOB_AS_DESCRIPTOR_OPTION: &str = "blob-as-descriptor";
const BLOB_DESCRIPTOR_FIELD_OPTION: &str = "blob-descriptor-field";
@@ -222,6 +224,22 @@ impl<'a> CoreOptions<'a> {
.unwrap_or(false)
}
+ pub fn global_index_row_count_per_shard(&self) -> crate::Result<i64> {
+ let value = self
+ .parse_i64_option(GLOBAL_INDEX_ROW_COUNT_PER_SHARD_OPTION)?
+ .unwrap_or(DEFAULT_GLOBAL_INDEX_ROW_COUNT_PER_SHARD);
+ if value <= 0 {
+ return Err(crate::Error::DataInvalid {
+ message: format!(
+ "Option '{}' must be greater than 0, got: {}",
+ GLOBAL_INDEX_ROW_COUNT_PER_SHARD_OPTION, value
+ ),
+ source: None,
+ });
+ }
+ Ok(value)
+ }
+
pub fn source_split_target_size(&self) -> i64 {
self.options
.get(SOURCE_SPLIT_TARGET_SIZE_OPTION)
@@ -536,6 +554,10 @@ mod tests {
assert_eq!(core_options.source_split_target_size(), 128 * 1024 * 1024);
assert_eq!(core_options.source_split_open_file_cost(), 4 * 1024 *
1024);
+ assert_eq!(
+ core_options.global_index_row_count_per_shard().unwrap(),
+ 100_000
+ );
}
#[test]
@@ -549,11 +571,36 @@ mod tests {
SOURCE_SPLIT_OPEN_FILE_COST_OPTION.to_string(),
"8 mb".to_string(),
),
+ (
+ GLOBAL_INDEX_ROW_COUNT_PER_SHARD_OPTION.to_string(),
+ "2048".to_string(),
+ ),
]);
let core_options = CoreOptions::new(&options);
assert_eq!(core_options.source_split_target_size(), 256 * 1024 * 1024);
assert_eq!(core_options.source_split_open_file_cost(), 8 * 1024 *
1024);
+ assert_eq!(
+ core_options.global_index_row_count_per_shard().unwrap(),
+ 2048
+ );
+ }
+
+ #[test]
+ fn test_global_index_row_count_per_shard_rejects_invalid_values() {
+ for value in ["0", "-1", "abc"] {
+ let options = HashMap::from([(
+ GLOBAL_INDEX_ROW_COUNT_PER_SHARD_OPTION.to_string(),
+ value.to_string(),
+ )]);
+ let core = CoreOptions::new(&options);
+
+ let err = core
+ .global_index_row_count_per_shard()
+ .expect_err("invalid rows-per-shard should fail");
+ assert!(matches!(err, crate::Error::DataInvalid { message, .. }
+ if
message.contains(GLOBAL_INDEX_ROW_COUNT_PER_SHARD_OPTION)));
+ }
}
#[test]
diff --git a/crates/paimon/src/table/lumina_index_build_builder.rs
b/crates/paimon/src/table/lumina_index_build_builder.rs
new file mode 100644
index 0000000..04a43cb
--- /dev/null
+++ b/crates/paimon/src/table/lumina_index_build_builder.rs
@@ -0,0 +1,1353 @@
+// 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 crate::lumina::ffi::LuminaBuilder;
+use crate::lumina::{
+ is_lumina_index_type, LuminaIndexMeta, LuminaVectorIndexOptions,
LUMINA_IDENTIFIER,
+};
+use crate::spec::{
+ bucket_dir_name, BinaryRow, CoreOptions, DataField, DataFileMeta,
DataType, FileKind,
+ GlobalIndexMeta, IndexFileMeta, IndexManifest, ROW_ID_FIELD_NAME,
+};
+use crate::table::{
+ CommitMessage, DataSplitBuilder, RowRange, SnapshotManager, Table,
TableCommit,
+};
+use crate::{Error, Result};
+use arrow_array::{Array, Float32Array, Int64Array, ListArray, RecordBatch};
+use bytes::Bytes;
+use futures::TryStreamExt;
+use std::collections::HashMap;
+use std::path::{Path, PathBuf};
+use tokio::io::AsyncReadExt;
+
+const INDEX_DIR: &str = "index";
+const COPY_BUFFER_SIZE: usize = 1024 * 1024;
+
+pub struct LuminaIndexBuildBuilder<'a> {
+ table: &'a Table,
+ index_column: Option<String>,
+ index_type: String,
+ options: HashMap<String, String>,
+}
+
+impl<'a> LuminaIndexBuildBuilder<'a> {
+ pub(crate) fn new(table: &'a Table) -> Self {
+ Self {
+ table,
+ index_column: None,
+ index_type: LUMINA_IDENTIFIER.to_string(),
+ options: HashMap::new(),
+ }
+ }
+
+ pub fn with_index_column(&mut self, column: &str) -> &mut Self {
+ self.index_column = Some(column.to_string());
+ self
+ }
+
+ pub fn with_index_type(&mut self, index_type: &str) -> &mut Self {
+ self.index_type = index_type.to_string();
+ self
+ }
+
+ pub fn with_options(&mut self, options: HashMap<String, String>) -> &mut
Self {
+ self.options = options;
+ self
+ }
+
+ pub async fn execute(&self) -> Result<usize> {
+ if !is_lumina_index_type(&self.index_type) {
+ return Err(Error::DataInvalid {
+ message: format!("Unsupported Lumina index type: {}",
self.index_type),
+ source: None,
+ });
+ }
+
+ let index_column = self
+ .index_column
+ .as_deref()
+ .ok_or_else(|| Error::DataInvalid {
+ message: "Lumina index column is required".to_string(),
+ source: None,
+ })?;
+
+ let core_options = CoreOptions::new(self.table.schema().options());
+ validate_table_options(self.table, &core_options)?;
+ let rows_per_shard = core_options.global_index_row_count_per_shard()?;
+
+ let index_field = find_index_field(self.table, index_column)?;
+ validate_vector_field(index_field)?;
+
+ let resolved_options =
+ resolve_lumina_options(self.table.schema().options(),
&self.options)?;
+ let lumina_options = LuminaVectorIndexOptions::new(&resolved_options)?;
+ let dimension = lumina_options.dimension;
+ let index_meta =
LuminaIndexMeta::new(lumina_options.to_lumina_options()).serialize()?;
+
+ let snapshot_manager = SnapshotManager::new(
+ self.table.file_io().clone(),
+ self.table.location().to_string(),
+ );
+ let snapshot = snapshot_manager
+ .get_latest_snapshot()
+ .await?
+ .ok_or_else(|| Error::DataInvalid {
+ message: "Cannot build Lumina index without a
snapshot".to_string(),
+ source: None,
+ })?;
+
+ let manifest_entries = self
+ .table
+ .new_read_builder()
+ .new_scan()
+ .with_scan_all_files()
+ .plan_manifest_entries(&snapshot)
+ .await?;
+ let shards = plan_lumina_shards(
+ self.table.location(),
+ self.table.schema().partition_keys(),
+ self.table.schema().fields(),
+ &core_options,
+ snapshot.id(),
+ manifest_entries,
+ rows_per_shard,
+ )?;
+ if shards.is_empty() {
+ return Ok(0);
+ }
+
+ validate_existing_index_overlap(
+ self.table,
+ snapshot.index_manifest(),
+ index_field.id(),
+ &shards,
+ )
+ .await?;
+
+ let shard_count = shards.len();
+ let mut messages = Vec::with_capacity(shard_count);
+ for shard in shards {
+ let vectors = extract_vectors(self.table, &shard, index_column,
dimension).await?;
+ let index_file = self
+ .build_index_file(
+ &shard,
+ &vectors,
+ dimension,
+ index_field.id(),
+ index_meta.clone(),
+ )
+ .await?;
+ let mut message =
CommitMessage::new(shard.partition_bytes.clone(), 0, vec![]);
+ message.new_index_files = vec![index_file];
+ messages.push(message);
+ }
+
+ TableCommit::new(
+ self.table.clone(),
+ format!(
+ "global-index-{}-create-{}",
+ LUMINA_IDENTIFIER,
+ uuid::Uuid::new_v4()
+ ),
+ )
+ .commit_if_latest_snapshot(messages, snapshot.id())
+ .await?;
+
+ Ok(shard_count)
+ }
+
+ async fn build_index_file(
+ &self,
+ shard: &LuminaIndexShard,
+ vectors: &[f32],
+ dimension: i32,
+ index_field_id: i32,
+ index_meta: Vec<u8>,
+ ) -> Result<IndexFileMeta> {
+ let row_count = checked_row_count(shard.row_range_start,
shard.row_range_end)?;
+ validate_vector_buffer(vectors, row_count, dimension)?;
+ let ids = (0..row_count as u64).collect::<Vec<_>>();
+ let native_options =
LuminaIndexMeta::deserialize(&index_meta)?.options().clone();
+
+ let temp_path = temp_lumina_path();
+ let temp_file = TempFileGuard::new(temp_path.clone());
+ let temp_path_str = temp_path.to_string_lossy().to_string();
+ let builder = LuminaBuilder::create(&native_options)?;
+ builder.pretrain(vectors, row_count, dimension)?;
+ builder.insert(vectors, &ids, row_count, dimension)?;
+ builder.dump(&temp_path_str)?;
+
+ let file_name = format!("lumina-global-index-{}.index",
uuid::Uuid::new_v4());
+ self.table
+ .file_io()
+ .mkdirs(&format!(
+ "{}/{INDEX_DIR}/",
+ self.table.location().trim_end_matches('/')
+ ))
+ .await?;
+ let index_path = format!(
+ "{}/{INDEX_DIR}/{}",
+ self.table.location().trim_end_matches('/'),
+ file_name
+ );
+ copy_local_file_to_output(&temp_path,
self.table.file_io().new_output(&index_path)?)
+ .await?;
+ temp_file.cleanup();
+
+ let status = self.table.file_io().get_status(&index_path).await?;
+ Ok(IndexFileMeta {
+ index_type: LUMINA_IDENTIFIER.to_string(),
+ file_name,
+ file_size: checked_i32(
+ status.size,
+ "Index file is too large for Rust IndexFileMeta",
+ )?,
+ row_count,
+ deletion_vectors_ranges: None,
+ global_index_meta: Some(GlobalIndexMeta {
+ row_range_start: shard.row_range_start,
+ row_range_end: shard.row_range_end,
+ index_field_id,
+ extra_field_ids: None,
+ index_meta: Some(index_meta),
+ }),
+ })
+ }
+}
+
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub(crate) struct LuminaIndexShard {
+ pub partition: BinaryRow,
+ pub partition_bytes: Vec<u8>,
+ pub files: Vec<DataFileMeta>,
+ pub row_range_start: i64,
+ pub row_range_end: i64,
+ snapshot_id: i64,
+ source_bucket: i32,
+ total_buckets: i32,
+ bucket_path: String,
+}
+
+fn validate_table_options(table: &Table, core_options: &CoreOptions) ->
Result<()> {
+ if !core_options.row_tracking_enabled() {
+ return Err(Error::DataInvalid {
+ message: "Lumina index build requires 'row-tracking.enabled' =
'true'".to_string(),
+ source: None,
+ });
+ }
+ if !core_options.data_evolution_enabled() {
+ return Err(Error::DataInvalid {
+ message: "Lumina index build requires 'data-evolution.enabled' =
'true'".to_string(),
+ source: None,
+ });
+ }
+ if !core_options.global_index_enabled() {
+ return Err(Error::DataInvalid {
+ message: "Lumina index build requires 'global-index.enabled' =
'true'".to_string(),
+ source: None,
+ });
+ }
+ if !table.schema().primary_keys().is_empty() {
+ return Err(Error::Unsupported {
+ message: "Lumina index build does not support primary-key
tables".to_string(),
+ });
+ }
+ if core_options.deletion_vectors_enabled() {
+ return Err(Error::Unsupported {
+ message:
+ "Lumina index build does not support tables with
deletion-vectors.enabled=true"
+ .to_string(),
+ });
+ }
+ Ok(())
+}
+
+fn find_index_field<'a>(table: &'a Table, column: &str) -> Result<&'a
DataField> {
+ table
+ .schema()
+ .fields()
+ .iter()
+ .find(|field| field.name() == column)
+ .ok_or_else(|| Error::ColumnNotExist {
+ full_name: table.identifier().full_name(),
+ column: column.to_string(),
+ })
+}
+
+fn validate_vector_field(field: &DataField) -> Result<()> {
+ if !matches!(
+ field.data_type(),
+ DataType::Array(array) if matches!(array.element_type(),
DataType::Float(_))
+ ) {
+ return Err(Error::DataInvalid {
+ message: format!(
+ "Lumina index requires ARRAY<FLOAT> column, got {:?} for
column '{}'",
+ field.data_type(),
+ field.name()
+ ),
+ source: None,
+ });
+ }
+ Ok(())
+}
+
+fn resolve_lumina_options(
+ table_options: &HashMap<String, String>,
+ user_options: &HashMap<String, String>,
+) -> Result<HashMap<String, String>> {
+ let mut options = table_options.clone();
+ options.extend(user_options.clone());
+ LuminaVectorIndexOptions::new(&options)?;
+ Ok(options)
+}
+
+fn plan_lumina_shards(
+ table_location: &str,
+ partition_keys: &[String],
+ schema_fields: &[DataField],
+ core_options: &CoreOptions,
+ snapshot_id: i64,
+ entries: Vec<crate::spec::ManifestEntry>,
+ rows_per_shard: i64,
+) -> Result<Vec<LuminaIndexShard>> {
+ if rows_per_shard <= 0 {
+ return Err(Error::DataInvalid {
+ message: format!(
+ "Option 'global-index.row-count-per-shard' must be greater
than 0, got: {rows_per_shard}"
+ ),
+ source: None,
+ });
+ }
+
+ let mut by_partition_bucket: HashMap<(Vec<u8>, i32, i32),
Vec<DataFileMeta>> = HashMap::new();
+ for entry in entries {
+ if *entry.kind() != FileKind::Add {
+ continue;
+ }
+ if entry.file().first_row_id.is_none() {
+ return Err(Error::DataInvalid {
+ message: format!(
+ "Data file '{}' is missing first_row_id; cannot build a
complete Lumina index",
+ entry.file().file_name
+ ),
+ source: None,
+ });
+ }
+ let (partition, bucket, total_buckets, file) = entry.into_parts();
+ by_partition_bucket
+ .entry((partition, bucket, total_buckets))
+ .or_default()
+ .push(file);
+ }
+
+ let mut result = Vec::new();
+ for ((partition_bytes, source_bucket, total_buckets), files) in
by_partition_bucket {
+ let partition = if partition_keys.is_empty() {
+ BinaryRow::new(0)
+ } else {
+ BinaryRow::from_serialized_bytes(&partition_bytes)?
+ };
+ let bucket_path = bucket_path(
+ table_location,
+ partition_keys,
+ schema_fields,
+ core_options,
+ &partition,
+ source_bucket,
+ )?;
+ let mut files_by_shard: HashMap<i64, Vec<DataFileMeta>> =
HashMap::new();
+ for file in files {
+ let (file_start, file_end) = file.row_id_range().ok_or_else(||
Error::DataInvalid {
+ message: format!(
+ "Data file '{}' is missing first_row_id; cannot build a
complete Lumina index",
+ file.file_name
+ ),
+ source: None,
+ })?;
+ let start_shard = file_start / rows_per_shard;
+ let end_shard = file_end / rows_per_shard;
+ for shard_id in start_shard..=end_shard {
+ files_by_shard
+ .entry(shard_id * rows_per_shard)
+ .or_default()
+ .push(file.clone());
+ }
+ }
+
+ let mut shard_starts =
files_by_shard.keys().copied().collect::<Vec<_>>();
+ shard_starts.sort_unstable();
+ for shard_start in shard_starts {
+ let shard_end = shard_start + rows_per_shard - 1;
+ let mut shard_files =
files_by_shard.remove(&shard_start).unwrap_or_default();
+ shard_files.sort_by_key(|file| file.first_row_id);
+ let groups = group_contiguous_files(shard_files)?;
+ for group in groups {
+ let group_start = group
+ .first()
+ .and_then(|file| file.first_row_id)
+ .expect("planned groups are non-empty and row-id
assigned");
+ let group_end = group
+ .iter()
+ .map(|file| file.row_id_range().unwrap().1)
+ .max()
+ .unwrap();
+ let row_range_start = group_start.max(shard_start);
+ let row_range_end = group_end.min(shard_end);
+ result.push(LuminaIndexShard {
+ partition: partition.clone(),
+ partition_bytes: partition_bytes.clone(),
+ files: group,
+ row_range_start,
+ row_range_end,
+ snapshot_id,
+ source_bucket,
+ total_buckets,
+ bucket_path: bucket_path.clone(),
+ });
+ }
+ }
+ }
+ result.sort_by(|a, b| {
+ a.partition
+ .to_serialized_bytes()
+ .cmp(&b.partition.to_serialized_bytes())
+ .then(a.source_bucket.cmp(&b.source_bucket))
+ .then(a.row_range_start.cmp(&b.row_range_start))
+ });
+ Ok(result)
+}
+
+fn group_contiguous_files(mut files: Vec<DataFileMeta>) ->
Result<Vec<Vec<DataFileMeta>>> {
+ if files.is_empty() {
+ return Ok(Vec::new());
+ }
+ files.sort_by_key(|file| file.first_row_id);
+ let mut groups = Vec::new();
+ let mut current = Vec::new();
+ let mut current_end = None;
+ for file in files {
+ let (file_start, file_end) = file.row_id_range().ok_or_else(||
Error::DataInvalid {
+ message: format!(
+ "Data file '{}' is missing first_row_id; cannot build a
complete Lumina index",
+ file.file_name
+ ),
+ source: None,
+ })?;
+ match current_end {
+ None => {
+ current.push(file);
+ current_end = Some(file_end);
+ }
+ Some(end) if file_start <= end + 1 => {
+ current.push(file);
+ current_end = Some(end.max(file_end));
+ }
+ Some(_) => {
+ groups.push(std::mem::take(&mut current));
+ current.push(file);
+ current_end = Some(file_end);
+ }
+ }
+ }
+ if !current.is_empty() {
+ groups.push(current);
+ }
+ Ok(groups)
+}
+
+fn bucket_path(
+ table_location: &str,
+ partition_keys: &[String],
+ schema_fields: &[DataField],
+ core_options: &CoreOptions,
+ partition: &BinaryRow,
+ bucket: i32,
+) -> Result<String> {
+ let base = table_location.trim_end_matches('/');
+ if partition_keys.is_empty() {
+ return Ok(format!("{base}/{}", bucket_dir_name(bucket)));
+ }
+ let computer = crate::spec::PartitionComputer::new(
+ partition_keys,
+ schema_fields,
+ core_options.partition_default_name(),
+ core_options.legacy_partition_name(),
+ )?;
+ Ok(format!(
+ "{base}/{}{}",
+ computer.generate_partition_path(partition)?,
+ bucket_dir_name(bucket)
+ ))
+}
+
+async fn validate_existing_index_overlap(
+ table: &Table,
+ index_manifest_name: Option<&str>,
+ index_field_id: i32,
+ shards: &[LuminaIndexShard],
+) -> Result<()> {
+ let Some(index_manifest_name) = index_manifest_name else {
+ return Ok(());
+ };
+ let path = format!(
+ "{}/manifest/{}",
+ table.location().trim_end_matches('/'),
+ index_manifest_name
+ );
+ let entries = IndexManifest::read(table.file_io(), &path).await?;
+ for entry in entries {
+ if entry.kind != FileKind::Add {
+ continue;
+ }
+ let Some(meta) = entry.index_file.global_index_meta else {
+ continue;
+ };
+ if meta.index_field_id != index_field_id {
+ continue;
+ }
+ if shards.iter().any(|shard| {
+ ranges_overlap(
+ meta.row_range_start,
+ meta.row_range_end,
+ shard.row_range_start,
+ shard.row_range_end,
+ )
+ }) {
+ return Err(Error::DataInvalid {
+ message: format!(
+ "Existing global index file '{}' overlaps requested row
range for field {}",
+ entry.index_file.file_name, index_field_id
+ ),
+ source: None,
+ });
+ }
+ }
+ Ok(())
+}
+
+async fn extract_vectors(
+ table: &Table,
+ shard: &LuminaIndexShard,
+ index_column: &str,
+ dimension: i32,
+) -> Result<Vec<f32>> {
+ let split = DataSplitBuilder::new()
+ .with_snapshot(shard.snapshot_id)
+ .with_partition(shard.partition.clone())
+ .with_bucket(shard.source_bucket)
+ .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,
+ )])
+ .build()?;
+
+ let mut read_builder = table.new_read_builder();
+ read_builder.with_projection(&[index_column, ROW_ID_FIELD_NAME]);
+ let read = read_builder.new_read()?;
+ let batches = read.to_arrow(&[split])?.try_collect::<Vec<_>>().await?;
+ extract_vectors_from_batches(
+ &batches,
+ index_column,
+ dimension,
+ shard.row_range_start,
+ i64::from(checked_row_count(
+ shard.row_range_start,
+ shard.row_range_end,
+ )?),
+ )
+}
+
+fn extract_vectors_from_batches(
+ batches: &[RecordBatch],
+ index_column: &str,
+ dimension: i32,
+ row_range_start: i64,
+ expected_row_count: i64,
+) -> Result<Vec<f32>> {
+ let dimension = usize::try_from(dimension).map_err(|e| Error::DataInvalid {
+ message: format!("Invalid Lumina dimension: {dimension}"),
+ source: Some(Box::new(e)),
+ })?;
+ let row_count = batches.iter().map(RecordBatch::num_rows).sum::<usize>();
+ let mut vectors = Vec::with_capacity(row_count * dimension);
+ let mut expected_row_id = row_range_start;
+ for batch in batches {
+ let vector_index =
+ batch
+ .schema()
+ .index_of(index_column)
+ .map_err(|e| Error::DataInvalid {
+ message: format!("Vector column '{index_column}' not found
in read batch: {e}"),
+ source: None,
+ })?;
+ let row_id_index =
+ batch
+ .schema()
+ .index_of(ROW_ID_FIELD_NAME)
+ .map_err(|e| Error::DataInvalid {
+ message: format!("_ROW_ID column not found in read batch:
{e}"),
+ source: None,
+ })?;
+ let vectors_array = batch
+ .column(vector_index)
+ .as_any()
+ .downcast_ref::<ListArray>()
+ .ok_or_else(|| Error::DataInvalid {
+ message: "Lumina vector extraction requires Arrow
List<Float32>".to_string(),
+ source: None,
+ })?;
+ let values = vectors_array
+ .values()
+ .as_any()
+ .downcast_ref::<Float32Array>()
+ .ok_or_else(|| Error::DataInvalid {
+ message: "Lumina vector extraction requires Arrow
List<Float32>".to_string(),
+ source: None,
+ })?;
+ let row_ids = batch
+ .column(row_id_index)
+ .as_any()
+ .downcast_ref::<Int64Array>()
+ .ok_or_else(|| Error::DataInvalid {
+ message: "Lumina vector extraction requires non-null Int64
_ROW_ID".to_string(),
+ source: None,
+ })?;
+
+ for row in 0..batch.num_rows() {
+ if row_ids.is_null(row) {
+ return Err(Error::DataInvalid {
+ message: "Lumina vector extraction found null
_ROW_ID".to_string(),
+ source: None,
+ });
+ }
+ let row_id = row_ids.value(row);
+ if row_id != expected_row_id {
+ return Err(Error::DataInvalid {
+ message: format!(
+ "Lumina vector extraction expected _ROW_ID {}, got {}",
+ expected_row_id, row_id
+ ),
+ source: None,
+ });
+ }
+ expected_row_id += 1;
+
+ if vectors_array.is_null(row) {
+ return Err(Error::DataInvalid {
+ message: "Lumina vector extraction found null vector
row".to_string(),
+ source: None,
+ });
+ }
+ let value_offsets = vectors_array.value_offsets();
+ let start = value_offsets[row] as usize;
+ let end = value_offsets[row + 1] as usize;
+ if end - start != dimension {
+ return Err(Error::DataInvalid {
+ message: format!(
+ "Lumina vector dimension mismatch: expected {}, got
{}",
+ dimension,
+ end - start
+ ),
+ source: None,
+ });
+ }
+ for value_index in start..end {
+ if values.is_null(value_index) {
+ return Err(Error::DataInvalid {
+ message: "Lumina vector extraction found null vector
element".to_string(),
+ source: None,
+ });
+ }
+ vectors.push(values.value(value_index));
+ }
+ }
+ }
+ let actual_row_count = expected_row_id - row_range_start;
+ if actual_row_count != expected_row_count {
+ return Err(Error::DataInvalid {
+ message: format!(
+ "Lumina vector extraction expected {} rows, got {}",
+ expected_row_count, actual_row_count
+ ),
+ source: None,
+ });
+ }
+ Ok(vectors)
+}
+
+fn checked_i32(value: u64, context: &str) -> Result<i32> {
+ i32::try_from(value).map_err(|_| Error::DataInvalid {
+ message: format!("{context}: {value}"),
+ source: None,
+ })
+}
+
+fn checked_row_count(row_range_start: i64, row_range_end: i64) -> Result<i32> {
+ if row_range_end < row_range_start {
+ return Err(Error::DataInvalid {
+ message: format!("Invalid Lumina row range [{row_range_start},
{row_range_end}]"),
+ source: None,
+ });
+ }
+ i32::try_from(row_range_end - row_range_start + 1).map_err(|_|
Error::DataInvalid {
+ message: format!(
+ "Lumina row count is too large for Rust IndexFileMeta:
[{row_range_start}, {row_range_end}]"
+ ),
+ source: None,
+ })
+}
+
+fn validate_vector_buffer(vectors: &[f32], row_count: i32, dimension: i32) ->
Result<()> {
+ if row_count <= 0 {
+ return Err(Error::DataInvalid {
+ message: format!("Lumina shard row count must be positive, got:
{row_count}"),
+ source: None,
+ });
+ }
+ if dimension <= 0 {
+ return Err(Error::DataInvalid {
+ message: format!("Lumina vector dimension must be positive, got:
{dimension}"),
+ source: None,
+ });
+ }
+ let row_count = row_count as usize;
+ let dimension = dimension as usize;
+ let expected_len = row_count
+ .checked_mul(dimension)
+ .ok_or_else(|| Error::DataInvalid {
+ message: format!(
+ "Lumina vector buffer length overflows: row_count={row_count},
dimension={dimension}"
+ ),
+ source: None,
+ })?;
+ if vectors.len() != expected_len {
+ return Err(Error::DataInvalid {
+ message: format!(
+ "Lumina vector buffer length {} does not match row_count={}
and dimension={}",
+ vectors.len(),
+ row_count,
+ dimension
+ ),
+ source: None,
+ });
+ }
+ Ok(())
+}
+
+fn temp_lumina_path() -> PathBuf {
+ std::env::temp_dir().join(format!("lumina-index-{}.index",
uuid::Uuid::new_v4()))
+}
+
+struct TempFileGuard {
+ path: Option<PathBuf>,
+}
+
+impl TempFileGuard {
+ fn new(path: PathBuf) -> Self {
+ Self { path: Some(path) }
+ }
+
+ fn cleanup(mut self) {
+ if let Some(path) = self.path.take() {
+ let _ = std::fs::remove_file(path);
+ }
+ }
+}
+
+impl Drop for TempFileGuard {
+ fn drop(&mut self) {
+ if let Some(path) = self.path.take() {
+ let _ = std::fs::remove_file(path);
+ }
+ }
+}
+
+async fn copy_local_file_to_output(
+ source_path: &Path,
+ output: crate::io::OutputFile,
+) -> Result<()> {
+ let mut source =
+ tokio::fs::File::open(source_path)
+ .await
+ .map_err(|e| Error::UnexpectedError {
+ message: format!("Failed to open temporary Lumina index file:
{e}"),
+ source: None,
+ })?;
+ let mut writer = output.writer().await?;
+ let mut buffer = vec![0u8; COPY_BUFFER_SIZE];
+
+ loop {
+ let len = source
+ .read(&mut buffer)
+ .await
+ .map_err(|e| Error::UnexpectedError {
+ message: format!("Failed to read temporary Lumina index file:
{e}"),
+ source: None,
+ })?;
+ if len == 0 {
+ break;
+ }
+ writer.write(Bytes::copy_from_slice(&buffer[..len])).await?;
+ }
+ writer.close().await
+}
+
+fn ranges_overlap(left_start: i64, left_end: i64, right_start: i64, right_end:
i64) -> bool {
+ left_start <= right_end && right_start <= left_end
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::catalog::Identifier;
+ use crate::io::FileIO;
+ use crate::io::FileIOBuilder;
+ use crate::spec::stats::BinaryTableStats;
+ use crate::spec::{ArrayType, FloatType, IntType, ManifestEntry, Schema,
TableSchema};
+ use crate::table::TableWrite;
+ use arrow_array::builder::{Float32Builder, Int64Builder, ListBuilder};
+ use arrow_array::{ArrayRef, Int32Array};
+ use arrow_schema::{DataType as ArrowDataType, Field as ArrowField, Schema
as ArrowSchema};
+ use chrono::{DateTime, Utc};
+ use std::sync::Arc;
+
+ fn data_file(name: &str, first_row_id: Option<i64>, row_count: i64) ->
DataFileMeta {
+ DataFileMeta {
+ file_name: name.to_string(),
+ file_size: 128,
+ row_count,
+ min_key: vec![],
+ max_key: vec![],
+ key_stats: BinaryTableStats::new(vec![], vec![], vec![]),
+ value_stats: BinaryTableStats::new(vec![], vec![], vec![]),
+ min_sequence_number: 0,
+ max_sequence_number: 0,
+ schema_id: 0,
+ level: 0,
+ extra_files: vec![],
+ creation_time: Some(
+ "2024-09-06T07:45:55.039+00:00"
+ .parse::<DateTime<Utc>>()
+ .unwrap(),
+ ),
+ delete_row_count: None,
+ embedded_index: None,
+ first_row_id,
+ write_cols: None,
+ external_path: None,
+ file_source: None,
+ value_stats_cols: None,
+ }
+ }
+
+ fn manifest_entry(file: DataFileMeta) -> ManifestEntry {
+ manifest_entry_with_bucket(file, 0, 1)
+ }
+
+ fn manifest_entry_with_bucket(
+ file: DataFileMeta,
+ bucket: i32,
+ total_buckets: i32,
+ ) -> ManifestEntry {
+ ManifestEntry::new(FileKind::Add, vec![], bucket, total_buckets, file,
2)
+ }
+
+ fn table_options(rows_per_shard: &str) -> HashMap<String, String> {
+ HashMap::from([
+ ("row-tracking.enabled".to_string(), "true".to_string()),
+ ("data-evolution.enabled".to_string(), "true".to_string()),
+ ("global-index.enabled".to_string(), "true".to_string()),
+ (
+ "global-index.row-count-per-shard".to_string(),
+ rows_per_shard.to_string(),
+ ),
+ ])
+ }
+
+ fn test_table(options: HashMap<String, String>) -> Table {
+ test_table_with_io(
+ FileIOBuilder::new("memory").build().unwrap(),
+ "memory:/test_lumina_builder",
+ Schema::builder()
+ .column("id", DataType::Int(IntType::new()))
+ .column(
+ "embedding",
+
DataType::Array(ArrayType::new(DataType::Float(FloatType::new()))),
+ )
+ .options(options)
+ .build()
+ .unwrap(),
+ )
+ }
+
+ fn test_table_with_schema(schema: Schema) -> Table {
+ test_table_with_io(
+ FileIOBuilder::new("memory").build().unwrap(),
+ "memory:/test_lumina_builder",
+ schema,
+ )
+ }
+
+ fn test_table_with_io(file_io: FileIO, table_path: &str, schema: Schema)
-> Table {
+ Table::new(
+ file_io,
+ Identifier::new("default", "test_table"),
+ table_path.to_string(),
+ TableSchema::new(0, &schema),
+ None,
+ )
+ }
+
+ fn vector_schema_builder(options: HashMap<String, String>) ->
crate::spec::SchemaBuilder {
+ Schema::builder()
+ .column("id", DataType::Int(IntType::new()))
+ .column(
+ "embedding",
+
DataType::Array(ArrayType::new(DataType::Float(FloatType::new()))),
+ )
+ .options(options)
+ }
+
+ fn plan(entries: Vec<ManifestEntry>, rows_per_shard: i64) ->
Result<Vec<LuminaIndexShard>> {
+ let table = test_table(table_options(&rows_per_shard.to_string()));
+ let core = CoreOptions::new(table.schema().options());
+ plan_lumina_shards(
+ table.location(),
+ table.schema().partition_keys(),
+ table.schema().fields(),
+ &core,
+ 1,
+ entries,
+ rows_per_shard,
+ )
+ }
+
+ #[test]
+ fn test_planner_splits_single_file_across_shards() {
+ let shards = plan(vec![manifest_entry(data_file("a", Some(0), 25))],
10).unwrap();
+
+ assert_eq!(
+ shards
+ .iter()
+ .map(|s| (s.row_range_start, s.row_range_end))
+ .collect::<Vec<_>>(),
+ vec![(0, 9), (10, 19), (20, 24)]
+ );
+ }
+
+ #[test]
+ fn test_planner_merges_contiguous_files() {
+ let shards = plan(
+ vec![
+ manifest_entry(data_file("a", Some(0), 5)),
+ manifest_entry(data_file("b", Some(5), 5)),
+ ],
+ 20,
+ )
+ .unwrap();
+
+ assert_eq!(shards.len(), 1);
+ assert_eq!((shards[0].row_range_start, shards[0].row_range_end), (0,
9));
+ assert_eq!(
+ shards[0]
+ .files
+ .iter()
+ .map(|f| f.file_name.as_str())
+ .collect::<Vec<_>>(),
+ vec!["a", "b"]
+ );
+ }
+
+ #[test]
+ fn test_planner_keeps_source_buckets_separate() {
+ let shards = plan(
+ vec![
+ manifest_entry_with_bucket(data_file("a", Some(0), 5), 0, 2),
+ manifest_entry_with_bucket(data_file("b", Some(5), 5), 1, 2),
+ ],
+ 20,
+ )
+ .unwrap();
+
+ assert_eq!(shards.len(), 2);
+ assert_eq!(
+ shards
+ .iter()
+ .map(|s| (
+ s.source_bucket,
+ s.total_buckets,
+ s.row_range_start,
+ s.row_range_end
+ ))
+ .collect::<Vec<_>>(),
+ vec![(0, 2, 0, 4), (1, 2, 5, 9)]
+ );
+ }
+
+ #[test]
+ fn test_planner_splits_gap_into_separate_groups() {
+ let shards = plan(
+ vec![
+ manifest_entry(data_file("a", Some(0), 5)),
+ manifest_entry(data_file("b", Some(10), 5)),
+ ],
+ 20,
+ )
+ .unwrap();
+
+ assert_eq!(
+ shards
+ .iter()
+ .map(|s| (s.row_range_start, s.row_range_end))
+ .collect::<Vec<_>>(),
+ vec![(0, 4), (10, 14)]
+ );
+ }
+
+ #[test]
+ fn test_planner_rejects_missing_first_row_id() {
+ let err = plan(vec![manifest_entry(data_file("a", None, 5))], 10)
+ .expect_err("missing first_row_id should fail");
+ assert!(
+ matches!(err, Error::DataInvalid { message, .. } if
message.contains("missing first_row_id"))
+ );
+ }
+
+ #[test]
+ fn test_planner_rejects_invalid_rows_per_shard() {
+ let err = plan(vec![manifest_entry(data_file("a", Some(0), 5))], 0)
+ .expect_err("invalid rows per shard should fail");
+ assert!(
+ matches!(err, Error::DataInvalid { message, .. } if
message.contains("row-count-per-shard"))
+ );
+ }
+
+ #[tokio::test]
+ async fn test_execute_rejects_primary_key_table() {
+ let table = test_table_with_schema(
+ vector_schema_builder(table_options("10"))
+ .primary_key(["id"])
+ .build()
+ .unwrap(),
+ );
+
+ let err = table
+ .new_lumina_index_build_builder()
+ .with_index_column("embedding")
+ .execute()
+ .await
+ .expect_err("primary-key table should fail before native build");
+
+ assert!(
+ matches!(err, Error::Unsupported { message } if
message.contains("primary-key tables"))
+ );
+ }
+
+ #[tokio::test]
+ async fn test_execute_rejects_deletion_vectors_table() {
+ let mut options = table_options("10");
+ options.insert("deletion-vectors.enabled".to_string(),
"true".to_string());
+ let table = test_table(options);
+
+ let err = table
+ .new_lumina_index_build_builder()
+ .with_index_column("embedding")
+ .execute()
+ .await
+ .expect_err("deletion vectors table should fail before native
build");
+
+ assert!(
+ matches!(err, Error::Unsupported { message } if
message.contains("deletion-vectors.enabled=true"))
+ );
+ }
+
+ fn vector_batch(rows: Vec<Option<Vec<Option<f32>>>>, row_ids:
Vec<Option<i64>>) -> RecordBatch {
+ let mut vector_builder = ListBuilder::new(Float32Builder::new());
+ for row in rows {
+ match row {
+ Some(values) => {
+ for value in values {
+ match value {
+ Some(value) =>
vector_builder.values().append_value(value),
+ None => vector_builder.values().append_null(),
+ }
+ }
+ vector_builder.append(true);
+ }
+ None => vector_builder.append(false),
+ }
+ }
+ let mut row_id_builder = Int64Builder::new();
+ for row_id in row_ids {
+ match row_id {
+ Some(value) => row_id_builder.append_value(value),
+ None => row_id_builder.append_null(),
+ }
+ }
+ let schema = Arc::new(ArrowSchema::new(vec![
+ ArrowField::new(
+ "embedding",
+ ArrowDataType::List(Arc::new(ArrowField::new(
+ "item",
+ ArrowDataType::Float32,
+ true,
+ ))),
+ true,
+ ),
+ ArrowField::new(ROW_ID_FIELD_NAME, ArrowDataType::Int64, true),
+ ]));
+ RecordBatch::try_new(
+ schema,
+ vec![
+ Arc::new(vector_builder.finish()) as ArrayRef,
+ Arc::new(row_id_builder.finish()) as ArrayRef,
+ ],
+ )
+ .unwrap()
+ }
+
+ #[test]
+ fn test_extract_vectors_accepts_list_float32_and_row_ids() {
+ let batch = vector_batch(
+ vec![
+ Some(vec![Some(1.0), Some(2.0)]),
+ Some(vec![Some(3.0), Some(4.0)]),
+ ],
+ vec![Some(10), Some(11)],
+ );
+
+ let vectors = extract_vectors_from_batches(&[batch], "embedding", 2,
10, 2).unwrap();
+
+ assert_eq!(vectors, vec![1.0, 2.0, 3.0, 4.0]);
+ }
+
+ #[test]
+ fn test_extract_vectors_rejects_null_vector() {
+ let batch = vector_batch(vec![None], vec![Some(0)]);
+
+ let err = extract_vectors_from_batches(&[batch], "embedding", 2, 0, 1)
+ .expect_err("null vector should fail");
+
+ assert!(
+ matches!(err, Error::DataInvalid { message, .. } if
message.contains("null vector"))
+ );
+ }
+
+ #[test]
+ fn test_extract_vectors_rejects_null_element() {
+ let batch = vector_batch(vec![Some(vec![Some(1.0), None])],
vec![Some(0)]);
+
+ let err = extract_vectors_from_batches(&[batch], "embedding", 2, 0, 1)
+ .expect_err("null element should fail");
+
+ assert!(
+ matches!(err, Error::DataInvalid { message, .. } if
message.contains("null vector element"))
+ );
+ }
+
+ #[test]
+ fn test_extract_vectors_rejects_dimension_mismatch() {
+ let batch = vector_batch(vec![Some(vec![Some(1.0)])], vec![Some(0)]);
+
+ let err = extract_vectors_from_batches(&[batch], "embedding", 2, 0, 1)
+ .expect_err("dimension mismatch should fail");
+
+ assert!(
+ matches!(err, Error::DataInvalid { message, .. } if
message.contains("dimension mismatch"))
+ );
+ }
+
+ #[test]
+ fn test_extract_vectors_rejects_row_id_gap() {
+ let batch = vector_batch(
+ vec![
+ Some(vec![Some(1.0), Some(2.0)]),
+ Some(vec![Some(3.0), Some(4.0)]),
+ ],
+ vec![Some(0), Some(2)],
+ );
+
+ let err = extract_vectors_from_batches(&[batch], "embedding", 2, 0, 2)
+ .expect_err("row id gap should fail");
+
+ assert!(
+ matches!(err, Error::DataInvalid { message, .. } if
message.contains("expected _ROW_ID"))
+ );
+ }
+
+ #[test]
+ fn test_extract_vectors_rejects_non_list_float32() {
+ let schema = Arc::new(ArrowSchema::new(vec![
+ ArrowField::new("embedding", ArrowDataType::Int32, false),
+ ArrowField::new(ROW_ID_FIELD_NAME, ArrowDataType::Int64, true),
+ ]));
+ let batch = RecordBatch::try_new(
+ schema,
+ vec![
+ Arc::new(Int32Array::from(vec![1])) as ArrayRef,
+ Arc::new(Int64Array::from(vec![Some(0)])) as ArrayRef,
+ ],
+ )
+ .unwrap();
+
+ let err = extract_vectors_from_batches(&[batch], "embedding", 2, 0, 1)
+ .expect_err("non-list vector should fail");
+
+ assert!(
+ matches!(err, Error::DataInvalid { message, .. } if
message.contains("List<Float32>"))
+ );
+ }
+
+ #[test]
+ fn test_checked_metadata_conversion_rejects_large_file_size() {
+ let err = checked_i32(i32::MAX as u64 + 1, "Index file is too large")
+ .expect_err("large file size should fail");
+ assert!(matches!(err, Error::DataInvalid { message, .. } if
message.contains("too large")));
+ }
+
+ #[test]
+ fn test_temp_file_guard_cleans_up_on_drop() {
+ let path = temp_lumina_path();
+ std::fs::write(&path, b"temporary lumina data").unwrap();
+ {
+ let _guard = TempFileGuard::new(path.clone());
+ assert!(path.exists());
+ }
+ assert!(!path.exists());
+ }
+
+ async fn setup_dirs(file_io: &FileIO, table_path: &str) {
+ file_io
+ .mkdirs(&format!("{table_path}/snapshot/"))
+ .await
+ .unwrap();
+ file_io
+ .mkdirs(&format!("{table_path}/manifest/"))
+ .await
+ .unwrap();
+ }
+
+ fn build_vector_batch(ids: Vec<i32>, vectors: Vec<Vec<f32>>) ->
RecordBatch {
+ let element_field = Arc::new(ArrowField::new("element",
ArrowDataType::Float32, true));
+ let mut vector_builder =
+
ListBuilder::new(Float32Builder::new()).with_field(element_field.clone());
+ for vector in vectors {
+ for value in vector {
+ vector_builder.values().append_value(value);
+ }
+ vector_builder.append(true);
+ }
+ let schema = Arc::new(ArrowSchema::new(vec![
+ ArrowField::new("id", ArrowDataType::Int32, false),
+ ArrowField::new("embedding", ArrowDataType::List(element_field),
true),
+ ]));
+ RecordBatch::try_new(
+ schema,
+ vec![
+ Arc::new(Int32Array::from(ids)) as ArrayRef,
+ Arc::new(vector_builder.finish()) as ArrayRef,
+ ],
+ )
+ .unwrap()
+ }
+
+ // Manual run with a local Lumina native library:
+ // LUMINA_LIB_PATH=/path/to/liblumina_py.so cargo test -p paimon \
+ //
table::lumina_index_build_builder::tests::test_execute_writes_lumina_index_manifest
\
+ // --features fulltext,vortex -- --ignored --exact
+ #[tokio::test]
+ #[ignore = "requires LUMINA_LIB_PATH; see manual run command above"]
+ async fn test_execute_writes_lumina_index_manifest() {
+ let file_io = FileIOBuilder::new("memory").build().unwrap();
+ let table_path = "memory:/test_lumina_builder_e2e";
+ setup_dirs(&file_io, table_path).await;
+
+ let mut options = table_options("10");
+ options.insert("lumina.index.dimension".to_string(), "2".to_string());
+ options.insert("lumina.encoding.type".to_string(),
"rawf32".to_string());
+ let table = test_table_with_io(
+ file_io.clone(),
+ table_path,
+ vector_schema_builder(options).build().unwrap(),
+ );
+
+ let mut table_write = TableWrite::new(&table,
"test-user".to_string()).unwrap();
+ table_write
+ .write_arrow_batch(&build_vector_batch(
+ vec![1, 2],
+ vec![vec![1.0, 0.0], vec![0.0, 1.0]],
+ ))
+ .await
+ .unwrap();
+ let messages = table_write.prepare_commit().await.unwrap();
+ TableCommit::new(table.clone(), "test-user".to_string())
+ .commit(messages)
+ .await
+ .unwrap();
+
+ let shard_count = table
+ .new_lumina_index_build_builder()
+ .with_index_column("embedding")
+ .execute()
+ .await
+ .unwrap();
+ assert_eq!(shard_count, 1);
+
+ let snapshot_manager = SnapshotManager::new(file_io.clone(),
table_path.to_string());
+ let snapshot = snapshot_manager
+ .get_latest_snapshot()
+ .await
+ .unwrap()
+ .unwrap();
+ assert_eq!(snapshot.id(), 2);
+ assert_eq!(snapshot.total_record_count(), Some(2));
+ assert_eq!(snapshot.delta_record_count(), Some(0));
+ assert_eq!(snapshot.next_row_id(), Some(2));
+
+ let index_manifest = snapshot.index_manifest().expect("index
manifest");
+ let index_entries =
+ IndexManifest::read(&file_io,
&format!("{table_path}/manifest/{index_manifest}"))
+ .await
+ .unwrap();
+ assert_eq!(index_entries.len(), 1);
+
+ let index_file = &index_entries[0].index_file;
+ assert_eq!(index_file.index_type, LUMINA_IDENTIFIER);
+ assert!(index_file.file_name.starts_with("lumina-global-index-"));
+ assert_eq!(index_file.row_count, 2);
+ assert!(index_file.file_size > 0);
+
+ let global_meta = index_file
+ .global_index_meta
+ .as_ref()
+ .expect("global index meta");
+ assert_eq!(global_meta.row_range_start, 0);
+ assert_eq!(global_meta.row_range_end, 1);
+ assert_eq!(global_meta.index_field_id, 1);
+ assert!(global_meta
+ .index_meta
+ .as_ref()
+ .is_some_and(|m| !m.is_empty()));
+
+ let index_path = format!("{table_path}/index/{}",
index_file.file_name);
+ let status = file_io.get_status(&index_path).await.unwrap();
+ assert_eq!(index_file.file_size as u64, status.size);
+ }
+}
diff --git a/crates/paimon/src/table/mod.rs b/crates/paimon/src/table/mod.rs
index 04c2501..3158ffc 100644
--- a/crates/paimon/src/table/mod.rs
+++ b/crates/paimon/src/table/mod.rs
@@ -38,6 +38,7 @@ mod full_text_search_builder;
pub(crate) mod global_index_scanner;
mod kv_file_reader;
mod kv_file_writer;
+mod lumina_index_build_builder;
pub(crate) mod merge_tree_split_generator;
mod partition_filter;
mod postpone_file_writer;
@@ -71,6 +72,7 @@ pub use data_evolution_writer::DataEvolutionWriter;
#[cfg(feature = "fulltext")]
pub use full_text_search_builder::FullTextSearchBuilder;
use futures::stream::BoxStream;
+pub use lumina_index_build_builder::LuminaIndexBuildBuilder;
pub use read_builder::ReadBuilder;
pub use rest_env::RESTEnv;
pub use schema_manager::SchemaManager;
@@ -182,6 +184,10 @@ impl Table {
VectorSearchBuilder::new(self)
}
+ pub fn new_lumina_index_build_builder(&self) ->
LuminaIndexBuildBuilder<'_> {
+ LuminaIndexBuildBuilder::new(self)
+ }
+
/// Create a write builder for write/commit.
///
/// Reference: [pypaimon
FileStoreTable.new_write_builder](https://github.com/apache/paimon/blob/master/paimon-python/pypaimon/table/file_store_table.py).
diff --git a/crates/paimon/src/table/table_commit.rs
b/crates/paimon/src/table/table_commit.rs
index 27ade42..938a023 100644
--- a/crates/paimon/src/table/table_commit.rs
+++ b/crates/paimon/src/table/table_commit.rs
@@ -100,11 +100,37 @@ impl TableCommit {
let entries = self.messages_to_entries(&commit_messages);
let changelog_entries =
self.messages_to_changelog_entries(&commit_messages);
let new_index_entries =
self.messages_to_index_entries(&commit_messages);
- self.try_commit(CommitEntriesPlan::Direct {
- entries,
- changelog_entries,
- new_index_entries,
- })
+ self.try_commit(
+ CommitEntriesPlan::Direct {
+ entries,
+ changelog_entries,
+ new_index_entries,
+ },
+ None,
+ )
+ .await
+ }
+
+ pub(crate) async fn commit_if_latest_snapshot(
+ &self,
+ commit_messages: Vec<CommitMessage>,
+ expected_snapshot_id: i64,
+ ) -> Result<()> {
+ if commit_messages.is_empty() {
+ return Ok(());
+ }
+
+ let entries = self.messages_to_entries(&commit_messages);
+ let changelog_entries =
self.messages_to_changelog_entries(&commit_messages);
+ let new_index_entries =
self.messages_to_index_entries(&commit_messages);
+ self.try_commit(
+ CommitEntriesPlan::Direct {
+ entries,
+ changelog_entries,
+ new_index_entries,
+ },
+ Some(expected_snapshot_id),
+ )
.await
}
@@ -155,11 +181,14 @@ impl TableCommit {
self.build_dynamic_partition_filter(&new_entries)?
};
- self.try_commit(CommitEntriesPlan::Overwrite {
- partition_filter,
- new_entries,
- new_index_entries,
- })
+ self.try_commit(
+ CommitEntriesPlan::Overwrite {
+ partition_filter,
+ new_entries,
+ new_index_entries,
+ },
+ None,
+ )
.await
}
@@ -254,11 +283,14 @@ impl TableCommit {
&partition_fields,
)?;
- self.try_commit(CommitEntriesPlan::Overwrite {
- partition_filter: Some(partition_filter),
- new_entries: vec![],
- new_index_entries: vec![],
- })
+ self.try_commit(
+ CommitEntriesPlan::Overwrite {
+ partition_filter: Some(partition_filter),
+ new_entries: vec![],
+ new_index_entries: vec![],
+ },
+ None,
+ )
.await
}
@@ -289,25 +321,36 @@ impl TableCommit {
/// Truncate the entire table (OVERWRITE with no filter, only deletes).
pub async fn truncate_table(&self) -> Result<()> {
- self.try_commit(CommitEntriesPlan::Overwrite {
- partition_filter: None,
- new_entries: vec![],
- new_index_entries: vec![],
- })
+ self.try_commit(
+ CommitEntriesPlan::Overwrite {
+ partition_filter: None,
+ new_entries: vec![],
+ new_index_entries: vec![],
+ },
+ None,
+ )
.await
}
/// Try to commit with retries.
- async fn try_commit(&self, plan: CommitEntriesPlan) -> Result<()> {
+ async fn try_commit(
+ &self,
+ plan: CommitEntriesPlan,
+ expected_snapshot_id: Option<i64>,
+ ) -> Result<()> {
let mut retry_count = 0u32;
let mut last_snapshot_for_dup_check: Option<Snapshot> = None;
let start_time_ms = current_time_millis();
loop {
let latest_snapshot =
self.snapshot_manager.get_latest_snapshot().await?;
+ validate_expected_latest_snapshot(expected_snapshot_id,
&latest_snapshot)?;
let resolved = self.resolve_commit(&plan, &latest_snapshot).await?;
- if resolved.entries.is_empty() &&
resolved.changelog_entries.is_empty() {
+ if resolved.entries.is_empty()
+ && resolved.changelog_entries.is_empty()
+ && !resolved.index_manifest_changed
+ {
break;
}
@@ -370,13 +413,20 @@ impl TableCommit {
.as_ref()
.and_then(|s| s.next_row_id())
.unwrap_or(0);
- let (assigned, nrid) = self.assign_row_tracking_meta(
- new_snapshot_id,
- first_row_id_start,
- resolved.entries,
- );
- resolved.entries = assigned;
- next_row_id = Some(nrid);
+ if resolved.entries.is_empty() {
+ next_row_id = latest_snapshot
+ .as_ref()
+ .and_then(|s| s.next_row_id())
+ .or(Some(first_row_id_start));
+ } else {
+ let (assigned, nrid) = self.assign_row_tracking_meta(
+ new_snapshot_id,
+ first_row_id_start,
+ resolved.entries,
+ );
+ resolved.entries = assigned;
+ next_row_id = Some(nrid);
+ }
}
let file_io = self.snapshot_manager.file_io();
@@ -606,28 +656,22 @@ impl TableCommit {
CommitKind::APPEND
};
- let index_manifest_name = if new_index_entries.is_empty() {
+ let previous =
+ Self::read_prev_index_entries(file_io, &manifest_dir,
latest_snapshot).await?;
+ let drop_previous_global_indexes =
+ !entries.is_empty() || !changelog_entries.is_empty();
+ let all = Self::merge_index_entries(
+ &previous,
+ new_index_entries,
+ drop_previous_global_indexes,
+ )?;
+ let index_manifest_changed = all != previous;
+ let index_manifest_name = if index_manifest_changed {
+ Self::write_index_manifest(file_io, &manifest_dir,
&all).await?
+ } else {
latest_snapshot
.as_ref()
.and_then(|s| s.index_manifest().map(|s|
s.to_string()))
- } else {
- let mut all =
- Self::read_prev_index_entries(file_io, &manifest_dir,
latest_snapshot)
- .await?;
- let new_keys: HashSet<(Vec<u8>, i32)> = new_index_entries
- .iter()
- .filter(|e| e.index_file.index_type == "HASH")
- .map(|e| (e.partition.clone(), e.bucket))
- .collect();
- all.retain(|e| {
- if e.index_file.index_type == "HASH" {
- !new_keys.contains(&(e.partition.clone(),
e.bucket))
- } else {
- true
- }
- });
- all.extend_from_slice(new_index_entries);
- Self::write_index_manifest(file_io, &manifest_dir,
&all).await?
};
Ok(ResolvedCommit {
@@ -635,6 +679,7 @@ impl TableCommit {
changelog_entries: changelog_entries.clone(),
kind,
index_manifest_name,
+ index_manifest_changed,
})
}
CommitEntriesPlan::Overwrite {
@@ -650,8 +695,9 @@ impl TableCommit {
)
.await?;
- let mut all =
+ let previous =
Self::read_prev_index_entries(file_io, &manifest_dir,
latest_snapshot).await?;
+ let mut all = previous.clone();
match partition_filter.as_ref() {
None => all.clear(),
Some(filter) => {
@@ -664,20 +710,131 @@ impl TableCommit {
all = retained;
}
}
+ Self::validate_global_index_overlap(&all, new_index_entries)?;
+ Self::validate_added_global_index_overlap(new_index_entries)?;
all.extend_from_slice(new_index_entries);
- let index_manifest_name =
- Self::write_index_manifest(file_io, &manifest_dir,
&all).await?;
+ let index_manifest_changed = all != previous;
+ let index_manifest_name = if index_manifest_changed {
+ Self::write_index_manifest(file_io, &manifest_dir,
&all).await?
+ } else {
+ latest_snapshot
+ .as_ref()
+ .and_then(|s| s.index_manifest().map(|s|
s.to_string()))
+ };
Ok(ResolvedCommit {
entries,
changelog_entries: vec![],
kind: CommitKind::OVERWRITE,
index_manifest_name,
+ index_manifest_changed,
})
}
}
}
+ fn merge_index_entries(
+ previous_entries: &[IndexManifestEntry],
+ new_index_entries: &[IndexManifestEntry],
+ drop_previous_global_indexes: bool,
+ ) -> Result<Vec<IndexManifestEntry>> {
+ let mut all = if drop_previous_global_indexes {
+ previous_entries
+ .iter()
+ .filter(|entry| entry.index_file.global_index_meta.is_none())
+ .cloned()
+ .collect::<Vec<_>>()
+ } else {
+ previous_entries.to_vec()
+ };
+ let new_hash_keys: HashSet<(Vec<u8>, i32)> = new_index_entries
+ .iter()
+ .filter(|e| e.index_file.index_type == "HASH")
+ .map(|e| (e.partition.clone(), e.bucket))
+ .collect();
+ all.retain(|e| {
+ if e.index_file.index_type == "HASH" {
+ !new_hash_keys.contains(&(e.partition.clone(), e.bucket))
+ } else {
+ true
+ }
+ });
+ Self::validate_global_index_overlap(&all, new_index_entries)?;
+ Self::validate_added_global_index_overlap(new_index_entries)?;
+ all.extend_from_slice(new_index_entries);
+ Ok(all)
+ }
+
+ fn validate_global_index_overlap(
+ retained_entries: &[IndexManifestEntry],
+ added_entries: &[IndexManifestEntry],
+ ) -> Result<()> {
+ for retained in retained_entries {
+ if retained.kind == FileKind::Delete {
+ continue;
+ }
+ let Some(retained_meta) =
retained.index_file.global_index_meta.as_ref() else {
+ continue;
+ };
+ for added in added_entries {
+ if added.kind == FileKind::Delete {
+ continue;
+ }
+ let Some(added_meta) =
added.index_file.global_index_meta.as_ref() else {
+ continue;
+ };
+ if retained_meta.index_field_id == added_meta.index_field_id
+ && ranges_overlap(
+ retained_meta.row_range_start,
+ retained_meta.row_range_end,
+ added_meta.row_range_start,
+ added_meta.row_range_end,
+ )
+ {
+ return Err(global_index_overlap_error(
+ retained,
+ retained_meta,
+ added,
+ added_meta,
+ ));
+ }
+ }
+ }
+ Ok(())
+ }
+
+ fn validate_added_global_index_overlap(added_entries:
&[IndexManifestEntry]) -> Result<()> {
+ for (left_index, left) in added_entries.iter().enumerate() {
+ if left.kind == FileKind::Delete {
+ continue;
+ }
+ let Some(left_meta) = left.index_file.global_index_meta.as_ref()
else {
+ continue;
+ };
+ for right in added_entries.iter().skip(left_index + 1) {
+ if right.kind == FileKind::Delete {
+ continue;
+ }
+ let Some(right_meta) =
right.index_file.global_index_meta.as_ref() else {
+ continue;
+ };
+ if left_meta.index_field_id == right_meta.index_field_id
+ && ranges_overlap(
+ left_meta.row_range_start,
+ left_meta.row_range_end,
+ right_meta.row_range_start,
+ right_meta.row_range_end,
+ )
+ {
+ return Err(global_index_overlap_error(
+ left, left_meta, right, right_meta,
+ ));
+ }
+ }
+ }
+ Ok(())
+ }
+
/// Read index entries from the previous snapshot's index manifest.
async fn read_prev_index_entries(
file_io: &FileIO,
@@ -1161,6 +1318,58 @@ struct ResolvedCommit {
changelog_entries: Vec<ManifestEntry>,
kind: CommitKind,
index_manifest_name: Option<String>,
+ index_manifest_changed: bool,
+}
+
+fn ranges_overlap(left_start: i64, left_end: i64, right_start: i64, right_end:
i64) -> bool {
+ left_start <= right_end && right_start <= left_end
+}
+
+fn global_index_overlap_error(
+ retained: &IndexManifestEntry,
+ retained_meta: &crate::spec::GlobalIndexMeta,
+ added: &IndexManifestEntry,
+ added_meta: &crate::spec::GlobalIndexMeta,
+) -> crate::Error {
+ crate::Error::DataInvalid {
+ message: format!(
+ "Trying to add global index file {} of type {} for index field {}
with row range \
+ [{}, {}], but previous file {} still exists with overlapping row
range [{}, {}]. \
+ Remove the previous file first.",
+ added.index_file.file_name,
+ added.index_file.index_type,
+ added_meta.index_field_id,
+ added_meta.row_range_start,
+ added_meta.row_range_end,
+ retained.index_file.file_name,
+ retained_meta.row_range_start,
+ retained_meta.row_range_end,
+ ),
+ source: None,
+ }
+}
+
+fn validate_expected_latest_snapshot(
+ expected_snapshot_id: Option<i64>,
+ latest_snapshot: &Option<Snapshot>,
+) -> Result<()> {
+ let Some(expected_snapshot_id) = expected_snapshot_id else {
+ return Ok(());
+ };
+ let actual_snapshot_id = latest_snapshot.as_ref().map(Snapshot::id);
+ if actual_snapshot_id == Some(expected_snapshot_id) {
+ return Ok(());
+ }
+ Err(crate::Error::DataInvalid {
+ message: format!(
+ "Snapshot changed while committing index files: expected latest
snapshot {}, got {}",
+ expected_snapshot_id,
+ actual_snapshot_id
+ .map(|id| id.to_string())
+ .unwrap_or_else(|| "none".to_string())
+ ),
+ source: None,
+ })
}
fn current_time_millis() -> u64 {
@@ -1190,7 +1399,9 @@ mod tests {
use crate::catalog::Identifier;
use crate::io::FileIOBuilder;
use crate::spec::stats::BinaryTableStats;
- use crate::spec::{BinaryRowBuilder, DataFileMeta, ManifestList,
TableSchema};
+ use crate::spec::{
+ BinaryRowBuilder, DataFileMeta, GlobalIndexMeta, IndexFileMeta,
ManifestList, TableSchema,
+ };
use chrono::{DateTime, Utc};
fn test_file_io() -> FileIO {
@@ -1267,6 +1478,28 @@ mod tests {
}
}
+ fn test_global_index_file(
+ name: &str,
+ index_field_id: i32,
+ row_range_start: i64,
+ row_range_end: i64,
+ ) -> IndexFileMeta {
+ IndexFileMeta {
+ index_type: "lumina".to_string(),
+ file_name: name.to_string(),
+ file_size: 128,
+ row_count: (row_range_end - row_range_start + 1) as i32,
+ deletion_vectors_ranges: None,
+ global_index_meta: Some(GlobalIndexMeta {
+ row_range_start,
+ row_range_end,
+ index_field_id,
+ extra_field_ids: None,
+ index_meta: None,
+ }),
+ }
+ }
+
fn setup_commit(file_io: &FileIO, table_path: &str) -> TableCommit {
let table = test_table(file_io, table_path);
TableCommit::new(table, "test-user".to_string())
@@ -1386,6 +1619,171 @@ mod tests {
assert!(snapshot.is_none());
}
+ #[tokio::test]
+ async fn test_index_only_commit_creates_snapshot() {
+ let file_io = test_file_io();
+ let table_path = "memory:/test_index_only_commit";
+ setup_dirs(&file_io, table_path).await;
+
+ let commit = setup_row_tracking_commit(&file_io, table_path);
+ let mut data_file = test_data_file("data-0.parquet", 10);
+ data_file.file_source = Some(0);
+ commit
+ .commit(vec![CommitMessage::new(vec![], 0, vec![data_file])])
+ .await
+ .unwrap();
+
+ let mut message = CommitMessage::new(vec![], 0, vec![]);
+ message.new_index_files =
vec![test_global_index_file("lumina-0.index", 0, 0, 9)];
+ commit
+ .commit_if_latest_snapshot(vec![message], 1)
+ .await
+ .unwrap();
+
+ let snap_manager = SnapshotManager::new(file_io.clone(),
table_path.to_string());
+ let snapshot =
snap_manager.get_latest_snapshot().await.unwrap().unwrap();
+ assert_eq!(snapshot.id(), 2);
+ assert_eq!(snapshot.total_record_count(), Some(10));
+ assert_eq!(snapshot.delta_record_count(), Some(0));
+ assert_eq!(snapshot.next_row_id(), Some(10));
+
+ let index_manifest = snapshot.index_manifest().expect("index
manifest");
+ let manifest_dir = format!("{table_path}/manifest");
+ let index_entries =
+ IndexManifest::read(&file_io,
&format!("{manifest_dir}/{index_manifest}"))
+ .await
+ .unwrap();
+ assert_eq!(index_entries.len(), 1);
+ assert_eq!(index_entries[0].index_file.file_name, "lumina-0.index");
+ }
+
+ #[tokio::test]
+ async fn test_index_only_commit_rejects_stale_snapshot_guard() {
+ let file_io = test_file_io();
+ let table_path = "memory:/test_index_only_commit_snapshot_guard";
+ setup_dirs(&file_io, table_path).await;
+
+ let commit = setup_row_tracking_commit(&file_io, table_path);
+ let mut data_file = test_data_file("data-0.parquet", 10);
+ data_file.file_source = Some(0);
+ commit
+ .commit(vec![CommitMessage::new(vec![], 0, vec![data_file])])
+ .await
+ .unwrap();
+
+ let mut message = CommitMessage::new(vec![], 0, vec![]);
+ message.new_index_files =
vec![test_global_index_file("lumina-0.index", 0, 0, 9)];
+ let result = commit.commit_if_latest_snapshot(vec![message], 0).await;
+
+ assert!(result.is_err());
+ let err_msg = result.unwrap_err().to_string();
+ assert!(
+ err_msg.contains("Snapshot changed while committing index files"),
+ "expected snapshot guard error, got: {err_msg}"
+ );
+
+ let snap_manager = SnapshotManager::new(file_io.clone(),
table_path.to_string());
+ let snapshot =
snap_manager.get_latest_snapshot().await.unwrap().unwrap();
+ assert_eq!(snapshot.id(), 1);
+ assert!(snapshot.index_manifest().is_none());
+ }
+
+ #[tokio::test]
+ async fn test_global_index_overlap_rejected_on_commit() {
+ let file_io = test_file_io();
+ let table_path = "memory:/test_global_index_overlap";
+ setup_dirs(&file_io, table_path).await;
+
+ let commit = setup_commit(&file_io, table_path);
+ let mut first = CommitMessage::new(vec![], 0, vec![]);
+ first.new_index_files = vec![test_global_index_file("lumina-0.index",
0, 0, 9)];
+ commit.commit(vec![first]).await.unwrap();
+
+ let mut second = CommitMessage::new(vec![], 0, vec![]);
+ second.new_index_files = vec![test_global_index_file("lumina-1.index",
0, 5, 14)];
+
+ let result = commit.commit(vec![second]).await;
+ assert!(result.is_err());
+ let err_msg = result.unwrap_err().to_string();
+ assert!(
+ err_msg.contains("overlapping row range"),
+ "expected overlap error, got: {err_msg}"
+ );
+ }
+
+ #[tokio::test]
+ async fn test_global_index_overlap_rejected_within_same_commit() {
+ let file_io = test_file_io();
+ let table_path = "memory:/test_global_index_overlap_same_commit";
+ setup_dirs(&file_io, table_path).await;
+
+ let commit = setup_commit(&file_io, table_path);
+ let mut message = CommitMessage::new(vec![], 0, vec![]);
+ message.new_index_files = vec![
+ test_global_index_file("lumina-0.index", 0, 0, 9),
+ test_global_index_file("lumina-1.index", 0, 5, 14),
+ ];
+
+ let result = commit.commit(vec![message]).await;
+ assert!(result.is_err());
+ let err_msg = result.unwrap_err().to_string();
+ assert!(
+ err_msg.contains("overlapping row range"),
+ "expected overlap error, got: {err_msg}"
+ );
+ }
+
+ #[tokio::test]
+ async fn test_global_index_non_overlap_allowed_on_commit() {
+ let file_io = test_file_io();
+ let table_path = "memory:/test_global_index_non_overlap";
+ setup_dirs(&file_io, table_path).await;
+
+ let commit = setup_commit(&file_io, table_path);
+ let mut first = CommitMessage::new(vec![], 0, vec![]);
+ first.new_index_files = vec![test_global_index_file("lumina-0.index",
0, 0, 9)];
+ commit.commit(vec![first]).await.unwrap();
+
+ let mut second = CommitMessage::new(vec![], 0, vec![]);
+ second.new_index_files = vec![test_global_index_file("lumina-1.index",
0, 10, 19)];
+ commit.commit(vec![second]).await.unwrap();
+
+ let snap_manager = SnapshotManager::new(file_io.clone(),
table_path.to_string());
+ let snapshot =
snap_manager.get_latest_snapshot().await.unwrap().unwrap();
+ let index_manifest = snapshot.index_manifest().expect("index
manifest");
+ let index_entries =
+ IndexManifest::read(&file_io,
&format!("{table_path}/manifest/{index_manifest}"))
+ .await
+ .unwrap();
+ assert_eq!(index_entries.len(), 2);
+ }
+
+ #[tokio::test]
+ async fn test_append_data_invalidates_previous_global_index() {
+ let file_io = test_file_io();
+ let table_path =
"memory:/test_append_data_invalidates_previous_global_index";
+ setup_dirs(&file_io, table_path).await;
+
+ let commit = setup_commit(&file_io, table_path);
+ let mut first = CommitMessage::new(vec![], 0, vec![]);
+ first.new_index_files = vec![test_global_index_file("lumina-0.index",
0, 0, 9)];
+ commit.commit(vec![first]).await.unwrap();
+
+ commit
+ .commit(vec![CommitMessage::new(
+ vec![],
+ 0,
+ vec![test_data_file("data-0.parquet", 10)],
+ )])
+ .await
+ .unwrap();
+
+ let snap_manager = SnapshotManager::new(file_io.clone(),
table_path.to_string());
+ let snapshot =
snap_manager.get_latest_snapshot().await.unwrap().unwrap();
+ assert_eq!(snapshot.id(), 2);
+ assert!(snapshot.index_manifest().is_none());
+ }
+
#[tokio::test]
async fn test_truncate_table() {
let file_io = test_file_io();