Skip to content

Commit cc657c5

Browse files
authored
feat: support merging zonemap index segments (#7128)
ZoneMap already supports fragment-scoped segment builds and multi-segment query, but its segments could not be consolidated through the canonical distributed indexing merge path. This adds a ZoneMap merge primitive that rewrites source segments into one self-contained physical segment while preserving fragment coverage and rows-per-zone metadata, and updates the distributed indexing guide to include zone map merge support.
1 parent d95c2c2 commit cc657c5

6 files changed

Lines changed: 521 additions & 8 deletions

File tree

docs/src/guide/distributed_indexing.md

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -18,9 +18,10 @@ write:
1818
3. Lance plans and builds index artifacts from the worker outputs supplied by the caller
1919
4. the built artifacts are committed into the dataset manifest
2020

21-
For vector indices, the worker outputs are segments stored directly
22-
under `indices/<segment_uuid>/`. Lance can turn these outputs into one or more
23-
physical segments and then commit them as one logical index.
21+
For vector indices and segment-native scalar indices, the worker outputs are
22+
segments stored directly under `indices/<segment_uuid>/`. Lance can turn these
23+
outputs into one or more physical segments and then commit them as one logical
24+
index.
2425

2526
![Distributed Vector Segment Build](../images/distributed_vector_segment_build.svg)
2627

@@ -81,7 +82,7 @@ launching workers and driving the overall workflow.
8182

8283
## Current Model
8384

84-
The current model for distributed vector indexing has two layers of parallelism.
85+
The current model for distributed indexing has two layers of parallelism.
8586

8687
### Worker Build
8788

@@ -105,6 +106,12 @@ or merged into larger segments:
105106

106107
Within a single commit, built segments must have disjoint fragment coverage.
107108

109+
`merge_existing_index_segments(...)` currently supports vector, inverted,
110+
bitmap, BTree, and zone map segments. Other scalar index families can still
111+
commit multiple compatible segments directly when their build path supports
112+
fragment-scoped segments, but cannot be merged into a larger physical segment
113+
until they add a merge implementation.
114+
108115
### Vector Model Scope
109116

110117
Distributed vector builds support two model scopes.
@@ -138,7 +145,7 @@ trained segments as separate physical segments.
138145

139146
## Internal Finalize Model
140147

141-
Internally, Lance models distributed vector segment build as:
148+
Internally, Lance models distributed segment build as:
142149

143150
1. **build** one uncommitted segment per worker
144151
2. **optionally merge** caller-defined groups of existing segments

rust/lance-index/src/scalar/zonemap.rs

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -661,6 +661,57 @@ impl ScalarIndex for ZoneMapIndex {
661661
}
662662
}
663663

664+
/// Merge caller-selected ZoneMap segments into one self-contained segment.
665+
pub async fn merge_zonemap_indices(
666+
source_indices: &[&ZoneMapIndex],
667+
dest_store: &dyn IndexStore,
668+
fragment_filter: &RoaringBitmap,
669+
) -> Result<CreatedIndex> {
670+
let first = source_indices.first().ok_or_else(|| {
671+
Error::invalid_input("merge_zonemap_indices requires at least one source index")
672+
})?;
673+
let rows_per_zone = first.rows_per_zone;
674+
let data_type = first.data_type.clone();
675+
676+
let mut zones = Vec::new();
677+
for source in source_indices {
678+
if source.rows_per_zone != rows_per_zone {
679+
return Err(Error::invalid_input(format!(
680+
"cannot merge ZoneMap segments with different rows_per_zone values: {} and {}",
681+
rows_per_zone, source.rows_per_zone
682+
)));
683+
}
684+
if source.data_type != data_type {
685+
return Err(Error::invalid_input(format!(
686+
"cannot merge ZoneMap segments with different value types: {:?} and {:?}",
687+
data_type, source.data_type
688+
)));
689+
}
690+
zones.extend(
691+
source
692+
.zones
693+
.iter()
694+
.filter(|zone| {
695+
u32::try_from(zone.bound.fragment_id)
696+
.is_ok_and(|fragment_id| fragment_filter.contains(fragment_id))
697+
})
698+
.cloned(),
699+
);
700+
}
701+
zones.sort_by_key(|zone| (zone.bound.fragment_id, zone.bound.start));
702+
703+
let mut builder =
704+
ZoneMapIndexBuilder::try_new(ZoneMapIndexBuilderParams::new(rows_per_zone), data_type)?;
705+
builder.maps = zones;
706+
builder.write_index(dest_store).await?;
707+
708+
Ok(CreatedIndex {
709+
index_details: prost_types::Any::from_msg(&pbold::ZoneMapIndexDetails::default()).unwrap(),
710+
index_version: ZONEMAP_INDEX_VERSION,
711+
files: dest_store.list_files_with_sizes().await?,
712+
})
713+
}
714+
664715
fn default_rows_per_zone() -> u64 {
665716
*DEFAULT_ROWS_PER_ZONE
666717
}

rust/lance/src/index.rs

Lines changed: 25 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -272,6 +272,13 @@ fn segment_has_btree_details(segment: &IndexMetadata) -> bool {
272272
)
273273
}
274274

275+
fn segment_has_zonemap_details(segment: &IndexMetadata) -> bool {
276+
segment
277+
.index_details
278+
.as_ref()
279+
.is_some_and(|details| details.type_url.ends_with("ZoneMapIndexDetails"))
280+
}
281+
275282
// Cache keys for different index types
276283
#[derive(Debug, Clone)]
277284
pub(crate) struct LegacyVectorIndexCacheKey<'a> {
@@ -1127,7 +1134,8 @@ impl DatasetIndexExt for Dataset {
11271134
let all_inverted = source_segments.iter().all(segment_has_inverted_details);
11281135
let all_bitmap = source_segments.iter().all(segment_has_bitmap_details);
11291136
let all_btree = source_segments.iter().all(segment_has_btree_details);
1130-
if !all_vector && !all_inverted && !all_bitmap && !all_btree {
1137+
let all_zonemap = source_segments.iter().all(segment_has_zonemap_details);
1138+
if !all_vector && !all_inverted && !all_bitmap && !all_btree && !all_zonemap {
11311139
return Err(Error::invalid_input(
11321140
"merge_existing_index_segments requires all segments to have the same supported index type"
11331141
.to_string(),
@@ -1145,6 +1153,8 @@ impl DatasetIndexExt for Dataset {
11451153
crate::index::scalar::inverted::merge_segments(self, source_segments).await?
11461154
} else if all_bitmap {
11471155
crate::index::scalar::bitmap::merge_segments(self, source_segments).await?
1156+
} else if all_zonemap {
1157+
crate::index::scalar::zonemap::merge_segments(self, source_segments).await?
11481158
} else {
11491159
crate::index::scalar::btree::merge_segments(self, source_segments).await?
11501160
};
@@ -1211,7 +1221,8 @@ impl DatasetIndexExt for Dataset {
12111221
.is_none_or(|(details, expected)| details.type_url == expected)
12121222
})
12131223
.map(|idx| -> Result<Option<IndexMetadata>> {
1214-
let Some(existing_fragments) = idx.fragment_bitmap.as_ref() else {
1224+
let Some(existing_fragments) = idx.effective_fragment_bitmap(&dataset_fragments)
1225+
else {
12151226
if incoming_fragments != dataset_fragments {
12161227
return Err(Error::invalid_input(format!(
12171228
"CreateIndex: cannot replace legacy index segment {} for '{}' with partial fragment coverage; rebuild all fragments in one commit",
@@ -6796,7 +6807,18 @@ mod tests {
67966807
)
67976808
.into_reader_rows(RowCount::from(20), BatchCount::from(2));
67986809

6799-
let mut dataset = Dataset::write(reader, test_uri, None).await.unwrap();
6810+
let mut dataset = Dataset::write(
6811+
reader,
6812+
test_uri,
6813+
Some(WriteParams {
6814+
max_rows_per_file: 20,
6815+
max_rows_per_group: 20,
6816+
..Default::default()
6817+
}),
6818+
)
6819+
.await
6820+
.unwrap();
6821+
assert_eq!(dataset.get_fragments().len(), 2);
68006822

68016823
let field_id = dataset.schema().field("vector").unwrap().id;
68026824
let original = write_vector_segment_metadata(

rust/lance/src/index/scalar.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
pub(crate) mod bitmap;
88
pub(crate) mod btree;
99
pub(crate) mod inverted;
10+
pub(crate) mod zonemap;
1011

1112
pub use inverted::{load_segment_details, load_segments};
1213

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
// SPDX-License-Identifier: Apache-2.0
2+
// SPDX-FileCopyrightText: Copyright The Lance Authors
3+
4+
use std::sync::Arc;
5+
6+
use lance_index::metrics::NoOpMetricsCollector;
7+
use lance_index::scalar::lance_format::LanceIndexStore;
8+
use lance_index::scalar::zonemap::ZoneMapIndex;
9+
use lance_table::format::IndexMetadata;
10+
use roaring::RoaringBitmap;
11+
use uuid::Uuid;
12+
13+
use crate::{Dataset, Error, Result, dataset::index::LanceIndexStoreExt};
14+
15+
/// Merge one caller-defined group of source ZoneMap segments into a single segment.
16+
pub(in crate::index) async fn merge_segments(
17+
dataset: &Dataset,
18+
segments: Vec<IndexMetadata>,
19+
) -> Result<IndexMetadata> {
20+
if segments.is_empty() {
21+
return Err(Error::index("No segment metadata was provided".to_string()));
22+
}
23+
24+
let field_id = *segments[0].fields.first().ok_or_else(|| {
25+
Error::invalid_input(format!(
26+
"CreateIndex: segment {} is missing field ids",
27+
segments[0].uuid
28+
))
29+
})?;
30+
let field_path = dataset.schema().field_path(field_id)?;
31+
32+
let mut scalar_indices = Vec::with_capacity(segments.len());
33+
let mut fragment_bitmap = RoaringBitmap::new();
34+
let dataset_fragments = dataset.fragment_bitmap.as_ref();
35+
for segment in &segments {
36+
let effective = segment
37+
.effective_fragment_bitmap(dataset_fragments)
38+
.ok_or_else(|| {
39+
Error::invalid_input(format!(
40+
"CreateIndex: segment {} is missing fragment coverage",
41+
segment.uuid
42+
))
43+
})?;
44+
fragment_bitmap |= effective;
45+
let scalar_index =
46+
super::open_scalar_index(dataset, &field_path, segment, &NoOpMetricsCollector).await?;
47+
scalar_indices.push((segment.uuid, scalar_index));
48+
}
49+
50+
let mut source_indices = Vec::with_capacity(scalar_indices.len());
51+
for (segment_uuid, scalar_index) in &scalar_indices {
52+
let zonemap_index = scalar_index
53+
.as_any()
54+
.downcast_ref::<ZoneMapIndex>()
55+
.ok_or_else(|| {
56+
Error::index(format!(
57+
"merge_existing_index_segments: expected zonemap segment {}, got {:?}",
58+
segment_uuid,
59+
scalar_index.index_type()
60+
))
61+
})?;
62+
source_indices.push(zonemap_index);
63+
}
64+
65+
let new_uuid = Uuid::new_v4();
66+
let new_store = LanceIndexStore::from_dataset_for_new(dataset, &new_uuid)?;
67+
let created_index = lance_index::scalar::zonemap::merge_zonemap_indices(
68+
&source_indices,
69+
&new_store,
70+
&fragment_bitmap,
71+
)
72+
.await?;
73+
74+
Ok(IndexMetadata {
75+
uuid: new_uuid,
76+
fields: vec![field_id],
77+
dataset_version: dataset.manifest.version,
78+
fragment_bitmap: Some(fragment_bitmap),
79+
index_details: Some(Arc::new(created_index.index_details)),
80+
index_version: created_index.index_version as i32,
81+
created_at: Some(chrono::Utc::now()),
82+
base_id: None,
83+
files: Some(created_index.files),
84+
..segments[0].clone()
85+
})
86+
}

0 commit comments

Comments
 (0)