Skip to main content

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}