lance_index_core/scalar.rs
1// SPDX-License-Identifier: Apache-2.0
2// SPDX-FileCopyrightText: Copyright The Lance Authors
3
4//! Abstract scalar index traits and types for Lance index plugins
5
6use arrow_array::{BooleanArray, RecordBatch, UInt64Array};
7use arrow_schema::Schema;
8use async_trait::async_trait;
9use bytes::Bytes;
10use datafusion::physical_plan::SendableRecordBatchStream;
11use datafusion_common::scalar::ScalarValue;
12use datafusion_expr::Expr;
13use lance_core::deepsize::DeepSizeOf;
14use lance_core::utils::row_addr_remap::RowAddrRemap;
15use lance_core::{Error, Result};
16use lance_io::stream::{RecordBatchStream, RecordBatchStreamAdapter};
17use lance_select::{NullableRowAddrSet, RowAddrTreeMap, RowSetOps};
18use roaring::{RoaringBitmap, RoaringTreemap};
19use serde::Serialize;
20use std::collections::HashMap;
21use std::fmt::Debug;
22use std::pin::Pin;
23use std::{any::Any, sync::Arc};
24
25use crate::metrics::MetricsCollector;
26use crate::{Index, IndexParams, IndexType};
27
28/// Metadata about a single file within an index segment.
29#[derive(Debug, Clone, PartialEq, DeepSizeOf)]
30pub struct IndexFile {
31 /// Path relative to the index directory (e.g., "index.idx", "auxiliary.idx")
32 pub path: String,
33 /// Size of the file in bytes
34 pub size_bytes: u64,
35}
36
37pub const LANCE_SCALAR_INDEX: &str = "__lance_scalar_index";
38
39/// Builtin index types supported by the Lance library
40///
41/// This is primarily for convenience to avoid a bunch of string
42/// constants and provide some auto-complete. This type should not
43/// be used in the manifest as plugins cannot add new entries.
44#[derive(Debug, Clone, PartialEq, Eq, DeepSizeOf)]
45pub enum BuiltinIndexType {
46 BTree,
47 Bitmap,
48 LabelList,
49 NGram,
50 ZoneMap,
51 BloomFilter,
52 RTree,
53 Inverted,
54 Fm,
55}
56
57impl BuiltinIndexType {
58 pub fn as_str(&self) -> &str {
59 match self {
60 Self::BTree => "btree",
61 Self::Bitmap => "bitmap",
62 Self::LabelList => "labellist",
63 Self::NGram => "ngram",
64 Self::ZoneMap => "zonemap",
65 Self::Inverted => "inverted",
66 Self::BloomFilter => "bloomfilter",
67 Self::RTree => "rtree",
68 Self::Fm => "fm",
69 }
70 }
71}
72
73impl TryFrom<IndexType> for BuiltinIndexType {
74 type Error = Error;
75
76 fn try_from(value: IndexType) -> Result<Self> {
77 match value {
78 IndexType::BTree => Ok(Self::BTree),
79 IndexType::Bitmap => Ok(Self::Bitmap),
80 IndexType::LabelList => Ok(Self::LabelList),
81 IndexType::NGram => Ok(Self::NGram),
82 IndexType::ZoneMap => Ok(Self::ZoneMap),
83 IndexType::Inverted => Ok(Self::Inverted),
84 IndexType::BloomFilter => Ok(Self::BloomFilter),
85 IndexType::RTree => Ok(Self::RTree),
86 IndexType::Fm => Ok(Self::Fm),
87 _ => Err(Error::index("Invalid index type".to_string())),
88 }
89 }
90}
91
92#[derive(Debug, Clone, PartialEq)]
93pub struct ScalarIndexParams {
94 /// The type of index to create
95 ///
96 /// Plugins may add additional index types. Index type lookup is case-insensitive.
97 pub index_type: String,
98 /// The parameters to train the index
99 ///
100 /// This should be a JSON string. The contents of the JSON string will be specific to the
101 /// index type. If not set, then default parameters will be used for the index type.
102 pub params: Option<String>,
103}
104
105impl Default for ScalarIndexParams {
106 fn default() -> Self {
107 Self {
108 index_type: BuiltinIndexType::BTree.as_str().to_string(),
109 params: None,
110 }
111 }
112}
113
114impl ScalarIndexParams {
115 /// Creates a new ScalarIndexParams from one of the builtin index types
116 pub fn for_builtin(index_type: BuiltinIndexType) -> Self {
117 Self {
118 index_type: index_type.as_str().to_string(),
119 params: None,
120 }
121 }
122
123 /// Create a new ScalarIndexParams with the given index type
124 pub fn new(index_type: String) -> Self {
125 Self {
126 index_type,
127 params: None,
128 }
129 }
130
131 /// Set the parameters for the index
132 pub fn with_params<ParamsType: Serialize>(mut self, params: &ParamsType) -> Self {
133 self.params = Some(serde_json::to_string(params).unwrap());
134 self
135 }
136}
137
138impl IndexParams for ScalarIndexParams {
139 fn as_any(&self) -> &dyn std::any::Any {
140 self
141 }
142
143 fn index_name(&self) -> &str {
144 LANCE_SCALAR_INDEX
145 }
146}
147
148/// Trait for storing an index (or parts of an index) into storage
149#[async_trait]
150pub trait IndexWriter: Send {
151 /// Writes a record batch into the file, returning the 0-based index of the batch in the file
152 ///
153 /// E.g. if this is the third time this is called this method will return 2
154 async fn write_record_batch(&mut self, batch: RecordBatch) -> Result<u64>;
155 /// Adds a global buffer and returns its index.
156 async fn add_global_buffer(&mut self, _data: Bytes) -> Result<u32> {
157 Err(Error::not_supported(
158 "global buffers are not supported by this index writer",
159 ))
160 }
161 /// Finishes writing the file and closes the file
162 async fn finish(&mut self) -> Result<IndexFile>;
163 /// Finishes writing the file and closes the file with additional metadata
164 async fn finish_with_metadata(
165 &mut self,
166 metadata: HashMap<String, String>,
167 ) -> Result<IndexFile>;
168}
169
170/// Trait for reading an index (or parts of an index) from storage
171#[async_trait]
172pub trait IndexReader: Send + Sync {
173 /// Read the n-th record batch from the file
174 async fn read_record_batch(&self, n: u64, batch_size: u64) -> Result<RecordBatch>;
175 /// Reads a global buffer by index.
176 async fn read_global_buffer(&self, _index: u32) -> Result<Bytes> {
177 Err(Error::not_supported(
178 "global buffers are not supported by this index reader",
179 ))
180 }
181 /// Read the range of rows from the file.
182 /// If projection is Some, only return the columns in the projection,
183 /// nested columns like Some(&["x.y"]) are not supported.
184 /// If projection is None, return all columns.
185 async fn read_range(
186 &self,
187 range: std::ops::Range<usize>,
188 projection: Option<&[&str]>,
189 ) -> Result<RecordBatch>;
190 /// Read multiple ranges and concatenate into a single batch.
191 /// Default impl runs `read_range`s in parallel via `try_join_all`.
192 async fn read_ranges(
193 &self,
194 ranges: &[std::ops::Range<usize>],
195 projection: Option<&[&str]>,
196 ) -> Result<RecordBatch> {
197 if ranges.is_empty() {
198 return self.read_range(0..0, projection).await;
199 }
200 let futures = ranges
201 .iter()
202 .map(|r| self.read_range(r.clone(), projection));
203 let batches = futures::future::try_join_all(futures).await?;
204 let schema = batches[0].schema();
205 Ok(arrow_select::concat::concat_batches(&schema, &batches)?)
206 }
207 /// Read a range of rows as a stream of record batches.
208 ///
209 /// This allows the caller to process rows incrementally without loading the
210 /// entire range into memory at once.
211 ///
212 /// The default implementation falls back to [`Self::read_range`] and wraps
213 /// the result in a single-item stream.
214 async fn read_range_stream(
215 &self,
216 range: std::ops::Range<usize>,
217 projection: Option<&[&str]>,
218 ) -> Result<Pin<Box<dyn RecordBatchStream>>> {
219 let batch = self.read_range(range, projection).await?;
220 let schema = batch.schema();
221 Ok(Box::pin(RecordBatchStreamAdapter::new(
222 schema,
223 futures::stream::once(async move { Ok(batch) }),
224 )))
225 }
226 /// Return the number of batches in the file
227 async fn num_batches(&self, batch_size: u64) -> u32;
228 /// Return the number of rows in the file
229 fn num_rows(&self) -> usize;
230 /// Return the metadata of the file
231 fn schema(&self) -> &lance_core::datatypes::Schema;
232 /// Best-effort on-disk byte size of the file when the reader already knows it
233 /// without extra I/O, else `None`. Used to size prewarm chunks.
234 fn file_size_bytes(&self) -> Option<u64> {
235 None
236 }
237}
238
239/// Trait abstracting I/O away from index logic
240///
241/// Scalar indices are currently serialized as indexable arrow record batches stored in
242/// named "files". The index store is responsible for serializing and deserializing
243/// these batches into file data (e.g. as .lance files or .parquet files, etc.)
244#[async_trait]
245pub trait IndexStore: std::fmt::Debug + Send + Sync + DeepSizeOf {
246 fn as_any(&self) -> &dyn Any;
247 fn clone_arc(&self) -> Arc<dyn IndexStore>;
248
249 /// Suggested I/O parallelism for the store
250 fn io_parallelism(&self) -> usize;
251
252 /// Create a new file and return a writer to store data in the file
253 async fn new_index_file(&self, name: &str, schema: Arc<Schema>)
254 -> Result<Box<dyn IndexWriter>>;
255
256 /// Open an existing file for retrieval
257 async fn open_index_file(&self, name: &str) -> Result<Arc<dyn IndexReader>>;
258
259 /// Return a store that submits its I/O at the given base priority.
260 fn with_io_priority(&self, io_priority: u64) -> Arc<dyn IndexStore>;
261
262 /// Copy a range of batches from an index file from this store to another
263 ///
264 /// This is often useful when remapping or updating
265 async fn copy_index_file(&self, name: &str, dest_store: &dyn IndexStore) -> Result<IndexFile>;
266
267 /// Copy an index file from this store to a new name in another store, leaving the source intact
268 async fn copy_index_file_to(
269 &self,
270 name: &str,
271 new_name: &str,
272 dest_store: &dyn IndexStore,
273 ) -> Result<IndexFile> {
274 if name == new_name {
275 self.copy_index_file(name, dest_store).await
276 } else {
277 Err(Error::not_supported(format!(
278 "copying index file {name} to {new_name} is not supported by this index store"
279 )))
280 }
281 }
282
283 /// Rename an index file
284 async fn rename_index_file(&self, name: &str, new_name: &str) -> Result<IndexFile>;
285
286 /// Delete an index file (used in the tmp spill store to keep tmp size down)
287 async fn delete_index_file(&self, name: &str) -> Result<()>;
288
289 /// List all files in the index directory with their sizes.
290 ///
291 /// Returns a list of (relative_path, size_bytes) tuples.
292 /// Used to capture file metadata after index creation/modification.
293 async fn list_files_with_sizes(&self) -> Result<Vec<IndexFile>>;
294}
295
296/// Different scalar indices may support different kinds of queries
297///
298/// For example, a btree index can support a wide range of queries (e.g. x > 7)
299/// while an index based on FTS only supports queries like "x LIKE 'foo'"
300///
301/// This trait is used when we need an object that can represent any kind of query
302///
303/// Note: if you are implementing this trait for a query type then you probably also
304/// need to implement the scalar query parser trait to create instances of your query at parse time.
305pub trait AnyQuery: std::fmt::Debug + Any + Send + Sync {
306 /// Cast the query as Any to allow for downcasting
307 fn as_any(&self) -> &dyn Any;
308 /// Format the query as a string for display purposes
309 fn format(&self, col: &str) -> String;
310 /// Convert the query to a datafusion expression
311 fn to_expr(&self, col: String) -> Expr;
312 /// Compare this query to another query
313 fn dyn_eq(&self, other: &dyn AnyQuery) -> bool;
314}
315
316impl PartialEq for dyn AnyQuery {
317 fn eq(&self, other: &Self) -> bool {
318 self.dyn_eq(other)
319 }
320}
321
322/// The result of a search operation against a scalar index
323#[derive(Debug, PartialEq)]
324pub enum SearchResult {
325 /// The exact row ids that satisfy the query
326 Exact(NullableRowAddrSet),
327 /// Any row id satisfying the query will be in this set but not every
328 /// row id in this set will satisfy the query, a further recheck step
329 /// is needed
330 AtMost(NullableRowAddrSet),
331 /// All of the given row ids satisfy the query but there may be more
332 ///
333 /// No scalar index actually returns this today but it can arise from
334 /// boolean operations (e.g. NOT(AtMost(x)) == AtLeast(NOT(x)))
335 AtLeast(NullableRowAddrSet),
336}
337
338impl SearchResult {
339 pub fn exact(row_ids: impl Into<RowAddrTreeMap>) -> Self {
340 Self::Exact(NullableRowAddrSet::new(row_ids.into(), Default::default()))
341 }
342
343 pub fn at_most(row_ids: impl Into<RowAddrTreeMap>) -> Self {
344 Self::AtMost(NullableRowAddrSet::new(row_ids.into(), Default::default()))
345 }
346
347 pub fn at_least(row_ids: impl Into<RowAddrTreeMap>) -> Self {
348 Self::AtLeast(NullableRowAddrSet::new(row_ids.into(), Default::default()))
349 }
350
351 pub fn with_nulls(self, nulls: impl Into<RowAddrTreeMap>) -> Self {
352 match self {
353 Self::Exact(row_ids) => Self::Exact(row_ids.with_nulls(nulls.into())),
354 Self::AtMost(row_ids) => Self::AtMost(row_ids.with_nulls(nulls.into())),
355 Self::AtLeast(row_ids) => Self::AtLeast(row_ids.with_nulls(nulls.into())),
356 }
357 }
358
359 pub fn row_addrs(&self) -> &NullableRowAddrSet {
360 match self {
361 Self::Exact(row_addrs) => row_addrs,
362 Self::AtMost(row_addrs) => row_addrs,
363 Self::AtLeast(row_addrs) => row_addrs,
364 }
365 }
366
367 pub fn is_exact(&self) -> bool {
368 matches!(self, Self::Exact(_))
369 }
370}
371
372/// Brief information about an index that was created
373pub struct CreatedIndex {
374 /// The details of the index that was created
375 ///
376 /// These should be stored somewhere as they will be needed to
377 /// load the index later.
378 pub index_details: prost_types::Any,
379 /// The version of the index that was created
380 ///
381 /// This can be used to determine if a reader is able to load the index.
382 pub index_version: u32,
383 /// List of files and their sizes for this index
384 ///
385 /// This enables skipping HEAD calls when opening indices and provides
386 /// visibility into index storage size via describe_indices().
387 pub files: Vec<IndexFile>,
388}
389
390/// The ordering that training data must satisfy
391#[derive(Debug, Clone, Copy, PartialEq, Eq)]
392pub enum TrainingOrdering {
393 /// The input will arrive sorted by the value column in ascending order
394 Values,
395 /// The input will arrive sorted by the address column in ascending order
396 Addresses,
397 /// The input will arrive in an arbitrary order
398 None,
399}
400
401#[derive(Debug, Clone)]
402pub struct TrainingCriteria {
403 pub ordering: TrainingOrdering,
404 pub needs_row_ids: bool,
405 pub needs_row_addrs: bool,
406}
407
408impl TrainingCriteria {
409 pub fn new(ordering: TrainingOrdering) -> Self {
410 Self {
411 ordering,
412 needs_row_ids: false,
413 needs_row_addrs: false,
414 }
415 }
416
417 pub fn with_row_id(mut self) -> Self {
418 self.needs_row_ids = true;
419 self
420 }
421
422 pub fn with_row_addr(mut self) -> Self {
423 self.needs_row_addrs = true;
424 self
425 }
426}
427
428/// The criteria that specifies how to update an index
429pub struct UpdateCriteria {
430 /// If true, then we need to read the old data to update the index
431 ///
432 /// This should be avoided if possible but is left in for some legacy paths
433 pub requires_old_data: bool,
434 /// The criteria required for data (both old and new)
435 pub data_criteria: TrainingCriteria,
436}
437
438/// Filter used when merging existing scalar-index rows during update.
439///
440/// The caller must pick a filter mode that matches the row-id semantics of the
441/// dataset:
442/// - address-style row IDs: fragment filtering is valid
443/// - stable row IDs: use exact row-id membership instead
444#[derive(Debug, Clone)]
445pub enum OldIndexDataFilter {
446 /// Keeps track of which fragments are still valid and which are no longer valid.
447 ///
448 /// This is valid for address-style row IDs.
449 Fragments {
450 to_keep: RoaringBitmap,
451 to_remove: RoaringBitmap,
452 },
453 /// Keep old rows whose row IDs are in this exact allow-list.
454 ///
455 /// This is required for stable row IDs, where row IDs are opaque and
456 /// should not be interpreted as encoded row addresses.
457 RowIds(RowAddrTreeMap),
458}
459
460impl OldIndexDataFilter {
461 /// Build a boolean mask that keeps only row IDs selected by this filter.
462 pub fn filter_row_ids(&self, row_ids: &UInt64Array) -> BooleanArray {
463 match self {
464 Self::Fragments { to_keep, .. } => row_ids
465 .iter()
466 .map(|id| id.map(|id| to_keep.contains((id >> 32) as u32)))
467 .collect(),
468 Self::RowIds(valid_row_ids) => row_ids
469 .iter()
470 .map(|id| id.map(|id| valid_row_ids.contains(id)))
471 .collect(),
472 }
473 }
474
475 /// Apply this filter in place to a set of existing (old) row ids/addresses,
476 /// retaining only the rows the filter selects to keep. Used by index types
477 /// that merge old postings directly (e.g. bitmap) instead of re-scanning a
478 /// row-id array through [`Self::filter_row_ids`].
479 pub fn retain_old_rows(&self, rows: &mut RowAddrTreeMap) {
480 match self {
481 Self::Fragments { to_keep, .. } => rows.retain_fragments(to_keep.iter()),
482 Self::RowIds(valid_row_ids) => *rows &= valid_row_ids,
483 }
484 }
485}
486
487impl UpdateCriteria {
488 pub fn requires_old_data(data_criteria: TrainingCriteria) -> Self {
489 Self {
490 requires_old_data: true,
491 data_criteria,
492 }
493 }
494
495 pub fn only_new_data(data_criteria: TrainingCriteria) -> Self {
496 Self {
497 requires_old_data: false,
498 data_criteria,
499 }
500 }
501}
502
503/// A trait for a scalar index, a structure that can determine row ids that satisfy scalar queries
504#[async_trait]
505pub trait ScalarIndex: Send + Sync + std::fmt::Debug + Index + DeepSizeOf {
506 /// Search the scalar index
507 ///
508 /// Returns all row ids that satisfy the query, these row ids are not necessarily ordered
509 async fn search(
510 &self,
511 query: &dyn AnyQuery,
512 metrics: &dyn MetricsCollector,
513 ) -> Result<SearchResult>;
514
515 /// Returns true if this index reports matches as physical row addresses
516 /// (`fragment_id << 32 | offset`) rather than row ids
517 ///
518 /// Address-domain indices (e.g. zone map, bloom filter) are built over the
519 /// `_rowaddr` column. On a dataset with stable row ids the address and
520 /// row-id domains diverge, so these results must be translated back to row
521 /// ids (via the per-fragment row-id sequences, known only at the dataset
522 /// layer) before they are combined with row-id results or handed to the
523 /// scan. The default (row-id domain) needs no translation.
524 fn results_are_row_addresses(&self) -> bool {
525 false
526 }
527
528 /// Returns true if the remap operation is supported
529 fn can_remap(&self) -> bool;
530
531 /// Remap the row ids, creating a new remapped version of this index in `dest_store`
532 async fn remap(
533 &self,
534 mapping: &RowAddrRemap,
535 dest_store: &dyn IndexStore,
536 ) -> Result<CreatedIndex>;
537
538 /// Add the new data into the index, creating an updated version of the index in `dest_store`
539 ///
540 /// If `old_data_filter` is provided, old index data will be filtered before
541 /// merge according to the chosen filter mode.
542 async fn update(
543 &self,
544 new_data: SendableRecordBatchStream,
545 dest_store: &dyn IndexStore,
546 old_data_filter: Option<OldIndexDataFilter>,
547 ) -> Result<CreatedIndex>;
548
549 /// Returns the criteria that will be used to update the index
550 fn update_criteria(&self) -> UpdateCriteria;
551
552 /// Derive the index parameters from the current index
553 ///
554 /// This returns a ScalarIndexParams that can be used to recreate an index
555 /// with the same configuration on another dataset.
556 fn derive_index_params(&self) -> Result<ScalarIndexParams>;
557
558 /// Global `[min, max]` of the indexed column from index metadata, without a
559 /// scan, or `None` if this index type cannot supply a sound bound. When
560 /// `Some`, the range is a superset of live values (conservative under
561 /// deletes): safe to prune with, not guaranteed tight.
562 fn value_range(&self) -> Option<(ScalarValue, ScalarValue)> {
563 None
564 }
565}
566
567/// Abstraction over any type that can remap row IDs during index loading.
568///
569/// This decouples scalar index plugins from the table-level frag reuse index type.
570/// The frag reuse index implements this trait, but callers may also supply custom
571/// implementations for testing or other remapping strategies.
572pub trait RowIdRemapper: Send + Sync + std::fmt::Debug {
573 /// Remap a single row id. Returns `None` if the row was deleted.
574 fn remap_row_id(&self, row_id: u64) -> Option<u64>;
575 /// Remap all addresses in a [`RowAddrTreeMap`], dropping deleted rows.
576 fn remap_row_addrs_tree_map(&self, row_addrs: &RowAddrTreeMap) -> RowAddrTreeMap;
577 /// Remap all row ids in a [`RoaringTreemap`], dropping deleted rows.
578 fn remap_row_ids_roaring_tree_map(&self, row_ids: &RoaringTreemap) -> RoaringTreemap;
579 /// Remap the row-id column at `row_id_idx` inside `batch`, dropping deleted rows.
580 fn remap_row_ids_record_batch(
581 &self,
582 batch: RecordBatch,
583 row_id_idx: usize,
584 ) -> Result<RecordBatch>;
585}