Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 13 additions & 11 deletions java/src/test/java/org/lance/DatasetTest.java
Original file line number Diff line number Diff line change
Expand Up @@ -1993,18 +1993,20 @@ void testOptimizingIndices(@TempDir Path tempDir) throws Exception {
OptimizeOptions options = OptimizeOptions.builder().numIndicesToMerge(0).build();
dsAppended.optimizeIndices(options);

List<Index> afterIndexes = dsAppended.getIndexes();
Index idIndexAfter =
afterIndexes.stream()
List<Index> idIndexes =
dsAppended.getIndexes().stream()
.filter(idx -> "id_idx".equals(idx.name()))
.findFirst()
.orElse(null);
assertNotNull(idIndexAfter);
List<Integer> afterFragments = idIndexAfter.fragments().orElse(Collections.emptyList());

assertTrue(afterFragments.contains(0));
assertTrue(afterFragments.contains(1));
assertEquals(2, afterFragments.size());
.collect(Collectors.toList());
assertEquals(
2,
idIndexes.size(),
"append-only optimize must add a delta segment instead of merging");

Set<Integer> coveredFragments =
idIndexes.stream()
.flatMap(idx -> idx.fragments().orElse(Collections.emptyList()).stream())
.collect(Collectors.toSet());
assertEquals(new HashSet<>(Arrays.asList(0, 1)), coveredFragments);
}
}
}
Expand Down
104 changes: 67 additions & 37 deletions rust/lance-index/src/scalar/btree.rs
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@ use tracing::{info, instrument};

mod flat;

const BTREE_LOOKUP_NAME: &str = "page_lookup.lance";
pub const BTREE_LOOKUP_NAME: &str = "page_lookup.lance";
const BTREE_PAGES_NAME: &str = "page_data.lance";
pub const DEFAULT_BTREE_BATCH_SIZE: u64 = 4096;
const BATCH_SIZE_META_KEY: &str = "batch_size";
Expand Down Expand Up @@ -1489,7 +1489,7 @@ impl BTreeIndex {
}

/// Create a stream of all the data in the index, in the same format used to train the index
async fn into_data_stream(self) -> Result<SendableRecordBatchStream> {
async fn data_stream(&self) -> Result<SendableRecordBatchStream> {
let lazy_reader = LazyIndexReader::new(self.store.clone(), self.ranges_to_files.clone());
let reader = lazy_reader.get().await?;
let new_schema = Arc::new(self.train_schema());
Expand All @@ -1512,25 +1512,51 @@ impl BTreeIndex {
)))
}

async fn combine_old_new(
self,
/// Merge N source BTree segments plus an additional `new_data` stream into
/// a single BTree under `dest_store`, without re-reading the dataset.
pub async fn merge_segments(
segments: &[Arc<Self>],
new_data: SendableRecordBatchStream,
chunk_size: u64,
dest_store: &dyn IndexStore,
old_data_filter: Option<OldIndexDataFilter>,
) -> Result<SendableRecordBatchStream> {
let value_column_index = new_data.schema().index_of(VALUE_COLUMN_NAME)?;

let new_input = Arc::new(OneShotExec::new(new_data));
let old_stream = self.into_data_stream().await?;
let old_stream = match old_data_filter {
Some(filter) => filter_row_ids(old_stream, filter),
None => old_stream,
) -> Result<CreatedIndex> {
let Some(first) = segments.first() else {
return Err(Error::invalid_input(
"cannot merge BTree index without at least one source segment".to_string(),
));
};
let old_input = Arc::new(OneShotExec::new(old_stream));
debug_assert_eq!(
old_input.schema().flattened_fields().len(),
new_input.schema().flattened_fields().len()
);

for segment in segments.iter().skip(1) {
if segment.data_type != first.data_type {
return Err(Error::index(format!(
"cannot merge BTree segments with different value types ({:?} vs {:?})",
first.data_type, segment.data_type
)));
}
}

let new_schema = new_data.schema();
let value_column_index = new_schema.index_of(VALUE_COLUMN_NAME)?;
let new_value_type = new_schema.field(value_column_index).data_type();
if new_value_type != &first.data_type {
return Err(Error::invalid_input(format!(
"BTree merge: new_data value column type {:?} does not match \
segment value type {:?}",
new_value_type, first.data_type
)));
}

let mut inputs: Vec<Arc<dyn ExecutionPlan>> = Vec::with_capacity(segments.len() + 1);
for segment in segments {
let stream = segment.data_stream().await?;
let stream = match old_data_filter.clone() {
Some(filter) => filter_row_ids(stream, filter),
None => stream,
};
let exec = Arc::new(OneShotExec::new(stream));
inputs.push(exec);
}
inputs.push(Arc::new(OneShotExec::new(new_data)));

let sort_expr = PhysicalSortExpr {
expr: Arc::new(Column::new(VALUE_COLUMN_NAME, value_column_index)),
Expand All @@ -1539,19 +1565,27 @@ impl BTreeIndex {
nulls_first: true,
},
};
// The UnionExec creates multiple partitions but the SortPreservingMergeExec merges
// them back into a single partition.
let all_data = UnionExec::try_new(vec![old_input, new_input])?;
let ordered = Arc::new(SortPreservingMergeExec::new([sort_expr].into(), all_data));

// UnionExec yields multiple partitions; SortPreservingMergeExec merges
// them back into a single partition while preserving value-ordering.
let unioned = UnionExec::try_new(inputs)?;
let ordered = Arc::new(SortPreservingMergeExec::new([sort_expr].into(), unioned));
let unchunked = execute_plan(
ordered,
LanceExecutionOptions {
use_spilling: true,
..Default::default()
},
)?;
Ok(chunk_concat_stream(unchunked, chunk_size as usize))
let merged_stream = chunk_concat_stream(unchunked, first.batch_size as usize);

train_btree_index(merged_stream, dest_store, first.batch_size, None, None).await?;

Ok(CreatedIndex {
index_details: prost_types::Any::from_msg(&pbold::BTreeIndexDetails::default())
.unwrap(),
index_version: BTREE_INDEX_VERSION,
files: Some(dest_store.list_files_with_sizes().await?),
})
}
}

Expand Down Expand Up @@ -1896,19 +1930,15 @@ impl ScalarIndex for BTreeIndex {
dest_store: &dyn IndexStore,
old_data_filter: Option<OldIndexDataFilter>,
) -> Result<CreatedIndex> {
// Merge the existing index data with the new data and then retrain the index on the merged stream
let merged_data_source = self
.clone()
.combine_old_new(new_data, self.batch_size, old_data_filter)
.await?;
train_btree_index(merged_data_source, dest_store, self.batch_size, None, None).await?;

Ok(CreatedIndex {
index_details: prost_types::Any::from_msg(&pbold::BTreeIndexDetails::default())
.unwrap(),
index_version: BTREE_INDEX_VERSION,
files: Some(dest_store.list_files_with_sizes().await?),
})
// Updating is the single-segment case of a segment merge: union this
// index's data with `new_data`, re-sort on value, and retrain.
Self::merge_segments(
&[Arc::new(self.clone())],
new_data,
dest_store,
old_data_filter,
)
.await
}

fn update_criteria(&self) -> UpdateCriteria {
Expand Down
3 changes: 2 additions & 1 deletion rust/lance/src/dataset.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3054,7 +3054,8 @@ impl Dataset {
IndexType::BTree => {
Err(Error::invalid_input(
"BTree distributed indexing no longer supports merge_index_metadata; \
build segments, and commit with commit_existing_index_segments(...)"
build segments, optionally merge groups with merge_existing_index_segments(...), \
and commit with commit_existing_index_segments(...)"
.to_string(),
))
}
Expand Down
21 changes: 19 additions & 2 deletions rust/lance/src/index.rs
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ use lance_index::{INDEX_FILE_NAME, Index, IndexType, PrewarmOptions, pb, vector:
use lance_index::{
IndexCriteria, is_system_index,
metrics::{MetricsCollector, NoOpMetricsCollector},
scalar::btree::BTREE_LOOKUP_NAME,
};
use lance_io::scheduler::{ScanScheduler, SchedulerConfig};
use lance_io::traits::Reader;
Expand Down Expand Up @@ -257,6 +258,19 @@ fn segment_has_bitmap_details(segment: &IndexMetadata) -> bool {
.is_some_and(|details| details.type_url.ends_with("BitmapIndexDetails"))
}

/// Detect BTree segments, preserving a legacy pre-details fallback.
fn segment_has_btree_details(segment: &IndexMetadata) -> bool {
segment.index_details.as_ref().map_or_else(
|| {
segment
.files
.as_ref()
.is_some_and(|files| files.iter().any(|file| file.path == BTREE_LOOKUP_NAME))
},
|details| details.type_url.ends_with("BTreeIndexDetails"),
)
}

// Cache keys for different index types
#[derive(Debug, Clone)]
pub(crate) struct LegacyVectorIndexCacheKey<'a> {
Expand Down Expand Up @@ -1111,7 +1125,8 @@ impl DatasetIndexExt for Dataset {
let all_vector = source_segments.iter().all(segment_has_vector_details);
let all_inverted = source_segments.iter().all(segment_has_inverted_details);
let all_bitmap = source_segments.iter().all(segment_has_bitmap_details);
if !all_vector && !all_inverted && !all_bitmap {
let all_btree = source_segments.iter().all(segment_has_btree_details);
if !all_vector && !all_inverted && !all_bitmap && !all_btree {
return Err(Error::invalid_input(
"merge_existing_index_segments requires all segments to have the same supported index type"
.to_string(),
Expand All @@ -1127,8 +1142,10 @@ impl DatasetIndexExt for Dataset {
.await?
} else if all_inverted {
crate::index::scalar::inverted::merge_segments(self, source_segments).await?
} else {
} else if all_bitmap {
crate::index::scalar::bitmap::merge_segments(self, source_segments).await?
} else {
crate::index::scalar::btree::merge_segments(self, source_segments).await?
};
merged_segment.dataset_version = self.manifest.version;
merged_segment.fields = vec![field_id];
Expand Down
Loading
Loading