Skip to main content

lance_index/scalar/inverted/
builder.rs

1// SPDX-License-Identifier: Apache-2.0
2// SPDX-FileCopyrightText: Copyright The Lance Authors
3
4use super::{InvertedIndexParams, index::*};
5use crate::scalar::inverted::json::JsonTextStream;
6use crate::scalar::inverted::lance_tokenizer::DocType;
7use crate::scalar::inverted::tokenizer::lance_tokenizer::LanceTokenizer;
8#[cfg(test)]
9use crate::scalar::lance_format::LanceIndexStore;
10use crate::scalar::{IndexStore, OldIndexDataFilter};
11use crate::vector::graph::OrderedFloat;
12use crate::{progress::IndexBuildProgress, progress::noop_progress};
13use arrow::array::AsArray;
14use arrow::datatypes;
15use arrow_array::{Array, BinaryArray, RecordBatch, UInt64Array};
16use arrow_schema::{DataType, Field, Schema, SchemaRef};
17use bitpacking::{BitPacker, BitPacker4x};
18use datafusion::execution::{RecordBatchStream, SendableRecordBatchStream};
19use deepsize::DeepSizeOf;
20use fst::Streamer;
21use futures::{Stream, StreamExt, TryStreamExt};
22use lance_arrow::json::JSON_EXT_NAME;
23use lance_arrow::{ARROW_EXT_NAME_KEY, iter_str_array};
24use lance_core::cache::LanceCache;
25use lance_core::error::LanceOptionExt;
26use lance_core::utils::tokio::{IO_CORE_RESERVATION, get_num_compute_intensive_cpus, spawn_cpu};
27use lance_core::{Error, ROW_ID, ROW_ID_FIELD, Result};
28use lance_io::object_store::ObjectStore;
29use object_store::path::Path;
30use roaring::RoaringBitmap;
31use smallvec::SmallVec;
32use std::collections::HashMap;
33use std::pin::Pin;
34use std::str::FromStr;
35use std::sync::Arc;
36use std::sync::LazyLock;
37use std::task::{Context, Poll};
38use std::{fmt::Debug, sync::atomic::AtomicU64};
39use tracing::instrument;
40
41// the number of elements in each block
42// each block contains 128 row ids and 128 frequencies
43// WARNING: changing this value will break the compatibility with existing indexes
44pub const BLOCK_SIZE: usize = BitPacker4x::BLOCK_LEN;
45
46// The default number of workers to use for FTS builds.
47// By default this is roughly `num_cpus / 2`, but it can be overridden
48// with `LANCE_FTS_NUM_SHARDS`.
49pub static LANCE_FTS_NUM_SHARDS: LazyLock<usize> = LazyLock::new(|| {
50    std::env::var("LANCE_FTS_NUM_SHARDS")
51        .unwrap_or_else(|_| default_num_workers().to_string())
52        .parse()
53        .expect("failed to parse LANCE_FTS_NUM_SHARDS")
54});
55// The default per-worker memory limit in MiB for FTS builds.
56pub static LANCE_FTS_PARTITION_SIZE: LazyLock<u64> = LazyLock::new(|| {
57    std::env::var("LANCE_FTS_PARTITION_SIZE")
58        .unwrap_or_else(|_| "2048".to_string())
59        .parse()
60        .expect("failed to parse LANCE_FTS_PARTITION_SIZE")
61});
62static LANCE_FTS_WRITE_QUEUE_SIZE: LazyLock<usize> = LazyLock::new(|| {
63    std::env::var("LANCE_FTS_WRITE_QUEUE_SIZE")
64        .unwrap_or_else(|_| "1".to_string())
65        .parse()
66        .expect("failed to parse LANCE_FTS_WRITE_QUEUE_SIZE")
67});
68static LANCE_FTS_POSTING_BATCH_ROWS: LazyLock<usize> = LazyLock::new(|| {
69    std::env::var("LANCE_FTS_POSTING_BATCH_ROWS")
70        .unwrap_or_else(|_| "256".to_string())
71        .parse()
72        .expect("failed to parse LANCE_FTS_POSTING_BATCH_ROWS")
73});
74const MAX_RETAINED_TOKEN_IDS: usize = 8 * 1024;
75
76fn default_num_workers() -> usize {
77    let total_cpus = get_num_compute_intensive_cpus() + *IO_CORE_RESERVATION;
78    std::cmp::max(1, total_cpus / 2)
79}
80
81fn resolve_num_workers(params: &InvertedIndexParams) -> usize {
82    let max_workers = get_num_compute_intensive_cpus().max(1);
83    params
84        .num_workers
85        .unwrap_or(*LANCE_FTS_NUM_SHARDS)
86        .clamp(1, max_workers)
87}
88
89fn resolve_worker_memory_limit_bytes(params: &InvertedIndexParams, num_workers: usize) -> u64 {
90    let default_worker_memory_limit_bytes = *LANCE_FTS_PARTITION_SIZE << 20;
91    params
92        .memory_limit_mb
93        .map(|memory_limit_mb| (memory_limit_mb << 20) / num_workers as u64)
94        .unwrap_or(default_worker_memory_limit_bytes)
95}
96
97fn merge_all_tail_partitions(tails: Vec<TailPartition>) -> Result<Option<InnerBuilder>> {
98    if tails.is_empty() {
99        return Ok(None);
100    }
101    merge_tail_partition_group(tails).map(Some)
102}
103
104fn merge_tail_partition_group(group: Vec<TailPartition>) -> Result<InnerBuilder> {
105    let mut group = group.into_iter();
106    let mut merged = group
107        .next()
108        .ok_or_else(|| {
109            Error::invalid_input("cannot merge an empty tail partition group".to_owned())
110        })?
111        .builder;
112    for tail in group {
113        merged.merge_from(tail.builder)?;
114    }
115    Ok(merged)
116}
117
118#[derive(Debug)]
119pub struct InvertedIndexBuilder {
120    params: InvertedIndexParams,
121    pub(crate) partitions: Vec<u64>,
122    new_partitions: Vec<u64>,
123    fragment_mask: Option<u64>,
124    token_set_format: TokenSetFormat,
125    format_version: InvertedListFormatVersion,
126    posting_tail_codec: PostingTailCodec,
127    src_store: Option<Arc<dyn IndexStore>>,
128    progress: Arc<dyn IndexBuildProgress>,
129    deleted_fragments: RoaringBitmap,
130}
131
132impl InvertedIndexBuilder {
133    pub fn new(params: InvertedIndexParams) -> Self {
134        Self::new_with_fragment_mask(params, None)
135    }
136
137    pub fn new_with_fragment_mask(params: InvertedIndexParams, fragment_mask: Option<u64>) -> Self {
138        Self::from_existing_index(
139            params,
140            None,
141            Vec::new(),
142            TokenSetFormat::default(),
143            fragment_mask,
144            RoaringBitmap::new(),
145        )
146    }
147
148    /// Creates an InvertedIndexBuilder from existing index with fragment filtering.
149    /// This method is used to create a builder from an existing index while applying
150    /// fragment-based filtering for distributed indexing scenarios.
151    /// fragment_mask Optional mask with fragment_id in high 32 bits for filtering.
152    /// Constructed as `(fragment_id as u64) << 32`.
153    /// When provided, ensures that generated IDs belong to the specified fragment.
154    pub fn from_existing_index(
155        params: InvertedIndexParams,
156        store: Option<Arc<dyn IndexStore>>,
157        partitions: Vec<u64>,
158        token_set_format: TokenSetFormat,
159        fragment_mask: Option<u64>,
160        deleted_fragments: RoaringBitmap,
161    ) -> Self {
162        Self {
163            params,
164            partitions,
165            new_partitions: Vec::new(),
166            src_store: store,
167            token_set_format,
168            fragment_mask,
169            format_version: current_fts_format_version(),
170            posting_tail_codec: current_fts_format_version().posting_tail_codec(),
171            progress: noop_progress(),
172            deleted_fragments,
173        }
174    }
175
176    pub fn with_posting_tail_codec(mut self, posting_tail_codec: PostingTailCodec) -> Self {
177        self.format_version =
178            InvertedListFormatVersion::from_posting_tail_codec(posting_tail_codec);
179        self.posting_tail_codec = posting_tail_codec;
180        self
181    }
182
183    pub fn with_format_version(mut self, format_version: InvertedListFormatVersion) -> Self {
184        self.format_version = format_version;
185        self.posting_tail_codec = format_version.posting_tail_codec();
186        self
187    }
188
189    pub fn with_progress(mut self, progress: Arc<dyn IndexBuildProgress>) -> Self {
190        self.progress = progress;
191        self
192    }
193
194    pub async fn update(
195        &mut self,
196        new_data: SendableRecordBatchStream,
197        dest_store: &dyn IndexStore,
198        old_data_filter: Option<crate::scalar::OldIndexDataFilter>,
199    ) -> Result<()> {
200        let schema = new_data.schema();
201        let doc_col = schema.field(0).name();
202
203        // infer lance_tokenizer based on document type
204        if self.params.lance_tokenizer.is_none() {
205            let schema = new_data.schema();
206            let field = schema.column_with_name(doc_col).expect_ok()?.1;
207            let doc_type = DocType::try_from(field)?;
208            self.params.lance_tokenizer = Some(doc_type.as_ref().to_string());
209        }
210
211        let new_data = document_input(new_data, doc_col)?;
212
213        self.progress
214            .stage_start("tokenize_docs", None, "rows")
215            .await?;
216        self.update_index(new_data, dest_store).await?;
217
218        if let Some(OldIndexDataFilter::Fragments { to_remove, .. }) = old_data_filter {
219            self.deleted_fragments.extend(to_remove);
220        }
221
222        self.progress.stage_complete("tokenize_docs").await?;
223        self.write(dest_store).await?;
224        Ok(())
225    }
226
227    #[instrument(level = "debug", skip_all)]
228    async fn update_index(
229        &mut self,
230        stream: SendableRecordBatchStream,
231        dest_store: &dyn IndexStore,
232    ) -> Result<()> {
233        let num_workers = resolve_num_workers(&self.params);
234        let tokenizer = self.params.build()?;
235        let with_position = self.params.with_position;
236        let worker_memory_limit_bytes =
237            resolve_worker_memory_limit_bytes(&self.params, num_workers);
238        let worker_config = IndexWorkerConfig {
239            with_position,
240            format_version: self.format_version,
241            fragment_mask: self.fragment_mask,
242            token_set_format: self.token_set_format,
243            worker_memory_limit_bytes,
244        };
245        let next_id = self.partitions.iter().map(|id| id + 1).max().unwrap_or(0);
246        let id_alloc = Arc::new(AtomicU64::new(next_id));
247        let tokenized_count = Arc::new(AtomicU64::new(0));
248        let (sender, receiver) = async_channel::bounded(num_workers);
249        let dest_store = dest_store.clone_arc();
250        let mut index_tasks = Vec::with_capacity(num_workers);
251        for _ in 0..num_workers {
252            let tokenizer = tokenizer.clone();
253            let receiver: async_channel::Receiver<RecordBatch> = receiver.clone();
254            let dest_store = dest_store.clone();
255            let id_alloc = id_alloc.clone();
256            let progress = self.progress.clone();
257            let tokenized_count = tokenized_count.clone();
258            index_tasks.push(tokio::task::spawn(async move {
259                let mut worker =
260                    IndexWorker::new(tokenizer, dest_store, id_alloc, worker_config).await?;
261                while let Ok(batch) = receiver.recv().await {
262                    let num_rows = batch.num_rows();
263                    worker.process_batch(batch).await?;
264                    let tokenized_count = tokenized_count
265                        .fetch_add(num_rows as u64, std::sync::atomic::Ordering::Relaxed)
266                        + num_rows as u64;
267                    progress
268                        .stage_progress("tokenize_docs", tokenized_count)
269                        .await?;
270                }
271                worker.finish().await
272            }));
273        }
274
275        let index_build = async {
276            // Keep the channel lifetime tied to the worker tasks so senders observe
277            // worker exits instead of blocking on an orphaned receiver handle.
278            drop(receiver);
279
280            let mut stream = Box::pin(stream);
281            log::info!("indexing FTS with {} workers", num_workers);
282
283            let mut last_num_rows = 0;
284            let mut total_num_rows = 0;
285            let start = std::time::Instant::now();
286            while let Some(batch) = stream.try_next().await? {
287                let num_rows = batch.num_rows();
288
289                if sender.send(batch).await.is_err() {
290                    // this only happens if all workers have exited,
291                    // so we don't return the send error here,
292                    // avoiding hiding the real error from workers.
293                    break;
294                }
295
296                total_num_rows += num_rows;
297                if total_num_rows >= last_num_rows + 1_000_000 {
298                    log::debug!(
299                        "indexed {} documents, elapsed: {:?}, speed: {}rows/s",
300                        total_num_rows,
301                        start.elapsed(),
302                        total_num_rows as f32 / start.elapsed().as_secs_f32()
303                    );
304                    last_num_rows = total_num_rows;
305                }
306            }
307            // drop the sender to stop receivers
308            drop(stream);
309            drop(sender);
310            log::info!("dispatching elapsed: {:?}", start.elapsed());
311
312            // wait for the workers to finish
313            let start = std::time::Instant::now();
314            let mut tail_partitions = Vec::new();
315            for index_task in index_tasks {
316                let output = index_task.await??;
317                self.new_partitions.extend(output.partitions);
318                if let Some(tail_partition) = output.tail_partition {
319                    tail_partitions.push(tail_partition);
320                }
321            }
322            let merged_tail_partitions =
323                spawn_cpu(move || merge_all_tail_partitions(tail_partitions)).await?;
324            if let Some(builder) = merged_tail_partitions {
325                self.new_partitions.push(builder.id());
326                let mut builder = builder;
327                builder.write(dest_store.as_ref()).await?;
328            }
329            log::info!("wait workers indexing elapsed: {:?}", start.elapsed());
330            Result::Ok(())
331        };
332
333        index_build.await
334    }
335
336    pub async fn remap(
337        &mut self,
338        mapping: &HashMap<u64, Option<u64>>,
339        src_store: Arc<dyn IndexStore>,
340        dest_store: &dyn IndexStore,
341    ) -> Result<()> {
342        for part in self.partitions.iter() {
343            let part = InvertedPartition::load(
344                src_store.clone(),
345                *part,
346                None,
347                &LanceCache::no_cache(),
348                self.token_set_format,
349            )
350            .await?;
351            let mut builder = part.into_builder().await?;
352            builder.remap(mapping).await?;
353            builder.write(dest_store).await?;
354        }
355        if self.fragment_mask.is_none() {
356            self.write_metadata(dest_store, &self.partitions).await?;
357        } else {
358            // in distributed mode, the part_temp_metadata is written by the worker
359            for &partition_id in &self.partitions {
360                self.write_part_metadata(dest_store, partition_id).await?;
361            }
362        }
363        Ok(())
364    }
365
366    async fn write_metadata(&self, dest_store: &dyn IndexStore, partitions: &[u64]) -> Result<()> {
367        let mut serialized_deleted_fragments =
368            Vec::with_capacity(self.deleted_fragments.serialized_size());
369        self.deleted_fragments
370            .serialize_into(&mut serialized_deleted_fragments)?;
371
372        let mut metadata = HashMap::from_iter(vec![
373            ("partitions".to_owned(), serde_json::to_string(&partitions)?),
374            ("params".to_owned(), serde_json::to_string(&self.params)?),
375            (
376                TOKEN_SET_FORMAT_KEY.to_owned(),
377                self.token_set_format.to_string(),
378            ),
379            (
380                POSTING_TAIL_CODEC_KEY.to_owned(),
381                self.posting_tail_codec.as_str().to_owned(),
382            ),
383        ]);
384
385        if self.params.with_position && self.format_version.uses_shared_position_stream() {
386            metadata.insert(
387                POSITIONS_LAYOUT_KEY.to_owned(),
388                POSITIONS_LAYOUT_SHARED_STREAM_V2.to_owned(),
389            );
390            metadata.insert(
391                POSITIONS_CODEC_KEY.to_owned(),
392                self.format_version
393                    .position_codec()
394                    .expect("shared positions require a codec")
395                    .as_str()
396                    .to_owned(),
397            );
398        }
399
400        let metadata_file_schema = Arc::new(Schema::new(vec![Field::new(
401            DELETED_FRAGMENTS_COL,
402            DataType::Binary,
403            false,
404        )]));
405        let deleted_fragments_col = Arc::new(BinaryArray::from(vec![
406            serialized_deleted_fragments.as_slice(),
407        ])) as Arc<dyn Array>;
408        let record_batch =
409            RecordBatch::try_new(metadata_file_schema.clone(), vec![deleted_fragments_col])?;
410
411        let mut writer = dest_store
412            .new_index_file(METADATA_FILE, metadata_file_schema)
413            .await?;
414        writer.write_record_batch(record_batch).await?;
415        writer.finish_with_metadata(metadata).await?;
416        Ok(())
417    }
418
419    /// Write partition metadata file for a single partition
420    ///
421    /// In a distributed environment, each worker node can write partition metadata files for the partitions it processes,
422    /// which are then merged into a final metadata file using the `merge_metadata_files` function.
423    pub(crate) async fn write_part_metadata(
424        &self,
425        dest_store: &dyn IndexStore,
426        partition: u64, // Modify parameter type
427    ) -> Result<()> {
428        let partitions = vec![partition];
429        let mut metadata = HashMap::from_iter(vec![
430            ("partitions".to_owned(), serde_json::to_string(&partitions)?),
431            ("params".to_owned(), serde_json::to_string(&self.params)?),
432            (
433                TOKEN_SET_FORMAT_KEY.to_owned(),
434                self.token_set_format.to_string(),
435            ),
436            (
437                POSTING_TAIL_CODEC_KEY.to_owned(),
438                self.posting_tail_codec.as_str().to_owned(),
439            ),
440        ]);
441        if self.params.with_position && self.format_version.uses_shared_position_stream() {
442            metadata.insert(
443                POSITIONS_LAYOUT_KEY.to_owned(),
444                POSITIONS_LAYOUT_SHARED_STREAM_V2.to_owned(),
445            );
446            metadata.insert(
447                POSITIONS_CODEC_KEY.to_owned(),
448                self.format_version
449                    .position_codec()
450                    .expect("shared positions require a codec")
451                    .as_str()
452                    .to_owned(),
453            );
454        }
455        // Use partition ID to generate a unique temporary filename
456        let file_name = part_metadata_file_path(partition);
457        let mut writer = dest_store
458            .new_index_file(&file_name, Arc::new(Schema::empty()))
459            .await?;
460        writer.finish_with_metadata(metadata).await?;
461        Ok(())
462    }
463
464    async fn write_metadata_with_progress(
465        &self,
466        dest_store: &dyn IndexStore,
467        partitions: &[u64],
468    ) -> Result<()> {
469        let total = if self.fragment_mask.is_none() {
470            Some(1)
471        } else {
472            Some(partitions.len() as u64)
473        };
474        self.progress
475            .stage_start("write_metadata", total, "files")
476            .await?;
477        if self.fragment_mask.is_none() {
478            self.write_metadata(dest_store, partitions).await?;
479            self.progress.stage_progress("write_metadata", 1).await?;
480        } else {
481            let mut completed = 0;
482            for &partition_id in partitions {
483                self.write_part_metadata(dest_store, partition_id).await?;
484                completed += 1;
485                self.progress
486                    .stage_progress("write_metadata", completed)
487                    .await?;
488            }
489        }
490        self.progress.stage_complete("write_metadata").await?;
491        Ok(())
492    }
493
494    async fn write(&self, dest_store: &dyn IndexStore) -> Result<()> {
495        let mut partitions = Vec::with_capacity(self.partitions.len() + self.new_partitions.len());
496        partitions.extend_from_slice(&self.partitions);
497        partitions.extend_from_slice(&self.new_partitions);
498        partitions.sort_unstable();
499
500        self.progress
501            .stage_start(
502                "copy_partitions",
503                Some(partitions.len() as u64),
504                "partitions",
505            )
506            .await?;
507        let mut copied = 0;
508        for part in self.partitions.iter() {
509            self.src_store
510                .as_ref()
511                .expect("existing partitions require a source store")
512                .copy_index_file(&token_file_path(*part), dest_store)
513                .await?;
514            self.src_store
515                .as_ref()
516                .expect("existing partitions require a source store")
517                .copy_index_file(&posting_file_path(*part), dest_store)
518                .await?;
519            self.src_store
520                .as_ref()
521                .expect("existing partitions require a source store")
522                .copy_index_file(&doc_file_path(*part), dest_store)
523                .await?;
524            copied += 1;
525            self.progress
526                .stage_progress("copy_partitions", copied)
527                .await?;
528        }
529        for _part in self.new_partitions.iter() {
530            copied += 1;
531            self.progress
532                .stage_progress("copy_partitions", copied)
533                .await?;
534        }
535        self.progress.stage_complete("copy_partitions").await?;
536
537        self.write_metadata_with_progress(dest_store, &partitions)
538            .await?;
539        Ok(())
540    }
541}
542
543impl Default for InvertedIndexBuilder {
544    fn default() -> Self {
545        let params = InvertedIndexParams::default();
546        Self::new(params)
547    }
548}
549
550// builder for single partition
551#[derive(Debug)]
552pub struct InnerBuilder {
553    id: u64,
554    with_position: bool,
555    token_set_format: TokenSetFormat,
556    format_version: InvertedListFormatVersion,
557    posting_tail_codec: PostingTailCodec,
558    pub(crate) tokens: TokenSet,
559    pub(crate) posting_lists: Vec<PostingListBuilder>,
560    pub(crate) docs: DocSet,
561}
562
563impl InnerBuilder {
564    pub fn new(id: u64, with_position: bool, token_set_format: TokenSetFormat) -> Self {
565        Self::new_with_format_version(
566            id,
567            with_position,
568            token_set_format,
569            current_fts_format_version(),
570        )
571    }
572
573    pub fn new_with_format_version(
574        id: u64,
575        with_position: bool,
576        token_set_format: TokenSetFormat,
577        format_version: InvertedListFormatVersion,
578    ) -> Self {
579        Self {
580            id,
581            with_position,
582            token_set_format,
583            format_version,
584            posting_tail_codec: format_version.posting_tail_codec(),
585            tokens: TokenSet::default(),
586            posting_lists: Vec::new(),
587            docs: DocSet::default(),
588        }
589    }
590
591    pub fn new_with_posting_tail_codec(
592        id: u64,
593        with_position: bool,
594        token_set_format: TokenSetFormat,
595        posting_tail_codec: PostingTailCodec,
596    ) -> Self {
597        let format_version = if posting_tail_codec == PostingTailCodec::Fixed32 {
598            InvertedListFormatVersion::V1
599        } else {
600            InvertedListFormatVersion::V2
601        };
602        let mut builder =
603            Self::new_with_format_version(id, with_position, token_set_format, format_version);
604        builder.posting_tail_codec = posting_tail_codec;
605        builder
606    }
607
608    pub fn id(&self) -> u64 {
609        self.id
610    }
611
612    /// Set the token set for this builder.
613    pub fn set_tokens(&mut self, tokens: TokenSet) {
614        self.tokens = tokens;
615    }
616
617    /// Set the document set for this builder.
618    pub fn set_docs(&mut self, docs: DocSet) {
619        self.docs = docs;
620    }
621
622    /// Set the posting lists for this builder.
623    pub fn set_posting_lists(&mut self, posting_lists: Vec<PostingListBuilder>) {
624        self.posting_lists = posting_lists;
625    }
626
627    pub async fn remap(&mut self, mapping: &HashMap<u64, Option<u64>>) -> Result<()> {
628        // for the docs, we need to remove the rows that are removed from the doc set,
629        // and update the row ids of the rows that are updated
630        let removed = self.docs.remap(mapping);
631
632        // for the posting lists, we need to remap the doc ids:
633        // - if the a row is removed, we need to shift the doc ids of the following rows
634        // - if a row is updated (assigned a new row id), we don't need to do anything with the posting lists
635        let mut token_id = 0;
636        let mut removed_token_ids = Vec::new();
637        self.posting_lists.retain_mut(|posting_list| {
638            posting_list.remap(&removed);
639            let keep = !posting_list.is_empty();
640            if !keep {
641                removed_token_ids.push(token_id as u32);
642            }
643            token_id += 1;
644            keep
645        });
646
647        // for the tokens, remap the token ids if any posting list is empty
648        self.tokens.remap(&removed_token_ids);
649
650        Ok(())
651    }
652
653    pub fn merge_from(&mut self, other: Self) -> Result<()> {
654        let Self {
655            id: _,
656            with_position,
657            token_set_format,
658            format_version,
659            posting_tail_codec,
660            tokens,
661            posting_lists,
662            docs,
663        } = other;
664
665        if self.with_position != with_position {
666            return Err(Error::index(format!(
667                "cannot merge partitions with mismatched positions settings: {} vs {}",
668                self.with_position, with_position
669            )));
670        }
671        if self.token_set_format != token_set_format {
672            return Err(Error::index(format!(
673                "cannot merge partitions with mismatched token set formats: {:?} vs {:?}",
674                self.token_set_format, token_set_format
675            )));
676        }
677        if self.format_version != format_version {
678            return Err(Error::index(format!(
679                "cannot merge partitions with mismatched FTS format versions: {:?} vs {:?}",
680                self.format_version, format_version
681            )));
682        }
683        if self.posting_tail_codec != posting_tail_codec {
684            return Err(Error::index(format!(
685                "cannot merge partitions with mismatched posting tail codecs: {:?} vs {:?}",
686                self.posting_tail_codec, posting_tail_codec
687            )));
688        }
689
690        let mut token_id_map = vec![u32::MAX; posting_lists.len()];
691        match tokens.tokens {
692            TokenMap::HashMap(map) => {
693                for (token, token_id) in map {
694                    let new_token_id = self.tokens.get_or_add(token.as_str());
695                    token_id_map[token_id as usize] = new_token_id;
696                }
697            }
698            TokenMap::Fst(map) => {
699                let mut stream = map.stream();
700                while let Some((token, token_id)) = stream.next() {
701                    let new_token_id = self
702                        .tokens
703                        .get_or_add(String::from_utf8_lossy(token).as_ref());
704                    token_id_map[token_id as usize] = new_token_id;
705                }
706            }
707        }
708
709        let doc_id_offset = self.docs.len() as u32;
710        for (row_id, num_tokens) in docs.iter() {
711            self.docs.append(*row_id, *num_tokens);
712        }
713        self.posting_lists.resize_with(self.tokens.len(), || {
714            PostingListBuilder::new_with_posting_tail_codec(with_position, self.posting_tail_codec)
715        });
716
717        for (token_id, posting_list) in posting_lists.into_iter().enumerate() {
718            if posting_list.is_empty() {
719                continue;
720            }
721            let new_token_id = token_id_map[token_id];
722            debug_assert_ne!(new_token_id, u32::MAX);
723            let merged_posting = &mut self.posting_lists[new_token_id as usize];
724            posting_list.for_each_entry(|doc_id, freq, positions| {
725                let positions = match positions {
726                    Some(positions) => PositionRecorder::Position(positions.into()),
727                    None => PositionRecorder::Count(freq),
728                };
729                merged_posting.add(doc_id_offset + doc_id, positions);
730                Ok::<(), Error>(())
731            })?;
732        }
733
734        Ok(())
735    }
736
737    pub async fn write(&mut self, store: &dyn IndexStore) -> Result<()> {
738        let docs = Arc::new(std::mem::take(&mut self.docs));
739        self.write_posting_lists(store, docs.clone()).await?;
740        self.write_tokens(store).await?;
741        self.write_docs(store, docs).await?;
742        Ok(())
743    }
744
745    #[instrument(level = "debug", skip_all)]
746    async fn write_posting_lists(
747        &mut self,
748        store: &dyn IndexStore,
749        docs: Arc<DocSet>,
750    ) -> Result<()> {
751        let id = self.id;
752        let mut writer = store
753            .new_index_file(
754                &posting_file_path(self.id),
755                inverted_list_schema_for_version(self.with_position, self.format_version),
756            )
757            .await?;
758        let posting_lists = std::mem::take(&mut self.posting_lists);
759
760        log::info!(
761            "writing {} posting lists of partition {}, with position {}",
762            posting_lists.len(),
763            id,
764            self.with_position
765        );
766        let with_position = self.with_position;
767        let format_version = self.format_version;
768        let schema = inverted_list_schema_for_version(self.with_position, self.format_version);
769        let docs_for_batches = docs.clone();
770        let schema_for_batches = schema.clone();
771        let batch_rows = *LANCE_FTS_POSTING_BATCH_ROWS;
772        let (tx, rx) = async_channel::bounded(*LANCE_FTS_WRITE_QUEUE_SIZE);
773        let producer = spawn_cpu(move || {
774            let mut batch_builder = PostingListBatchBuilder::new(
775                schema_for_batches.clone(),
776                with_position,
777                format_version,
778                batch_rows,
779            );
780            for posting_list in posting_lists {
781                posting_list.append_to_batch_with_docs(
782                    &docs_for_batches,
783                    &mut batch_builder,
784                    format_version,
785                )?;
786                if batch_builder.len() < batch_rows {
787                    continue;
788                }
789
790                let batch = batch_builder.finish()?;
791                if let Err(err) = tx.send_blocking(batch) {
792                    return Err(Error::execution(format!(
793                        "failed to send posting list batch to writer: {err}"
794                    )));
795                }
796            }
797
798            if !batch_builder.is_empty() {
799                let batch = batch_builder.finish()?;
800                if let Err(err) = tx.send_blocking(batch) {
801                    return Err(Error::execution(format!(
802                        "failed to send posting list batch to writer: {err}"
803                    )));
804                }
805            }
806
807            Result::Ok(())
808        });
809
810        while let Ok(batch) = rx.recv().await {
811            if let Err(err) = writer.write_record_batch(batch).await {
812                drop(rx);
813                // Wait for producer to stop; preserve the write error as the primary failure.
814                let _ = producer.await;
815                return Err(err);
816            }
817        }
818        drop(rx);
819        producer.await?;
820
821        writer.finish().await?;
822        Ok(())
823    }
824
825    #[instrument(level = "debug", skip_all)]
826    async fn write_tokens(&mut self, store: &dyn IndexStore) -> Result<()> {
827        log::info!("writing tokens of partition {}", self.id);
828        let tokens = std::mem::take(&mut self.tokens);
829        let batch = tokens.to_batch(self.token_set_format)?;
830        let mut writer = store
831            .new_index_file(&token_file_path(self.id), batch.schema())
832            .await?;
833        writer.write_record_batch(batch).await?;
834        writer.finish().await?;
835        Ok(())
836    }
837
838    #[instrument(level = "debug", skip_all)]
839    async fn write_docs(&mut self, store: &dyn IndexStore, docs: Arc<DocSet>) -> Result<()> {
840        log::info!("writing docs of partition {}", self.id);
841        let batch = docs.to_batch()?;
842        let mut writer = store
843            .new_index_file(&doc_file_path(self.id), batch.schema())
844            .await?;
845        writer.write_record_batch(batch).await?;
846        writer.finish().await?;
847        Ok(())
848    }
849}
850
851struct IndexWorker {
852    tokenizer: Box<dyn LanceTokenizer>,
853    dest_store: Arc<dyn IndexStore>,
854    id_alloc: Arc<AtomicU64>,
855    builder: InnerBuilder,
856    partitions: Vec<u64>,
857    schema: SchemaRef,
858    memory_size: u64,
859    worker_memory_limit_bytes: u64,
860    total_doc_length: usize,
861    fragment_mask: Option<u64>,
862    token_set_format: TokenSetFormat,
863    token_ids: Vec<u32>,
864    last_token_count: usize,
865}
866
867struct TailPartition {
868    builder: InnerBuilder,
869}
870
871struct WorkerOutput {
872    partitions: Vec<u64>,
873    tail_partition: Option<TailPartition>,
874}
875
876#[derive(Debug, Clone, Copy)]
877struct IndexWorkerConfig {
878    with_position: bool,
879    format_version: InvertedListFormatVersion,
880    fragment_mask: Option<u64>,
881    token_set_format: TokenSetFormat,
882    worker_memory_limit_bytes: u64,
883}
884
885impl IndexWorker {
886    fn posting_lists_overhead_size(&self) -> u64 {
887        (self.builder.posting_lists.capacity() * std::mem::size_of::<PostingListBuilder>()) as u64
888    }
889
890    fn adjust_tracked_value(tracked: &mut u64, old: u64, new: u64) {
891        if new >= old {
892            *tracked += new - old;
893        } else {
894            *tracked -= old - new;
895        }
896    }
897
898    fn adjust_tracked_memory_size(&mut self, old_memory_size: u64, new_memory_size: u64) {
899        Self::adjust_tracked_value(&mut self.memory_size, old_memory_size, new_memory_size);
900    }
901
902    fn apply_delta(total: &mut u64, delta: i64) {
903        if delta >= 0 {
904            *total += delta as u64;
905        } else {
906            *total -= (-delta) as u64;
907        }
908    }
909
910    fn temporary_memory_size(&self) -> u64 {
911        (self.token_ids.capacity() * std::mem::size_of::<u32>()) as u64
912    }
913
914    fn trim_temporary_buffers(&mut self) {
915        if self.token_ids.capacity() > MAX_RETAINED_TOKEN_IDS {
916            self.token_ids = Vec::with_capacity(self.last_token_count.min(MAX_RETAINED_TOKEN_IDS));
917        }
918    }
919
920    async fn new(
921        tokenizer: Box<dyn LanceTokenizer>,
922        dest_store: Arc<dyn IndexStore>,
923        id_alloc: Arc<AtomicU64>,
924        config: IndexWorkerConfig,
925    ) -> Result<Self> {
926        let schema = inverted_list_schema_for_version(config.with_position, config.format_version);
927
928        Ok(Self {
929            tokenizer,
930            dest_store,
931            builder: InnerBuilder::new_with_format_version(
932                id_alloc.fetch_add(1, std::sync::atomic::Ordering::Relaxed)
933                    | config.fragment_mask.unwrap_or(0),
934                config.with_position,
935                config.token_set_format,
936                config.format_version,
937            ),
938            partitions: Vec::new(),
939            id_alloc,
940            schema,
941            memory_size: 0,
942            worker_memory_limit_bytes: config.worker_memory_limit_bytes,
943            total_doc_length: 0,
944            fragment_mask: config.fragment_mask,
945            token_set_format: config.token_set_format,
946            token_ids: Vec::new(),
947            last_token_count: 0,
948        })
949    }
950
951    fn has_position(&self) -> bool {
952        self.schema
953            .column_with_name(COMPRESSED_POSITION_COL)
954            .is_some()
955            || self.schema.column_with_name(POSITION_COL).is_some()
956    }
957
958    async fn process_batch(&mut self, batch: RecordBatch) -> Result<()> {
959        let doc_col = batch.column(0);
960        let doc_iter = iter_str_array(doc_col);
961        let row_id_col = batch[ROW_ID].as_primitive::<datatypes::UInt64Type>();
962        let docs = doc_iter
963            .zip(row_id_col.values().iter())
964            .filter_map(|(doc, row_id)| doc.map(|doc| (doc, *row_id)));
965
966        let with_position = self.has_position();
967        for (doc, row_id) in docs {
968            let builder_was_empty = self.builder.docs.is_empty();
969            let old_temporary_memory_size = self.temporary_memory_size();
970            let old_token_memory_size = self.builder.tokens.memory_size() as u64;
971            let doc_id = self.builder.docs.len() as u32;
972            let mut token_num: u32 = 0;
973            let mut posting_memory_delta = 0i64;
974            if with_position {
975                if self.token_ids.capacity() < self.last_token_count {
976                    self.token_ids
977                        .reserve(self.last_token_count - self.token_ids.capacity());
978                }
979                self.token_ids.clear();
980                let builder = &mut self.builder;
981                let token_ids = &mut self.token_ids;
982                let memory_size = &mut self.memory_size;
983                let posting_tail_codec = builder.posting_tail_codec;
984
985                let mut token_stream = self.tokenizer.token_stream_for_doc(doc);
986                while token_stream.advance() {
987                    let token = token_stream.token_mut();
988                    let token_text = std::mem::take(&mut token.text);
989                    let token_id = builder.tokens.add(token_text);
990                    if token_id as usize == builder.posting_lists.len() {
991                        let old_posting_lists_overhead_size = (builder.posting_lists.capacity()
992                            * std::mem::size_of::<PostingListBuilder>())
993                            as u64;
994                        builder.posting_lists.push(
995                            PostingListBuilder::new_with_posting_tail_codec(
996                                true,
997                                posting_tail_codec,
998                            ),
999                        );
1000                        let new_posting_lists_overhead_size = (builder.posting_lists.capacity()
1001                            * std::mem::size_of::<PostingListBuilder>())
1002                            as u64;
1003                        Self::adjust_tracked_value(
1004                            memory_size,
1005                            old_posting_lists_overhead_size,
1006                            new_posting_lists_overhead_size,
1007                        );
1008                    }
1009                    let posting_list = &mut builder.posting_lists[token_id as usize];
1010                    let old_posting_memory_size = posting_list.size();
1011                    if posting_list.add_occurrence(doc_id, token.position as u32)? {
1012                        token_ids.push(token_id);
1013                    }
1014                    let new_posting_memory_size = posting_list.size();
1015                    posting_memory_delta +=
1016                        new_posting_memory_size as i64 - old_posting_memory_size as i64;
1017                    token_num += 1;
1018                }
1019            } else {
1020                if self.token_ids.capacity() < self.last_token_count {
1021                    self.token_ids
1022                        .reserve(self.last_token_count - self.token_ids.capacity());
1023                }
1024                self.token_ids.clear();
1025
1026                let mut token_stream = self.tokenizer.token_stream_for_doc(doc);
1027                while token_stream.advance() {
1028                    let token = token_stream.token_mut();
1029                    let token_text = std::mem::take(&mut token.text);
1030                    let token_id = self.builder.tokens.add(token_text);
1031                    self.token_ids.push(token_id);
1032                    token_num += 1;
1033                }
1034            }
1035            self.adjust_tracked_memory_size(
1036                old_token_memory_size,
1037                self.builder.tokens.memory_size() as u64,
1038            );
1039
1040            if !with_position {
1041                let old_posting_lists_overhead_size = self.posting_lists_overhead_size();
1042                self.builder
1043                    .posting_lists
1044                    .resize_with(self.builder.tokens.len(), || {
1045                        PostingListBuilder::new_with_posting_tail_codec(
1046                            false,
1047                            self.builder.posting_tail_codec,
1048                        )
1049                    });
1050                let new_posting_lists_overhead_size = self.posting_lists_overhead_size();
1051                Self::adjust_tracked_value(
1052                    &mut self.memory_size,
1053                    old_posting_lists_overhead_size,
1054                    new_posting_lists_overhead_size,
1055                );
1056            }
1057
1058            let old_doc_memory_size = self.builder.docs.memory_size() as u64;
1059            let appended_doc_id = self.builder.docs.append(row_id, token_num);
1060            debug_assert_eq!(appended_doc_id, doc_id);
1061            self.adjust_tracked_memory_size(
1062                old_doc_memory_size,
1063                self.builder.docs.memory_size() as u64,
1064            );
1065            self.total_doc_length += doc.len();
1066
1067            if with_position {
1068                for &token_id in &self.token_ids {
1069                    let (old_posting_memory_size, new_posting_memory_size) = {
1070                        let posting_list = &mut self.builder.posting_lists[token_id as usize];
1071                        let old_posting_memory_size = posting_list.size();
1072                        posting_list.finish_open_doc(doc_id)?;
1073                        let new_posting_memory_size = posting_list.size();
1074                        (old_posting_memory_size, new_posting_memory_size)
1075                    };
1076                    posting_memory_delta +=
1077                        new_posting_memory_size as i64 - old_posting_memory_size as i64;
1078                }
1079                Self::apply_delta(&mut self.memory_size, posting_memory_delta);
1080            } else if token_num > 0 {
1081                self.token_ids.sort_unstable();
1082                let mut iter = self.token_ids.iter();
1083                let mut current = *iter.next().unwrap();
1084                let mut count = 1u32;
1085                for &token_id in iter {
1086                    if token_id == current {
1087                        count += 1;
1088                        continue;
1089                    }
1090
1091                    let (old_posting_memory_size, new_posting_memory_size) = {
1092                        let posting_list = &mut self.builder.posting_lists[current as usize];
1093                        let old_posting_memory_size = posting_list.size();
1094                        posting_list.add(doc_id, PositionRecorder::Count(count));
1095                        let new_posting_memory_size = posting_list.size();
1096                        (old_posting_memory_size, new_posting_memory_size)
1097                    };
1098                    posting_memory_delta +=
1099                        new_posting_memory_size as i64 - old_posting_memory_size as i64;
1100
1101                    current = token_id;
1102                    count = 1;
1103                }
1104                let (old_posting_memory_size, new_posting_memory_size) = {
1105                    let posting_list = &mut self.builder.posting_lists[current as usize];
1106                    let old_posting_memory_size = posting_list.size();
1107                    posting_list.add(doc_id, PositionRecorder::Count(count));
1108                    let new_posting_memory_size = posting_list.size();
1109                    (old_posting_memory_size, new_posting_memory_size)
1110                };
1111                posting_memory_delta +=
1112                    new_posting_memory_size as i64 - old_posting_memory_size as i64;
1113                Self::apply_delta(&mut self.memory_size, posting_memory_delta);
1114            }
1115            self.last_token_count = self.token_ids.len();
1116            self.trim_temporary_buffers();
1117            self.adjust_tracked_memory_size(
1118                old_temporary_memory_size,
1119                self.temporary_memory_size(),
1120            );
1121
1122            if self.builder.docs.len() == 1 && self.memory_size > self.worker_memory_limit_bytes {
1123                return Err(Error::invalid_input(format!(
1124                    "single document row_id={} exceeds worker memory limit: {} > {} bytes",
1125                    row_id, self.memory_size, self.worker_memory_limit_bytes
1126                )));
1127            }
1128
1129            if self.builder.docs.len() as u32 == u32::MAX
1130                || (!builder_was_empty && self.memory_size >= self.worker_memory_limit_bytes)
1131            {
1132                self.flush().await?;
1133            }
1134        }
1135
1136        Ok(())
1137    }
1138
1139    #[instrument(level = "debug", skip_all)]
1140    async fn flush(&mut self) -> Result<()> {
1141        if self.builder.tokens.is_empty() {
1142            return Ok(());
1143        }
1144
1145        log::info!(
1146            "flushing posting lists, memory size: {} MiB",
1147            self.memory_size / (1024 * 1024)
1148        );
1149        self.memory_size = self.temporary_memory_size();
1150        let with_position = self.has_position();
1151        let format_version = self.builder.format_version;
1152        let builder = std::mem::replace(
1153            &mut self.builder,
1154            InnerBuilder::new_with_format_version(
1155                self.id_alloc
1156                    .fetch_add(1, std::sync::atomic::Ordering::Relaxed)
1157                    | self.fragment_mask.unwrap_or(0),
1158                with_position,
1159                self.token_set_format,
1160                format_version,
1161            ),
1162        );
1163        let written_partition_id = builder.id();
1164        let mut builder = builder;
1165        builder
1166            .write(self.dest_store.as_ref())
1167            .await
1168            .map_err(|err| {
1169                Error::execution(format!(
1170                    "failed to write finalized partition {}: {err}",
1171                    written_partition_id
1172                ))
1173            })?;
1174        self.partitions.push(written_partition_id);
1175        Ok(())
1176    }
1177
1178    async fn finish(self) -> Result<WorkerOutput> {
1179        let tail_partition = if self.builder.tokens.is_empty() {
1180            None
1181        } else {
1182            Some(TailPartition {
1183                builder: self.builder,
1184            })
1185        };
1186        Ok(WorkerOutput {
1187            partitions: self.partitions,
1188            tail_partition,
1189        })
1190    }
1191}
1192
1193#[derive(Debug, Clone)]
1194pub enum PositionRecorder {
1195    Position(SmallVec<[u32; 2]>),
1196    Count(u32),
1197}
1198
1199impl PositionRecorder {
1200    pub fn len(&self) -> u32 {
1201        match self {
1202            Self::Position(positions) => positions.len() as u32,
1203            Self::Count(count) => *count,
1204        }
1205    }
1206
1207    pub fn is_empty(&self) -> bool {
1208        self.len() == 0
1209    }
1210
1211    pub fn into_vec(self) -> Vec<u32> {
1212        match self {
1213            Self::Position(positions) => positions.into_vec(),
1214            Self::Count(_) => vec![0],
1215        }
1216    }
1217}
1218
1219#[derive(Debug, Eq, PartialEq, Clone, DeepSizeOf)]
1220pub struct ScoredDoc {
1221    pub row_id: u64,
1222    pub score: OrderedFloat,
1223}
1224
1225impl ScoredDoc {
1226    pub fn new(row_id: u64, score: f32) -> Self {
1227        Self {
1228            row_id,
1229            score: OrderedFloat(score),
1230        }
1231    }
1232}
1233
1234impl PartialOrd for ScoredDoc {
1235    fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
1236        Some(self.cmp(other))
1237    }
1238}
1239
1240impl Ord for ScoredDoc {
1241    fn cmp(&self, other: &Self) -> std::cmp::Ordering {
1242        self.score.cmp(&other.score)
1243    }
1244}
1245
1246pub fn legacy_inverted_list_schema(with_position: bool) -> SchemaRef {
1247    let mut fields = vec![
1248        arrow_schema::Field::new(ROW_ID, arrow_schema::DataType::UInt64, false),
1249        arrow_schema::Field::new(FREQUENCY_COL, arrow_schema::DataType::Float32, false),
1250    ];
1251    if with_position {
1252        fields.push(arrow_schema::Field::new(
1253            POSITION_COL,
1254            arrow_schema::DataType::List(Arc::new(arrow_schema::Field::new(
1255                "item",
1256                arrow_schema::DataType::Int32,
1257                true,
1258            ))),
1259            false,
1260        ));
1261    }
1262    Arc::new(arrow_schema::Schema::new(fields))
1263}
1264
1265pub fn inverted_list_schema(with_position: bool) -> SchemaRef {
1266    inverted_list_schema_for_version(with_position, current_fts_format_version())
1267}
1268
1269pub fn inverted_list_schema_for_version(
1270    with_position: bool,
1271    format_version: InvertedListFormatVersion,
1272) -> SchemaRef {
1273    match format_version {
1274        InvertedListFormatVersion::V1 => inverted_list_schema_v1(with_position),
1275        InvertedListFormatVersion::V2 => inverted_list_schema_with_tail_codec_and_position_codec(
1276            with_position,
1277            PostingTailCodec::VarintDelta,
1278            Some(PositionStreamCodec::PackedDelta),
1279        ),
1280    }
1281}
1282
1283fn inverted_list_schema_v1(with_position: bool) -> SchemaRef {
1284    let mut fields = vec![
1285        arrow_schema::Field::new(
1286            POSTING_COL,
1287            datatypes::DataType::List(Arc::new(Field::new(
1288                "item",
1289                datatypes::DataType::LargeBinary,
1290                true,
1291            ))),
1292            false,
1293        ),
1294        arrow_schema::Field::new(MAX_SCORE_COL, datatypes::DataType::Float32, false),
1295        arrow_schema::Field::new(LENGTH_COL, datatypes::DataType::UInt32, false),
1296    ];
1297    if with_position {
1298        fields.push(arrow_schema::Field::new(
1299            POSITION_COL,
1300            arrow_schema::DataType::List(Arc::new(arrow_schema::Field::new(
1301                "item",
1302                arrow_schema::DataType::List(Arc::new(arrow_schema::Field::new(
1303                    "item",
1304                    arrow_schema::DataType::LargeBinary,
1305                    true,
1306                ))),
1307                true,
1308            ))),
1309            false,
1310        ));
1311    }
1312    Arc::new(arrow_schema::Schema::new(fields))
1313}
1314
1315pub fn inverted_list_schema_with_tail_codec(
1316    with_position: bool,
1317    posting_tail_codec: PostingTailCodec,
1318) -> SchemaRef {
1319    inverted_list_schema_with_tail_codec_and_position_codec(
1320        with_position,
1321        posting_tail_codec,
1322        Some(PositionStreamCodec::PackedDelta),
1323    )
1324}
1325
1326fn inverted_list_schema_with_tail_codec_and_position_codec(
1327    with_position: bool,
1328    posting_tail_codec: PostingTailCodec,
1329    position_codec: Option<PositionStreamCodec>,
1330) -> SchemaRef {
1331    let mut fields = vec![
1332        // we compress the posting lists (including row ids and frequencies),
1333        // and store the compressed posting lists, so it's a large binary array
1334        arrow_schema::Field::new(
1335            POSTING_COL,
1336            datatypes::DataType::List(Arc::new(Field::new(
1337                "item",
1338                datatypes::DataType::LargeBinary,
1339                true,
1340            ))),
1341            false,
1342        ),
1343        arrow_schema::Field::new(MAX_SCORE_COL, datatypes::DataType::Float32, false),
1344        arrow_schema::Field::new(LENGTH_COL, datatypes::DataType::UInt32, false),
1345    ];
1346    if with_position {
1347        fields.push(arrow_schema::Field::new(
1348            COMPRESSED_POSITION_COL,
1349            arrow_schema::DataType::LargeBinary,
1350            false,
1351        ));
1352        fields.push(arrow_schema::Field::new(
1353            POSITION_BLOCK_OFFSET_COL,
1354            arrow_schema::DataType::List(Arc::new(arrow_schema::Field::new(
1355                "item",
1356                arrow_schema::DataType::UInt32,
1357                true,
1358            ))),
1359            false,
1360        ));
1361    }
1362    let mut metadata = HashMap::from([(
1363        POSTING_TAIL_CODEC_KEY.to_owned(),
1364        posting_tail_codec.as_str().to_owned(),
1365    )]);
1366    if let Some(position_codec) = position_codec.filter(|_| with_position) {
1367        metadata.insert(
1368            POSITIONS_LAYOUT_KEY.to_owned(),
1369            POSITIONS_LAYOUT_SHARED_STREAM_V2.to_owned(),
1370        );
1371        metadata.insert(
1372            POSITIONS_CODEC_KEY.to_owned(),
1373            position_codec.as_str().to_owned(),
1374        );
1375    }
1376    Arc::new(arrow_schema::Schema::new_with_metadata(fields, metadata))
1377}
1378
1379/// Flatten the string list stream into a string stream
1380pub struct FlattenStream {
1381    /// Inner record batch stream with 2 columns:
1382    /// 1. doc_col: List(Utf8) or List(LargeUtf8)
1383    /// 2. row_id_col: UInt64
1384    inner: SendableRecordBatchStream,
1385    field_type: DataType,
1386    data_type: DataType,
1387}
1388
1389impl FlattenStream {
1390    pub fn new(input: SendableRecordBatchStream) -> Self {
1391        let schema = input.schema();
1392        let field = schema.field(0);
1393        let data_type = match field.data_type() {
1394            DataType::List(f) if matches!(f.data_type(), DataType::Utf8) => DataType::Utf8,
1395            DataType::List(f) if matches!(f.data_type(), DataType::LargeUtf8) => {
1396                DataType::LargeUtf8
1397            }
1398            DataType::LargeList(f) if matches!(f.data_type(), DataType::Utf8) => DataType::Utf8,
1399            DataType::LargeList(f) if matches!(f.data_type(), DataType::LargeUtf8) => {
1400                DataType::LargeUtf8
1401            }
1402            _ => panic!(
1403                "expect data type List(Utf8) or List(LargeUtf8) but got {:?}",
1404                field.data_type()
1405            ),
1406        };
1407        Self {
1408            inner: input,
1409            field_type: field.data_type().clone(),
1410            data_type,
1411        }
1412    }
1413}
1414
1415impl Stream for FlattenStream {
1416    type Item = datafusion_common::Result<RecordBatch>;
1417
1418    fn poll_next(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Option<Self::Item>> {
1419        match Pin::new(&mut self.inner).poll_next(cx) {
1420            Poll::Ready(Some(Ok(batch))) => {
1421                let doc_col = batch.column(0);
1422                let batch = match self.field_type {
1423                    DataType::List(_) => flatten_string_list::<i32>(&batch, doc_col).map_err(|e| {
1424                        datafusion_common::error::DataFusionError::Execution(format!(
1425                            "flatten string list error: {}",
1426                            e
1427                        ))
1428                    }),
1429                    DataType::LargeList(_) => {
1430                        flatten_string_list::<i64>(&batch, doc_col).map_err(|e| {
1431                            datafusion_common::error::DataFusionError::Execution(format!(
1432                                "flatten string list error: {}",
1433                                e
1434                            ))
1435                        })
1436                    }
1437                    _ => unreachable!(
1438                        "expect data type List or LargeList but got {:?}",
1439                        self.field_type
1440                    ),
1441                };
1442                Poll::Ready(Some(batch))
1443            }
1444            Poll::Ready(Some(Err(e))) => Poll::Ready(Some(Err(e))),
1445            Poll::Ready(None) => Poll::Ready(None),
1446            Poll::Pending => Poll::Pending,
1447        }
1448    }
1449}
1450
1451impl RecordBatchStream for FlattenStream {
1452    fn schema(&self) -> SchemaRef {
1453        let schema = Schema::new(vec![
1454            Field::new(
1455                self.inner.schema().field(0).name(),
1456                self.data_type.clone(),
1457                true,
1458            ),
1459            ROW_ID_FIELD.clone(),
1460        ]);
1461
1462        Arc::new(schema)
1463    }
1464}
1465
1466fn flatten_string_list<Offset: arrow::array::OffsetSizeTrait>(
1467    batch: &RecordBatch,
1468    doc_col: &Arc<dyn Array>,
1469) -> Result<RecordBatch> {
1470    let docs = doc_col.as_list::<Offset>();
1471    let row_ids = batch[ROW_ID].as_primitive::<datatypes::UInt64Type>();
1472
1473    let row_ids = row_ids
1474        .values()
1475        .iter()
1476        .zip(docs.iter())
1477        .flat_map(|(row_id, doc)| std::iter::repeat_n(*row_id, doc.map(|d| d.len()).unwrap_or(0)));
1478
1479    let row_ids = Arc::new(UInt64Array::from_iter_values(row_ids));
1480    let docs = match docs.value_type() {
1481        datatypes::DataType::Utf8 | datatypes::DataType::LargeUtf8 => docs.values().clone(),
1482        _ => {
1483            return Err(Error::index(format!(
1484                "expect data type String or LargeString but got {}",
1485                docs.value_type()
1486            )));
1487        }
1488    };
1489
1490    let schema = Schema::new(vec![
1491        Field::new(
1492            batch.schema().field(0).name(),
1493            docs.data_type().clone(),
1494            true,
1495        ),
1496        ROW_ID_FIELD.clone(),
1497    ]);
1498    let batch = RecordBatch::try_new(Arc::new(schema), vec![docs, row_ids])?;
1499    Ok(batch)
1500}
1501
1502pub(crate) fn token_file_path(partition_id: u64) -> String {
1503    format!("part_{}_{}", partition_id, TOKENS_FILE)
1504}
1505
1506pub(crate) fn posting_file_path(partition_id: u64) -> String {
1507    format!("part_{}_{}", partition_id, INVERT_LIST_FILE)
1508}
1509
1510pub(crate) fn doc_file_path(partition_id: u64) -> String {
1511    format!("part_{}_{}", partition_id, DOCS_FILE)
1512}
1513
1514pub(crate) fn part_metadata_file_path(partition_id: u64) -> String {
1515    format!("part_{}_{}", partition_id, METADATA_FILE)
1516}
1517
1518pub async fn merge_index_files(
1519    object_store: &ObjectStore,
1520    index_dir: &Path,
1521    store: Arc<dyn IndexStore>,
1522) -> Result<()> {
1523    // List all partition metadata files in the index directory
1524    let part_metadata_files = list_metadata_files(object_store, index_dir).await?;
1525
1526    // Call merge_metadata_files function for inverted index
1527    merge_metadata_files(store, &part_metadata_files).await
1528}
1529
1530/// List and filter metadata files from the index directory
1531/// Returns partition metadata files
1532async fn list_metadata_files(object_store: &ObjectStore, index_dir: &Path) -> Result<Vec<String>> {
1533    // List all partition metadata files in the index directory
1534    let mut part_metadata_files = Vec::new();
1535    let mut list_stream = object_store.list(Some(index_dir.clone()));
1536
1537    while let Some(item) = list_stream.next().await {
1538        match item {
1539            Ok(meta) => {
1540                let file_name = meta.location.filename().unwrap_or_default();
1541                // Filter files matching the pattern part_*_metadata.lance
1542                if file_name.starts_with("part_") && file_name.ends_with("_metadata.lance") {
1543                    part_metadata_files.push(file_name.to_string());
1544                }
1545            }
1546            Err(_) => continue,
1547        }
1548    }
1549
1550    if part_metadata_files.is_empty() {
1551        return Err(Error::invalid_input_source(
1552            format!(
1553                "No partition metadata files found in index directory: {}",
1554                index_dir
1555            )
1556            .into(),
1557        ));
1558    }
1559
1560    Ok(part_metadata_files)
1561}
1562
1563/// Merge partition metadata files with partition ID remapping to sequential IDs starting from 0
1564async fn merge_metadata_files(
1565    store: Arc<dyn IndexStore>,
1566    part_metadata_files: &[String],
1567) -> Result<()> {
1568    // Collect all partition IDs and params
1569    let mut all_partitions = Vec::new();
1570    let mut params = None;
1571    let mut token_set_format = None;
1572    let mut format_version = None;
1573    let mut posting_tail_codec = None;
1574
1575    let mut deleted_fragments = RoaringBitmap::new();
1576
1577    for file_name in part_metadata_files {
1578        let reader = store.open_index_file(file_name).await?;
1579        let metadata = &reader.schema().metadata;
1580
1581        let partitions_str = metadata.get("partitions").ok_or(Error::index(format!(
1582            "partitions not found in {}",
1583            file_name
1584        )))?;
1585
1586        let partition_ids: Vec<u64> = serde_json::from_str(partitions_str)
1587            .map_err(|e| Error::index(format!("Failed to parse partitions: {}", e)))?;
1588
1589        all_partitions.extend(partition_ids);
1590
1591        if params.is_none() {
1592            let params_str = metadata
1593                .get("params")
1594                .ok_or(Error::index(format!("params not found in {}", file_name)))?;
1595            params = Some(
1596                serde_json::from_str::<InvertedIndexParams>(params_str)
1597                    .map_err(|e| Error::index(format!("Failed to parse params: {}", e)))?,
1598            );
1599        }
1600
1601        if token_set_format.is_none()
1602            && let Some(name) = metadata.get(TOKEN_SET_FORMAT_KEY)
1603        {
1604            token_set_format = Some(TokenSetFormat::from_str(name)?);
1605        }
1606        if format_version.is_none() {
1607            format_version = Some(parse_format_version_from_metadata(metadata)?);
1608        }
1609        if posting_tail_codec.is_none() {
1610            posting_tail_codec = Some(parse_posting_tail_codec(metadata)?);
1611        }
1612
1613        if reader.num_rows() > 0 {
1614            let metadata_batch = reader.read_range(0..1, None).await?;
1615            let deleted_fragments_col = metadata_batch
1616                .column_by_name(DELETED_FRAGMENTS_COL)
1617                .expect_ok()?;
1618            let deleted_fragments_arr = deleted_fragments_col
1619                .as_any()
1620                .downcast_ref::<BinaryArray>()
1621                .expect_ok()?;
1622            let part_deleted_fragments =
1623                RoaringBitmap::deserialize_from(deleted_fragments_arr.value(0))?;
1624            deleted_fragments.extend(part_deleted_fragments);
1625        }
1626    }
1627
1628    // Create ID mapping: sorted original IDs -> 0,1,2...
1629    let mut sorted_ids = all_partitions.clone();
1630    sorted_ids.sort();
1631    sorted_ids.dedup();
1632
1633    let id_mapping: HashMap<u64, u64> = sorted_ids
1634        .iter()
1635        .enumerate()
1636        .map(|(new_id, &old_id)| (old_id, new_id as u64))
1637        .collect();
1638
1639    // Safe rename partition files using temporary files to avoid overwrite
1640    let timestamp = std::time::SystemTime::now()
1641        .duration_since(std::time::UNIX_EPOCH)
1642        .unwrap()
1643        .as_secs();
1644
1645    // Phase 1: Move files to temporary locations
1646    let mut temp_files: Vec<(String, String, String)> = Vec::new(); // (temp_path, old_path, final_path)
1647
1648    for (&old_id, &new_id) in &id_mapping {
1649        if old_id != new_id {
1650            for suffix in [TOKENS_FILE, INVERT_LIST_FILE, DOCS_FILE] {
1651                let old_path = format!("part_{}_{}", old_id, suffix);
1652                let new_path = format!("part_{}_{}", new_id, suffix);
1653                let temp_path = format!("temp_{}_{}", timestamp, old_path);
1654
1655                // Move to temporary location first to avoid overwrite
1656                if let Err(e) = store.rename_index_file(&old_path, &temp_path).await {
1657                    // Rollback phase 1: restore files from temp locations
1658                    for (temp_name, old_name, _) in temp_files.iter().rev() {
1659                        let _ = store.rename_index_file(temp_name, old_name).await;
1660                    }
1661                    return Err(Error::index(format!(
1662                        "Failed to move {} to temp {}: {}",
1663                        old_path, temp_path, e
1664                    )));
1665                }
1666                temp_files.push((temp_path, old_path, new_path));
1667            }
1668        }
1669    }
1670
1671    // Phase 2: Move from temporary to final locations
1672    let mut completed_renames: Vec<(String, String)> = Vec::new(); // (final_path, temp_path)
1673
1674    for (temp_path, _old_path, final_path) in &temp_files {
1675        if let Err(e) = store.rename_index_file(temp_path, final_path).await {
1676            // Rollback phase 2: restore completed renames and remaining temps
1677            for (final_name, temp_name) in completed_renames.iter().rev() {
1678                let _ = store.rename_index_file(final_name, temp_name).await;
1679            }
1680            // Restore remaining temp files to original locations
1681            for (temp_name, orig_name, _) in temp_files.iter() {
1682                if !completed_renames.iter().any(|(_, t)| t == temp_name) {
1683                    let _ = store.rename_index_file(temp_name, orig_name).await;
1684                }
1685            }
1686            return Err(Error::index(format!(
1687                "Failed to rename {} to {}: {}",
1688                temp_path, final_path, e
1689            )));
1690        }
1691        completed_renames.push((final_path.clone(), temp_path.clone()));
1692    }
1693
1694    // Write merged metadata with remapped IDs
1695    let remapped_partitions: Vec<u64> = (0..id_mapping.len() as u64).collect();
1696    let params = params.unwrap_or_default();
1697    let token_set_format = token_set_format.unwrap_or(TokenSetFormat::Arrow);
1698    let builder = InvertedIndexBuilder::from_existing_index(
1699        params,
1700        None,
1701        remapped_partitions.clone(),
1702        token_set_format,
1703        None,
1704        deleted_fragments,
1705    )
1706    .with_format_version(format_version.unwrap_or(InvertedListFormatVersion::V1))
1707    .with_posting_tail_codec(posting_tail_codec.unwrap_or(PostingTailCodec::Fixed32));
1708    builder
1709        .write_metadata(&*store, &remapped_partitions)
1710        .await?;
1711
1712    // Cleanup partition metadata files
1713    for file_name in part_metadata_files {
1714        if file_name.starts_with("part_") && file_name.ends_with("_metadata.lance") {
1715            let _ = store.delete_index_file(file_name).await;
1716        }
1717    }
1718
1719    Ok(())
1720}
1721
1722/// Convert input stream into a stream of documents.
1723///
1724/// The input stream must be one of:
1725/// 1. Document in Utf8 or LargeUtf8 format.
1726/// 2. Document in List(Utf8) or List(LargeUtf8) format.
1727/// 3. Json document in LargeBinary format.
1728pub fn document_input(
1729    input: SendableRecordBatchStream,
1730    column: &str,
1731) -> Result<SendableRecordBatchStream> {
1732    let schema = input.schema();
1733    let field = schema.column_with_name(column).expect_ok()?.1;
1734    match field.data_type() {
1735        DataType::Utf8 | DataType::LargeUtf8 => Ok(input),
1736        DataType::List(field) | DataType::LargeList(field)
1737            if matches!(field.data_type(), DataType::Utf8 | DataType::LargeUtf8) =>
1738        {
1739            Ok(Box::pin(FlattenStream::new(input)))
1740        }
1741        DataType::LargeBinary => match field.metadata().get(ARROW_EXT_NAME_KEY) {
1742            Some(name) if name.as_str() == JSON_EXT_NAME => {
1743                Ok(Box::pin(JsonTextStream::new(input, column.to_string())))
1744            }
1745            _ => Err(Error::invalid_input_source(
1746                format!("column {} is not json", column).into(),
1747            )),
1748        },
1749        _ => Err(Error::invalid_input_source(
1750            format!(
1751                "column {} has type {}, is not utf8, large utf8 type/list, or large binary",
1752                column,
1753                field.data_type()
1754            )
1755            .into(),
1756        )),
1757    }
1758}
1759
1760#[cfg(test)]
1761mod tests {
1762    use super::*;
1763    use crate::metrics::NoOpMetricsCollector;
1764    use crate::progress::IndexBuildProgress;
1765    use crate::scalar::{IndexFile, IndexReader, IndexWriter, ScalarIndex};
1766    use arrow_array::{RecordBatch, StringArray, UInt64Array};
1767    use arrow_schema::{DataType, Field, Schema};
1768    use async_trait::async_trait;
1769    use datafusion::physical_plan::stream::RecordBatchStreamAdapter;
1770    use futures::stream;
1771    use lance_core::ROW_ID;
1772    use lance_core::cache::LanceCache;
1773    use lance_core::utils::tempfile::TempDir;
1774    use std::any::Any;
1775    use std::sync::atomic::{AtomicU64, AtomicUsize, Ordering};
1776    use std::time::Duration;
1777    use tokio::sync::Mutex;
1778
1779    fn make_doc_batch(doc: &str, row_id: u64) -> RecordBatch {
1780        let schema = Arc::new(Schema::new(vec![
1781            Field::new("doc", DataType::Utf8, true),
1782            Field::new(ROW_ID, DataType::UInt64, false),
1783        ]));
1784        let docs = Arc::new(StringArray::from(vec![Some(doc)]));
1785        let row_ids = Arc::new(UInt64Array::from(vec![row_id]));
1786        RecordBatch::try_new(schema, vec![docs, row_ids]).unwrap()
1787    }
1788
1789    #[derive(Debug, Default, Clone)]
1790    struct CountingStore {
1791        write_count: Arc<AtomicUsize>,
1792    }
1793
1794    impl CountingStore {
1795        fn new() -> Self {
1796            Self {
1797                write_count: Arc::new(AtomicUsize::new(0)),
1798            }
1799        }
1800
1801        fn write_count(&self) -> usize {
1802            self.write_count.load(Ordering::SeqCst)
1803        }
1804    }
1805
1806    impl DeepSizeOf for CountingStore {
1807        fn deep_size_of_children(&self, _context: &mut deepsize::Context) -> usize {
1808            0
1809        }
1810    }
1811
1812    #[derive(Debug)]
1813    struct CountingWriter {
1814        write_count: Arc<AtomicUsize>,
1815    }
1816
1817    #[async_trait]
1818    impl IndexWriter for CountingWriter {
1819        async fn write_record_batch(&mut self, _batch: RecordBatch) -> Result<u64> {
1820            Ok(self.write_count.fetch_add(1, Ordering::SeqCst) as u64)
1821        }
1822
1823        async fn finish(&mut self) -> Result<()> {
1824            Ok(())
1825        }
1826
1827        async fn finish_with_metadata(&mut self, _metadata: HashMap<String, String>) -> Result<()> {
1828            Ok(())
1829        }
1830    }
1831
1832    #[async_trait]
1833    impl IndexStore for CountingStore {
1834        fn as_any(&self) -> &dyn Any {
1835            self
1836        }
1837
1838        fn clone_arc(&self) -> Arc<dyn IndexStore> {
1839            Arc::new(self.clone())
1840        }
1841
1842        fn io_parallelism(&self) -> usize {
1843            1
1844        }
1845
1846        async fn new_index_file(
1847            &self,
1848            _name: &str,
1849            _schema: Arc<Schema>,
1850        ) -> Result<Box<dyn IndexWriter>> {
1851            Ok(Box::new(CountingWriter {
1852                write_count: self.write_count.clone(),
1853            }))
1854        }
1855
1856        async fn open_index_file(&self, _name: &str) -> Result<Arc<dyn IndexReader>> {
1857            Err(Error::not_supported(
1858                "CountingStore does not support reading",
1859            ))
1860        }
1861
1862        async fn copy_index_file(&self, _name: &str, _dest_store: &dyn IndexStore) -> Result<()> {
1863            Err(Error::not_supported(
1864                "CountingStore does not support copying",
1865            ))
1866        }
1867
1868        async fn rename_index_file(&self, _name: &str, _new_name: &str) -> Result<()> {
1869            Err(Error::not_supported(
1870                "CountingStore does not support renaming",
1871            ))
1872        }
1873
1874        async fn delete_index_file(&self, _name: &str) -> Result<()> {
1875            Err(Error::not_supported(
1876                "CountingStore does not support deleting",
1877            ))
1878        }
1879
1880        async fn list_files_with_sizes(&self) -> Result<Vec<IndexFile>> {
1881            Ok(vec![])
1882        }
1883    }
1884
1885    #[tokio::test]
1886    async fn test_write_posting_lists_batches_multiple_rows() -> Result<()> {
1887        let mut builder = InnerBuilder::new(0, false, TokenSetFormat::default());
1888        for doc_id in 0..3u64 {
1889            builder.docs.append(doc_id, 1);
1890        }
1891
1892        for doc_id in 0..3u32 {
1893            let mut posting_list = PostingListBuilder::new(false);
1894            posting_list.add(doc_id, PositionRecorder::Count(1));
1895            builder.posting_lists.push(posting_list);
1896        }
1897
1898        let store = CountingStore::new();
1899        let docs = Arc::new(std::mem::take(&mut builder.docs));
1900        builder.write_posting_lists(&store, docs).await?;
1901
1902        assert_eq!(store.write_count(), 1);
1903        Ok(())
1904    }
1905
1906    #[tokio::test]
1907    async fn test_build_only_path_writes_partitions_as_is() -> Result<()> {
1908        let src_dir = TempDir::default();
1909        let dest_dir = TempDir::default();
1910        let src_store = Arc::new(LanceIndexStore::new(
1911            ObjectStore::local().into(),
1912            src_dir.obj_path(),
1913            Arc::new(LanceCache::no_cache()),
1914        ));
1915        let dest_store = Arc::new(LanceIndexStore::new(
1916            ObjectStore::local().into(),
1917            dest_dir.obj_path(),
1918            Arc::new(LanceCache::no_cache()),
1919        ));
1920
1921        let params = InvertedIndexParams::default();
1922        let tokenizer = params.build()?;
1923        let token_set_format = TokenSetFormat::default();
1924        let id_alloc = Arc::new(AtomicU64::new(0));
1925
1926        let mut worker1 = IndexWorker::new(
1927            tokenizer.clone(),
1928            src_store.clone(),
1929            id_alloc.clone(),
1930            IndexWorkerConfig {
1931                with_position: params.with_position,
1932                format_version: InvertedListFormatVersion::V1,
1933                fragment_mask: None,
1934                token_set_format,
1935                worker_memory_limit_bytes: u64::MAX,
1936            },
1937        )
1938        .await?;
1939        worker1
1940            .process_batch(make_doc_batch("hello world", 0))
1941            .await?;
1942        let output1 = worker1.finish().await?;
1943        let mut partitions = output1.partitions;
1944        if let Some(mut tail_partition) = output1.tail_partition {
1945            partitions.push(tail_partition.builder.id());
1946            tail_partition.builder.write(src_store.as_ref()).await?;
1947        }
1948
1949        let mut worker2 = IndexWorker::new(
1950            tokenizer.clone(),
1951            src_store.clone(),
1952            id_alloc.clone(),
1953            IndexWorkerConfig {
1954                with_position: params.with_position,
1955                format_version: InvertedListFormatVersion::V1,
1956                fragment_mask: None,
1957                token_set_format,
1958                worker_memory_limit_bytes: u64::MAX,
1959            },
1960        )
1961        .await?;
1962        worker2
1963            .process_batch(make_doc_batch("goodbye world", 1))
1964            .await?;
1965        let output2 = worker2.finish().await?;
1966        partitions.extend(output2.partitions);
1967        if let Some(mut tail_partition) = output2.tail_partition {
1968            partitions.push(tail_partition.builder.id());
1969            tail_partition.builder.write(src_store.as_ref()).await?;
1970        }
1971        partitions.sort_unstable();
1972        assert_eq!(partitions.len(), 2);
1973        assert_ne!(partitions[0], partitions[1]);
1974
1975        let builder = InvertedIndexBuilder::from_existing_index(
1976            InvertedIndexParams::default(),
1977            Some(src_store.clone()),
1978            partitions.clone(),
1979            token_set_format,
1980            None,
1981            RoaringBitmap::new(),
1982        );
1983        builder.write(dest_store.as_ref()).await?;
1984
1985        let metadata_reader = dest_store.open_index_file(METADATA_FILE).await?;
1986        let metadata = &metadata_reader.schema().metadata;
1987        let partitions_str = metadata
1988            .get("partitions")
1989            .expect("partitions missing from metadata");
1990        let written_partitions: Vec<u64> = serde_json::from_str(partitions_str).unwrap();
1991        assert_eq!(written_partitions, partitions);
1992
1993        for id in &partitions {
1994            dest_store.open_index_file(&token_file_path(*id)).await?;
1995            dest_store.open_index_file(&posting_file_path(*id)).await?;
1996            dest_store.open_index_file(&doc_file_path(*id)).await?;
1997        }
1998
1999        Ok(())
2000    }
2001
2002    #[tokio::test]
2003    async fn test_update_preserves_existing_posting_tail_codec() -> Result<()> {
2004        let src_dir = TempDir::default();
2005        let dest_dir = TempDir::default();
2006        let src_store = Arc::new(LanceIndexStore::new(
2007            ObjectStore::local().into(),
2008            src_dir.obj_path(),
2009            Arc::new(LanceCache::no_cache()),
2010        ));
2011        let dest_store = Arc::new(LanceIndexStore::new(
2012            ObjectStore::local().into(),
2013            dest_dir.obj_path(),
2014            Arc::new(LanceCache::no_cache()),
2015        ));
2016
2017        let posting_tail_codec = PostingTailCodec::Fixed32;
2018        let mut partition = InnerBuilder::new_with_posting_tail_codec(
2019            0,
2020            false,
2021            TokenSetFormat::default(),
2022            posting_tail_codec,
2023        );
2024        partition.tokens.add("hello".to_owned());
2025        let mut posting_list =
2026            PostingListBuilder::new_with_posting_tail_codec(false, posting_tail_codec);
2027        posting_list.add(0, PositionRecorder::Count(1));
2028        partition.posting_lists.push(posting_list);
2029        partition.docs.append(100, 1);
2030        partition.write(src_store.as_ref()).await?;
2031
2032        let metadata_writer = InvertedIndexBuilder::from_existing_index(
2033            InvertedIndexParams::default(),
2034            Some(src_store.clone()),
2035            vec![0],
2036            TokenSetFormat::default(),
2037            None,
2038            RoaringBitmap::new(),
2039        )
2040        .with_posting_tail_codec(posting_tail_codec);
2041        metadata_writer
2042            .write_metadata(src_store.as_ref(), &[0])
2043            .await?;
2044
2045        let index = InvertedIndex::load(src_store, None, &LanceCache::no_cache()).await?;
2046        let schema = Arc::new(Schema::new(vec![
2047            Field::new("doc", DataType::Utf8, true),
2048            Field::new(ROW_ID, DataType::UInt64, false),
2049        ]));
2050        let docs = Arc::new(StringArray::from(vec![Some("hello again")]));
2051        let row_ids = Arc::new(UInt64Array::from(vec![101u64]));
2052        let batch = RecordBatch::try_new(schema.clone(), vec![docs, row_ids])?;
2053        let stream = RecordBatchStreamAdapter::new(schema, stream::iter(vec![Ok(batch)]));
2054        index
2055            .update(Box::pin(stream), dest_store.as_ref(), None)
2056            .await?;
2057
2058        let updated =
2059            InvertedIndex::load(dest_store.clone(), None, &LanceCache::no_cache()).await?;
2060        assert_eq!(updated.partitions.len(), 2);
2061        for partition in &updated.partitions {
2062            assert_eq!(
2063                partition.inverted_list.posting_tail_codec(),
2064                posting_tail_codec
2065            );
2066        }
2067
2068        let metadata = dest_store.open_index_file(METADATA_FILE).await?;
2069        assert_eq!(
2070            metadata.schema().metadata.get(POSTING_TAIL_CODEC_KEY),
2071            Some(&posting_tail_codec.as_str().to_owned())
2072        );
2073
2074        Ok(())
2075    }
2076
2077    #[test]
2078    fn test_with_posting_tail_codec_syncs_format_version() {
2079        let builder = InvertedIndexBuilder::from_existing_index(
2080            InvertedIndexParams::default(),
2081            None,
2082            Vec::new(),
2083            TokenSetFormat::default(),
2084            None,
2085            RoaringBitmap::new(),
2086        )
2087        .with_format_version(InvertedListFormatVersion::V2)
2088        .with_posting_tail_codec(PostingTailCodec::Fixed32);
2089        assert_eq!(builder.format_version, InvertedListFormatVersion::V1);
2090        assert_eq!(builder.posting_tail_codec, PostingTailCodec::Fixed32);
2091
2092        let builder = builder.with_posting_tail_codec(PostingTailCodec::VarintDelta);
2093        assert_eq!(builder.format_version, InvertedListFormatVersion::V2);
2094        assert_eq!(builder.posting_tail_codec, PostingTailCodec::VarintDelta);
2095    }
2096
2097    #[tokio::test]
2098    async fn test_inverted_index_without_positions_tracks_frequency() -> Result<()> {
2099        let index_dir = TempDir::default();
2100        let store = Arc::new(LanceIndexStore::new(
2101            ObjectStore::local().into(),
2102            index_dir.obj_path(),
2103            Arc::new(LanceCache::no_cache()),
2104        ));
2105
2106        let schema = Arc::new(Schema::new(vec![
2107            Field::new("doc", DataType::Utf8, true),
2108            Field::new(ROW_ID, DataType::UInt64, false),
2109        ]));
2110        let docs = Arc::new(StringArray::from(vec![Some("hello hello world")]));
2111        let row_ids = Arc::new(UInt64Array::from(vec![0u64]));
2112        let batch = RecordBatch::try_new(schema.clone(), vec![docs, row_ids])?;
2113        let stream = RecordBatchStreamAdapter::new(schema, stream::iter(vec![Ok(batch)]));
2114        let stream = Box::pin(stream);
2115
2116        let params = InvertedIndexParams::new(
2117            "whitespace".to_string(),
2118            tantivy::tokenizer::Language::English,
2119        )
2120        .with_position(false)
2121        .remove_stop_words(false)
2122        .stem(false)
2123        .max_token_length(None);
2124
2125        let mut builder = InvertedIndexBuilder::new(params);
2126        builder.update(stream, store.as_ref(), None).await?;
2127
2128        let index = InvertedIndex::load(store, None, &LanceCache::no_cache()).await?;
2129        assert_eq!(index.partitions.len(), 1);
2130        let partition = &index.partitions[0];
2131        let token_id = partition.tokens.get("hello").unwrap();
2132        let posting = partition
2133            .inverted_list
2134            .posting_list(token_id, false, &NoOpMetricsCollector)
2135            .await?;
2136
2137        let mut iter = posting.iter();
2138        let (doc_id, freq, positions) = iter.next().unwrap();
2139        assert_eq!(doc_id, 0);
2140        assert_eq!(freq, 2);
2141        assert!(positions.is_none());
2142        assert!(iter.next().is_none());
2143
2144        Ok(())
2145    }
2146
2147    #[derive(Debug, Default)]
2148    struct RecordingProgress {
2149        events: Mutex<Vec<(String, String, u64)>>,
2150    }
2151
2152    #[async_trait]
2153    impl IndexBuildProgress for RecordingProgress {
2154        async fn stage_start(&self, stage: &str, total: Option<u64>, _unit: &str) -> Result<()> {
2155            self.events.lock().await.push((
2156                "start".to_string(),
2157                stage.to_string(),
2158                total.unwrap_or(0),
2159            ));
2160            Ok(())
2161        }
2162
2163        async fn stage_progress(&self, stage: &str, completed: u64) -> Result<()> {
2164            self.events
2165                .lock()
2166                .await
2167                .push(("progress".to_string(), stage.to_string(), completed));
2168            Ok(())
2169        }
2170
2171        async fn stage_complete(&self, stage: &str) -> Result<()> {
2172            self.events
2173                .lock()
2174                .await
2175                .push(("complete".to_string(), stage.to_string(), 0));
2176            Ok(())
2177        }
2178    }
2179
2180    #[derive(Debug, Default)]
2181    struct FailingProgress;
2182
2183    #[async_trait]
2184    impl IndexBuildProgress for FailingProgress {
2185        async fn stage_start(&self, _stage: &str, _total: Option<u64>, _unit: &str) -> Result<()> {
2186            Ok(())
2187        }
2188
2189        async fn stage_progress(&self, _stage: &str, _completed: u64) -> Result<()> {
2190            Err(Error::io("injected progress failure"))
2191        }
2192
2193        async fn stage_complete(&self, _stage: &str) -> Result<()> {
2194            Ok(())
2195        }
2196    }
2197
2198    #[tokio::test]
2199    async fn test_builder_reports_progress_stages() -> Result<()> {
2200        let index_dir = TempDir::default();
2201        let store = Arc::new(LanceIndexStore::new(
2202            ObjectStore::local().into(),
2203            index_dir.obj_path(),
2204            Arc::new(LanceCache::no_cache()),
2205        ));
2206
2207        let batch1 = make_doc_batch("hello world", 0);
2208        let batch2 = make_doc_batch("goodbye world", 1);
2209        let total_rows = 2u64;
2210        let stream = RecordBatchStreamAdapter::new(
2211            batch1.schema(),
2212            stream::iter(vec![Ok(batch1), Ok(batch2)]),
2213        );
2214        let stream = Box::pin(stream);
2215
2216        let progress = Arc::new(RecordingProgress::default());
2217        let mut builder = InvertedIndexBuilder::new(InvertedIndexParams::default())
2218            .with_progress(progress.clone());
2219        builder.update(stream, store.as_ref(), None).await?;
2220
2221        let events = progress.events.lock().await.clone();
2222        let tags = events
2223            .iter()
2224            .map(|(kind, stage, _)| format!("{kind}:{stage}"))
2225            .collect::<Vec<_>>();
2226        let tokenize_progress = events
2227            .iter()
2228            .filter_map(|(kind, stage, completed)| {
2229                if kind == "progress" && stage == "tokenize_docs" {
2230                    Some(*completed)
2231                } else {
2232                    None
2233                }
2234            })
2235            .collect::<Vec<_>>();
2236
2237        let tokenize_start = tags
2238            .iter()
2239            .position(|e| e == "start:tokenize_docs")
2240            .expect("missing tokenize_docs start");
2241        let tokenize_complete = tags
2242            .iter()
2243            .position(|e| e == "complete:tokenize_docs")
2244            .expect("missing tokenize_docs complete");
2245        let copy_start = tags
2246            .iter()
2247            .position(|e| e == "start:copy_partitions")
2248            .expect("missing copy_partitions start");
2249        let copy_complete = tags
2250            .iter()
2251            .position(|e| e == "complete:copy_partitions")
2252            .expect("missing copy_partitions complete");
2253        let metadata_start = tags
2254            .iter()
2255            .position(|e| e == "start:write_metadata")
2256            .expect("missing write_metadata start");
2257        let metadata_complete = tags
2258            .iter()
2259            .position(|e| e == "complete:write_metadata")
2260            .expect("missing write_metadata complete");
2261
2262        assert!(tokenize_start < tokenize_complete);
2263        assert!(tokenize_complete < copy_start);
2264        assert!(copy_start < copy_complete);
2265        assert!(copy_complete < metadata_start);
2266        assert!(metadata_start < metadata_complete);
2267
2268        assert!(
2269            tags.iter().any(|e| e == "progress:tokenize_docs"),
2270            "expected progress callback for tokenize_docs"
2271        );
2272        assert!(
2273            tokenize_progress.len() >= 2,
2274            "expected at least two progress callbacks for tokenize_docs, got {tokenize_progress:?}"
2275        );
2276        assert_eq!(
2277            tokenize_progress.iter().copied().max().unwrap_or_default(),
2278            total_rows,
2279            "expected tokenize_docs progress to reach all rows"
2280        );
2281        assert!(
2282            tags.iter().any(|e| e == "progress:copy_partitions"),
2283            "expected progress callback for copy_partitions"
2284        );
2285        assert!(
2286            tags.iter().any(|e| e == "progress:write_metadata"),
2287            "expected progress callback for write_metadata"
2288        );
2289        assert!(
2290            !tags.iter().any(|e| e == "start:merge_partitions"),
2291            "merge_partitions should not run in the build-only path"
2292        );
2293
2294        Ok(())
2295    }
2296
2297    #[tokio::test]
2298    async fn test_builder_default_path_skips_merge_stage() -> Result<()> {
2299        let index_dir = TempDir::default();
2300        let store = Arc::new(LanceIndexStore::new(
2301            ObjectStore::local().into(),
2302            index_dir.obj_path(),
2303            Arc::new(LanceCache::no_cache()),
2304        ));
2305
2306        let batch = make_doc_batch("hello world", 0);
2307        let stream = RecordBatchStreamAdapter::new(batch.schema(), stream::iter(vec![Ok(batch)]));
2308        let stream = Box::pin(stream);
2309
2310        let progress = Arc::new(RecordingProgress::default());
2311        let mut builder = InvertedIndexBuilder::new(InvertedIndexParams::default())
2312            .with_progress(progress.clone());
2313        builder.update(stream, store.as_ref(), None).await?;
2314
2315        let tags = progress
2316            .events
2317            .lock()
2318            .await
2319            .iter()
2320            .map(|(kind, stage, _)| format!("{kind}:{stage}"))
2321            .collect::<Vec<_>>();
2322
2323        assert!(
2324            tags.iter().any(|e| e == "start:copy_partitions"),
2325            "default path should copy finalized partitions"
2326        );
2327        assert!(
2328            !tags.iter().any(|e| e == "start:merge_partitions"),
2329            "default path should not run merge_partitions"
2330        );
2331        Ok(())
2332    }
2333
2334    #[tokio::test]
2335    async fn test_worker_memory_limit_rejects_single_large_doc() {
2336        let index_dir = TempDir::default();
2337        let store = Arc::new(LanceIndexStore::new(
2338            ObjectStore::local().into(),
2339            index_dir.obj_path(),
2340            Arc::new(LanceCache::no_cache()),
2341        ));
2342
2343        let batch = make_doc_batch("hello world", 42);
2344        let stream = RecordBatchStreamAdapter::new(batch.schema(), stream::iter(vec![Ok(batch)]));
2345        let stream = Box::pin(stream);
2346
2347        let mut builder =
2348            InvertedIndexBuilder::new(InvertedIndexParams::default().memory_limit_mb(0));
2349        let err = builder
2350            .update(stream, store.as_ref(), None)
2351            .await
2352            .expect_err("single doc should exceed zero worker memory limit");
2353        assert!(
2354            err.to_string().contains("row_id=42"),
2355            "unexpected error: {err}"
2356        );
2357    }
2358
2359    #[tokio::test]
2360    async fn test_worker_trims_position_temp_buffers() -> Result<()> {
2361        let tokenizer = InvertedIndexParams::default().with_position(true).build()?;
2362        let store = Arc::new(CountingStore::new());
2363        let id_alloc = Arc::new(AtomicU64::new(0));
2364        let mut worker = IndexWorker::new(
2365            tokenizer,
2366            store,
2367            id_alloc,
2368            IndexWorkerConfig {
2369                with_position: true,
2370                format_version: InvertedListFormatVersion::V1,
2371                fragment_mask: None,
2372                token_set_format: TokenSetFormat::default(),
2373                worker_memory_limit_bytes: u64::MAX,
2374            },
2375        )
2376        .await?;
2377
2378        let doc = (0..(MAX_RETAINED_TOKEN_IDS * 2))
2379            .map(|i| format!("tok{i}"))
2380            .collect::<Vec<_>>()
2381            .join(" ");
2382        worker.process_batch(make_doc_batch(&doc, 0)).await?;
2383
2384        assert!(worker.token_ids.is_empty());
2385        assert!(worker.token_ids.capacity() <= MAX_RETAINED_TOKEN_IDS);
2386        assert!(worker.memory_size >= worker.temporary_memory_size());
2387        Ok(())
2388    }
2389
2390    #[tokio::test]
2391    async fn test_worker_flush_keeps_position_temp_memory_bounded() -> Result<()> {
2392        let tokenizer = InvertedIndexParams::default().with_position(true).build()?;
2393        let store = Arc::new(CountingStore::new());
2394        let id_alloc = Arc::new(AtomicU64::new(0));
2395        let mut worker = IndexWorker::new(
2396            tokenizer,
2397            store,
2398            id_alloc,
2399            IndexWorkerConfig {
2400                with_position: true,
2401                format_version: InvertedListFormatVersion::V1,
2402                fragment_mask: None,
2403                token_set_format: TokenSetFormat::default(),
2404                worker_memory_limit_bytes: u64::MAX,
2405            },
2406        )
2407        .await?;
2408
2409        let doc = std::iter::repeat_n("common", 32_768)
2410            .collect::<Vec<_>>()
2411            .join(" ");
2412        let mut observed_post_flush_memory = Vec::new();
2413        for row_id in 0..8 {
2414            worker.process_batch(make_doc_batch(&doc, row_id)).await?;
2415            worker.flush().await?;
2416            observed_post_flush_memory.push(worker.memory_size);
2417        }
2418
2419        let max_memory = *observed_post_flush_memory.iter().max().unwrap();
2420        let min_memory = *observed_post_flush_memory.iter().min().unwrap();
2421        assert!(
2422            max_memory <= min_memory.saturating_add(256 * 1024),
2423            "post-flush worker memory drifted upward: {observed_post_flush_memory:?}"
2424        );
2425        Ok(())
2426    }
2427
2428    #[tokio::test]
2429    async fn test_worker_flush_writes_partition_directly() -> Result<()> {
2430        let tokenizer = InvertedIndexParams::default().with_position(true).build()?;
2431        let store = Arc::new(CountingStore::new());
2432        let id_alloc = Arc::new(AtomicU64::new(0));
2433        let mut worker = IndexWorker::new(
2434            tokenizer,
2435            store.clone(),
2436            id_alloc,
2437            IndexWorkerConfig {
2438                with_position: true,
2439                format_version: InvertedListFormatVersion::V1,
2440                fragment_mask: None,
2441                token_set_format: TokenSetFormat::default(),
2442                worker_memory_limit_bytes: u64::MAX,
2443            },
2444        )
2445        .await?;
2446        worker
2447            .process_batch(make_doc_batch("alpha beta gamma", 0))
2448            .await?;
2449        worker.flush().await?;
2450        assert!(store.write_count() > 0);
2451        Ok(())
2452    }
2453
2454    #[test]
2455    fn test_resolve_worker_memory_limit_uses_default_when_unset() {
2456        let params = InvertedIndexParams::default();
2457        assert_eq!(
2458            resolve_worker_memory_limit_bytes(&params, 8),
2459            *LANCE_FTS_PARTITION_SIZE << 20
2460        );
2461    }
2462
2463    #[test]
2464    fn test_resolve_num_workers_uses_default_when_unset() {
2465        let expected = default_num_workers().clamp(1, get_num_compute_intensive_cpus().max(1));
2466        assert_eq!(
2467            resolve_num_workers(&InvertedIndexParams::default()),
2468            expected
2469        );
2470    }
2471
2472    #[test]
2473    fn test_resolve_num_workers_clamps_requested_value() {
2474        let max_workers = get_num_compute_intensive_cpus().max(1);
2475        assert_eq!(
2476            resolve_num_workers(&InvertedIndexParams::default().num_workers(0)),
2477            1
2478        );
2479        assert_eq!(
2480            resolve_num_workers(&InvertedIndexParams::default().num_workers(max_workers + 10)),
2481            max_workers
2482        );
2483    }
2484
2485    #[test]
2486    fn test_resolve_worker_memory_limit_splits_total_memory_limit() {
2487        let params = InvertedIndexParams::default().memory_limit_mb(4096);
2488        assert_eq!(resolve_worker_memory_limit_bytes(&params, 16), 256 << 20);
2489    }
2490
2491    #[test]
2492    fn test_merge_all_tail_partitions_combines_everything() -> Result<()> {
2493        let merged = merge_all_tail_partitions(vec![
2494            TailPartition {
2495                builder: InnerBuilder::new(0, false, TokenSetFormat::default()),
2496            },
2497            TailPartition {
2498                builder: InnerBuilder::new(1, false, TokenSetFormat::default()),
2499            },
2500            TailPartition {
2501                builder: InnerBuilder::new(2, false, TokenSetFormat::default()),
2502            },
2503        ])?;
2504
2505        assert_eq!(merged.expect("merged builder should exist").id(), 0);
2506        Ok(())
2507    }
2508
2509    #[test]
2510    fn test_merge_all_tail_partitions_returns_none_for_empty_input() -> Result<()> {
2511        assert!(merge_all_tail_partitions(Vec::new())?.is_none());
2512        Ok(())
2513    }
2514
2515    #[test]
2516    fn test_merge_tail_partition_group_combines_tail_builders() -> Result<()> {
2517        let mut first = InnerBuilder::new(0, false, TokenSetFormat::default());
2518        let hello = first.tokens.add("hello".to_owned());
2519        first
2520            .posting_lists
2521            .resize_with(first.tokens.len(), || PostingListBuilder::new(false));
2522        let first_doc = first.docs.append(10, 1);
2523        first.posting_lists[hello as usize].add(first_doc, PositionRecorder::Count(1));
2524
2525        let mut second = InnerBuilder::new(1, false, TokenSetFormat::default());
2526        let world = second.tokens.add("world".to_owned());
2527        second
2528            .posting_lists
2529            .resize_with(second.tokens.len(), || PostingListBuilder::new(false));
2530        let second_doc = second.docs.append(20, 2);
2531        second.posting_lists[world as usize].add(second_doc, PositionRecorder::Count(2));
2532
2533        let merged = merge_tail_partition_group(vec![
2534            TailPartition { builder: first },
2535            TailPartition { builder: second },
2536        ])?;
2537
2538        assert_eq!(merged.id(), 0);
2539        assert_eq!(merged.docs.len(), 2);
2540        assert_eq!(merged.tokens.len(), 2);
2541        assert_eq!(merged.posting_lists.len(), 2);
2542        assert_eq!(
2543            merged.posting_lists[merged.tokens.get("hello").unwrap() as usize].len(),
2544            1
2545        );
2546        assert_eq!(
2547            merged.posting_lists[merged.tokens.get("world").unwrap() as usize].len(),
2548            1
2549        );
2550        Ok(())
2551    }
2552
2553    #[tokio::test]
2554    async fn test_update_index_returns_worker_error_when_workers_exit_during_dispatch() {
2555        let num_batches = (*LANCE_FTS_NUM_SHARDS * 2 + 1) as u64;
2556        let index_dir = TempDir::default();
2557        let store = Arc::new(LanceIndexStore::new(
2558            ObjectStore::local().into(),
2559            index_dir.obj_path(),
2560            Arc::new(LanceCache::no_cache()),
2561        ));
2562        let schema = make_doc_batch("hello world", 0).schema();
2563        let stream = RecordBatchStreamAdapter::new(
2564            schema,
2565            stream::iter((0..num_batches).map(|row_id| Ok(make_doc_batch("hello world", row_id)))),
2566        );
2567        let stream = Box::pin(stream);
2568
2569        let mut builder = InvertedIndexBuilder::new(InvertedIndexParams::default())
2570            .with_progress(Arc::new(FailingProgress));
2571
2572        let result = tokio::time::timeout(
2573            Duration::from_secs(5),
2574            builder.update_index(stream, store.as_ref()),
2575        )
2576        .await
2577        .expect("update_index should not hang")
2578        .expect_err("worker failure should be returned");
2579
2580        assert!(
2581            result.to_string().contains("injected progress failure"),
2582            "unexpected error: {result}"
2583        );
2584    }
2585
2586    #[tokio::test]
2587    async fn test_new_index_has_empty_deleted_fragments() {
2588        let index_dir = TempDir::default();
2589        let store = Arc::new(LanceIndexStore::new(
2590            ObjectStore::local().into(),
2591            index_dir.obj_path(),
2592            Arc::new(LanceCache::no_cache()),
2593        ));
2594
2595        let batch = make_doc_batch("hello world", 0);
2596        let stream = RecordBatchStreamAdapter::new(batch.schema(), stream::iter(vec![Ok(batch)]));
2597        let stream = Box::pin(stream);
2598
2599        let mut builder = InvertedIndexBuilder::new(InvertedIndexParams::default());
2600        builder.update(stream, store.as_ref(), None).await.unwrap();
2601
2602        let index = InvertedIndex::load(store, None, &LanceCache::no_cache())
2603            .await
2604            .unwrap();
2605        assert!(
2606            index.deleted_fragments().is_empty(),
2607            "new index should have empty deleted fragments, got {:?}",
2608            index.deleted_fragments()
2609        );
2610    }
2611
2612    #[tokio::test]
2613    async fn test_remap_preserves_deleted_fragments() {
2614        let src_dir = TempDir::default();
2615        let dest_dir = TempDir::default();
2616        let src_store = Arc::new(LanceIndexStore::new(
2617            ObjectStore::local().into(),
2618            src_dir.obj_path(),
2619            Arc::new(LanceCache::no_cache()),
2620        ));
2621        let dest_store = Arc::new(LanceIndexStore::new(
2622            ObjectStore::local().into(),
2623            dest_dir.obj_path(),
2624            Arc::new(LanceCache::no_cache()),
2625        ));
2626
2627        // Build an initial index with some deleted fragments
2628        let batch = make_doc_batch("hello world", 0);
2629        let stream = RecordBatchStreamAdapter::new(batch.schema(), stream::iter(vec![Ok(batch)]));
2630        let stream = Box::pin(stream);
2631
2632        let initial_deleted = RoaringBitmap::from_iter([5, 10, 42]);
2633        let mut builder = InvertedIndexBuilder::from_existing_index(
2634            InvertedIndexParams::default(),
2635            None,
2636            Vec::new(),
2637            TokenSetFormat::default(),
2638            None,
2639            initial_deleted.clone(),
2640        );
2641        builder
2642            .update(stream, src_store.as_ref(), None)
2643            .await
2644            .unwrap();
2645
2646        // Load it back and confirm the invalidated fragments are set
2647        let index = InvertedIndex::load(src_store.clone(), None, &LanceCache::no_cache())
2648            .await
2649            .unwrap();
2650        assert_eq!(index.deleted_fragments(), &initial_deleted);
2651
2652        // Remap the index via the ScalarIndex trait method
2653        use crate::scalar::ScalarIndex;
2654        let mapping = HashMap::from([(0u64, Some(50 << 32))]);
2655        index.remap(&mapping, dest_store.as_ref()).await.unwrap();
2656
2657        // Reload from dest and verify deleted fragments are preserved
2658        let remapped_index = InvertedIndex::load(dest_store.clone(), None, &LanceCache::no_cache())
2659            .await
2660            .unwrap();
2661        assert_eq!(
2662            remapped_index.deleted_fragments(),
2663            &initial_deleted,
2664            "remap should preserve deleted fragments"
2665        );
2666    }
2667
2668    #[tokio::test]
2669    async fn test_update_grows_deleted_fragments_from_old_data_filter() {
2670        let index_dir = TempDir::default();
2671        let store = Arc::new(LanceIndexStore::new(
2672            ObjectStore::local().into(),
2673            index_dir.obj_path(),
2674            Arc::new(LanceCache::no_cache()),
2675        ));
2676
2677        // Build an initial index with no deleted fragments
2678        let batch = make_doc_batch("hello world", 0);
2679        let stream = RecordBatchStreamAdapter::new(batch.schema(), stream::iter(vec![Ok(batch)]));
2680        let stream = Box::pin(stream);
2681
2682        let mut builder = InvertedIndexBuilder::new(InvertedIndexParams::default());
2683        builder.update(stream, store.as_ref(), None).await.unwrap();
2684
2685        // Load the index and update it with an old_data_filter that invalidates fragments
2686        let index = InvertedIndex::load(store.clone(), None, &LanceCache::no_cache())
2687            .await
2688            .unwrap();
2689        assert!(index.deleted_fragments().is_empty());
2690
2691        let update_dir = TempDir::default();
2692        let update_store = Arc::new(LanceIndexStore::new(
2693            ObjectStore::local().into(),
2694            update_dir.obj_path(),
2695            Arc::new(LanceCache::no_cache()),
2696        ));
2697
2698        let batch2 = make_doc_batch("new document", 1 << 32 | 1);
2699        let stream2 =
2700            RecordBatchStreamAdapter::new(batch2.schema(), stream::iter(vec![Ok(batch2)]));
2701        let stream2 = Box::pin(stream2);
2702
2703        let old_data_filter = Some(crate::scalar::OldIndexDataFilter::Fragments {
2704            to_keep: RoaringBitmap::from_iter([0]),
2705            to_remove: RoaringBitmap::from_iter([3, 7]),
2706        });
2707
2708        // Use ScalarIndex::update trait method
2709        use crate::scalar::ScalarIndex;
2710        index
2711            .update(stream2, update_store.as_ref(), old_data_filter)
2712            .await
2713            .unwrap();
2714
2715        let updated_index =
2716            InvertedIndex::load(update_store.clone(), None, &LanceCache::no_cache())
2717                .await
2718                .unwrap();
2719        assert_eq!(
2720            updated_index.deleted_fragments(),
2721            &RoaringBitmap::from_iter([3, 7]),
2722            "update should add deleted fragments from old_data_filter"
2723        );
2724    }
2725
2726    #[tokio::test]
2727    async fn test_update_accumulates_deleted_fragments() {
2728        let dir1 = TempDir::default();
2729        let store1 = Arc::new(LanceIndexStore::new(
2730            ObjectStore::local().into(),
2731            dir1.obj_path(),
2732            Arc::new(LanceCache::no_cache()),
2733        ));
2734
2735        // Build initial index
2736        let batch = make_doc_batch("hello world", 0);
2737        let stream = RecordBatchStreamAdapter::new(batch.schema(), stream::iter(vec![Ok(batch)]));
2738        let stream = Box::pin(stream);
2739
2740        let mut builder = InvertedIndexBuilder::new(InvertedIndexParams::default());
2741        builder.update(stream, store1.as_ref(), None).await.unwrap();
2742
2743        // First update: delete fragments 3 and 7
2744        let index = InvertedIndex::load(store1.clone(), None, &LanceCache::no_cache())
2745            .await
2746            .unwrap();
2747
2748        let dir2 = TempDir::default();
2749        let store2 = Arc::new(LanceIndexStore::new(
2750            ObjectStore::local().into(),
2751            dir2.obj_path(),
2752            Arc::new(LanceCache::no_cache()),
2753        ));
2754
2755        let batch2 = make_doc_batch("second doc", 1 << 32 | 1);
2756        let stream2 =
2757            RecordBatchStreamAdapter::new(batch2.schema(), stream::iter(vec![Ok(batch2)]));
2758        let stream2 = Box::pin(stream2);
2759
2760        use crate::scalar::ScalarIndex;
2761        index
2762            .update(
2763                stream2,
2764                store2.as_ref(),
2765                Some(crate::scalar::OldIndexDataFilter::Fragments {
2766                    to_keep: RoaringBitmap::from_iter([0]),
2767                    to_remove: RoaringBitmap::from_iter([3, 7]),
2768                }),
2769            )
2770            .await
2771            .unwrap();
2772
2773        // Second update: invalidate additional fragments 12 and 15
2774        let index2 = InvertedIndex::load(store2.clone(), None, &LanceCache::no_cache())
2775            .await
2776            .unwrap();
2777        assert_eq!(
2778            index2.deleted_fragments(),
2779            &RoaringBitmap::from_iter([3, 7])
2780        );
2781
2782        let dir3 = TempDir::default();
2783        let store3 = Arc::new(LanceIndexStore::new(
2784            ObjectStore::local().into(),
2785            dir3.obj_path(),
2786            Arc::new(LanceCache::no_cache()),
2787        ));
2788
2789        let batch3 = make_doc_batch("third doc", 2 << 32 | 2);
2790        let stream3 =
2791            RecordBatchStreamAdapter::new(batch3.schema(), stream::iter(vec![Ok(batch3)]));
2792        let stream3 = Box::pin(stream3);
2793
2794        index2
2795            .update(
2796                stream3,
2797                store3.as_ref(),
2798                Some(crate::scalar::OldIndexDataFilter::Fragments {
2799                    to_keep: RoaringBitmap::from_iter([0, 1]),
2800                    to_remove: RoaringBitmap::from_iter([12, 15]),
2801                }),
2802            )
2803            .await
2804            .unwrap();
2805
2806        let index3 = InvertedIndex::load(store3.clone(), None, &LanceCache::no_cache())
2807            .await
2808            .unwrap();
2809        assert_eq!(
2810            index3.deleted_fragments(),
2811            &RoaringBitmap::from_iter([3, 7, 12, 15]),
2812            "deleted fragments should accumulate across updates"
2813        );
2814    }
2815
2816    #[tokio::test]
2817    async fn test_update_with_rowid_filter_does_not_grow_deleted_fragments() {
2818        let index_dir = TempDir::default();
2819        let store = Arc::new(LanceIndexStore::new(
2820            ObjectStore::local().into(),
2821            index_dir.obj_path(),
2822            Arc::new(LanceCache::no_cache()),
2823        ));
2824
2825        let batch = make_doc_batch("hello world", 0);
2826        let stream = RecordBatchStreamAdapter::new(batch.schema(), stream::iter(vec![Ok(batch)]));
2827        let stream = Box::pin(stream);
2828
2829        let mut builder = InvertedIndexBuilder::new(InvertedIndexParams::default());
2830        builder.update(stream, store.as_ref(), None).await.unwrap();
2831
2832        let index = InvertedIndex::load(store.clone(), None, &LanceCache::no_cache())
2833            .await
2834            .unwrap();
2835
2836        let update_dir = TempDir::default();
2837        let update_store = Arc::new(LanceIndexStore::new(
2838            ObjectStore::local().into(),
2839            update_dir.obj_path(),
2840            Arc::new(LanceCache::no_cache()),
2841        ));
2842
2843        let batch2 = make_doc_batch("new doc", 1);
2844        let stream2 =
2845            RecordBatchStreamAdapter::new(batch2.schema(), stream::iter(vec![Ok(batch2)]));
2846        let stream2 = Box::pin(stream2);
2847
2848        // Use RowIds filter instead of Fragments — should not affect deleted_fragments
2849        let mut valid_ids = lance_core::utils::mask::RowAddrTreeMap::new();
2850        valid_ids.insert(0);
2851        let old_data_filter = Some(crate::scalar::OldIndexDataFilter::RowIds(valid_ids));
2852
2853        use crate::scalar::ScalarIndex;
2854        index
2855            .update(stream2, update_store.as_ref(), old_data_filter)
2856            .await
2857            .unwrap();
2858
2859        let updated_index =
2860            InvertedIndex::load(update_store.clone(), None, &LanceCache::no_cache())
2861                .await
2862                .unwrap();
2863        assert!(
2864            updated_index.deleted_fragments().is_empty(),
2865            "RowIds filter should not add to deleted fragments"
2866        );
2867    }
2868}