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::document_tokenizer::DocType;
6use crate::scalar::inverted::json::JsonTextStream;
7use crate::scalar::inverted::tokenizer::LEGACY_BLOCK_SIZE;
8use crate::scalar::inverted::tokenizer::document_tokenizer::LanceTokenizer;
9#[cfg(test)]
10use crate::scalar::lance_format::LanceIndexStore;
11use crate::scalar::{IndexFile, IndexStore, OldIndexDataFilter};
12use crate::vector::graph::OrderedFloat;
13use crate::{progress::IndexBuildProgress, progress::noop_progress};
14use arrow::array::AsArray;
15use arrow::datatypes;
16use arrow_array::{Array, BinaryArray, RecordBatch};
17use arrow_schema::{DataType, Field, Schema, SchemaRef};
18use datafusion::execution::SendableRecordBatchStream;
19use fst::Streamer;
20use futures::{StreamExt, TryStreamExt};
21use lance_arrow::json::JSON_EXT_NAME;
22use lance_arrow::{ARROW_EXT_NAME_KEY, iter_str_array};
23use lance_bitpacking::{BitPacker, BitPacker4x};
24use lance_core::cache::LanceCache;
25use lance_core::deepsize::DeepSizeOf;
26use lance_core::error::LanceOptionExt;
27use lance_core::utils::row_addr_remap::RowAddrRemap;
28use lance_core::utils::tokio::{IO_CORE_RESERVATION, get_num_compute_intensive_cpus, spawn_cpu};
29use lance_core::{Error, ROW_ID, Result};
30use lance_io::object_store::ObjectStore;
31use lance_select::RowSetOps;
32use object_store::path::Path;
33use roaring::RoaringBitmap;
34use smallvec::SmallVec;
35use std::collections::HashMap;
36use std::str::FromStr;
37use std::sync::Arc;
38use std::sync::LazyLock;
39use std::{fmt::Debug, sync::atomic::AtomicU64};
40use tracing::instrument;
41
42// The legacy bitpacking block size. Position streams still use this block size;
43// FTS posting blocks choose their physical bitpacker from the configured
44// InvertedIndexParams::block_size.
45pub const BLOCK_SIZE: usize = BitPacker4x::BLOCK_LEN;
46
47// The default number of workers to use for FTS builds.
48// By default this is roughly `num_cpus / 2`, but it can be overridden
49// with `LANCE_FTS_NUM_SHARDS`.
50pub static LANCE_FTS_NUM_SHARDS: LazyLock<usize> = LazyLock::new(|| {
51    std::env::var("LANCE_FTS_NUM_SHARDS")
52        .unwrap_or_else(|_| default_num_workers().to_string())
53        .parse()
54        .expect("failed to parse LANCE_FTS_NUM_SHARDS")
55});
56// The default per-worker memory limit in MiB for FTS builds.
57pub static LANCE_FTS_PARTITION_SIZE: LazyLock<u64> = LazyLock::new(|| {
58    std::env::var("LANCE_FTS_PARTITION_SIZE")
59        .unwrap_or_else(|_| "2048".to_string())
60        .parse()
61        .expect("failed to parse LANCE_FTS_PARTITION_SIZE")
62});
63static LANCE_FTS_WRITE_QUEUE_SIZE: LazyLock<usize> = LazyLock::new(|| {
64    std::env::var("LANCE_FTS_WRITE_QUEUE_SIZE")
65        .unwrap_or_else(|_| "1".to_string())
66        .parse()
67        .expect("failed to parse LANCE_FTS_WRITE_QUEUE_SIZE")
68});
69static LANCE_FTS_POSTING_BATCH_ROWS: LazyLock<usize> = LazyLock::new(|| {
70    std::env::var("LANCE_FTS_POSTING_BATCH_ROWS")
71        .unwrap_or_else(|_| "256".to_string())
72        .parse()
73        .expect("failed to parse LANCE_FTS_POSTING_BATCH_ROWS")
74});
75const MAX_RETAINED_TOKEN_IDS: usize = 8 * 1024;
76
77fn default_num_workers() -> usize {
78    let total_cpus = get_num_compute_intensive_cpus() + *IO_CORE_RESERVATION;
79    std::cmp::max(1, total_cpus / 2)
80}
81
82fn resolve_num_workers(params: &InvertedIndexParams) -> usize {
83    let max_workers = get_num_compute_intensive_cpus().max(1);
84    params
85        .num_workers
86        .unwrap_or(*LANCE_FTS_NUM_SHARDS)
87        .clamp(1, max_workers)
88}
89
90fn resolve_worker_memory_limit_bytes(params: &InvertedIndexParams, num_workers: usize) -> u64 {
91    let default_worker_memory_limit_bytes = *LANCE_FTS_PARTITION_SIZE << 20;
92    params
93        .memory_limit_mb
94        .map(|memory_limit_mb| (memory_limit_mb << 20) / num_workers as u64)
95        .unwrap_or(default_worker_memory_limit_bytes)
96}
97
98/// Merge the workers' leftover tail builders into as few partitions as the
99/// memory budget allows. Folding unconditionally would collapse every build
100/// whose workers never hit the flush threshold into a single partition, which
101/// destroys intra-query parallelism; splitting by the same per-partition
102/// budget as the flush path makes the final partition count converge to
103/// roughly total_builder_memory / memory_limit_bytes regardless of worker
104/// layout.
105fn merge_all_tail_partitions(
106    tails: Vec<TailPartition>,
107    memory_limit_bytes: u64,
108) -> Result<Vec<InnerBuilder>> {
109    let mut merged_builders: Vec<InnerBuilder> = Vec::new();
110    let mut merged: Option<InnerBuilder> = None;
111    for tail in tails {
112        let builder = tail.builder;
113        if builder.is_empty() {
114            continue;
115        }
116        match &mut merged {
117            Some(current) => {
118                let would_exceed_memory =
119                    current.memory_size().saturating_add(builder.memory_size())
120                        >= memory_limit_bytes;
121                let would_exceed_doc_ids =
122                    current.docs.len().saturating_add(builder.docs.len()) > u32::MAX as usize;
123                if would_exceed_memory || would_exceed_doc_ids {
124                    merged_builders.push(std::mem::replace(current, builder));
125                } else {
126                    current.merge_from(builder)?;
127                }
128            }
129            None => merged = Some(builder),
130        }
131    }
132    if let Some(builder) = merged {
133        merged_builders.push(builder);
134    }
135    Ok(merged_builders)
136}
137
138#[derive(Debug)]
139pub struct InvertedIndexBuilder {
140    params: InvertedIndexParams,
141    pub(crate) partitions: Vec<u64>,
142    new_partitions: Vec<u64>,
143    fragment_mask: Option<u64>,
144    token_set_format: TokenSetFormat,
145    format_version: InvertedListFormatVersion,
146    posting_tail_codec: PostingTailCodec,
147    src_store: Option<Arc<dyn IndexStore>>,
148    progress: Arc<dyn IndexBuildProgress>,
149    deleted_fragments: RoaringBitmap,
150}
151
152impl InvertedIndexBuilder {
153    pub fn new(params: InvertedIndexParams) -> Self {
154        Self::new_with_fragment_mask(params, None)
155    }
156
157    pub fn new_with_fragment_mask(params: InvertedIndexParams, fragment_mask: Option<u64>) -> Self {
158        Self::from_existing_index(
159            params,
160            None,
161            Vec::new(),
162            TokenSetFormat::default(),
163            fragment_mask,
164            RoaringBitmap::new(),
165        )
166    }
167
168    /// Creates an InvertedIndexBuilder from existing index with fragment filtering.
169    /// This method is used to create a builder from an existing index while applying
170    /// fragment-based filtering for distributed indexing scenarios.
171    /// fragment_mask Optional mask with fragment_id in high 32 bits for filtering.
172    /// Constructed as `(fragment_id as u64) << 32`.
173    /// When provided, ensures that generated IDs belong to the specified fragment.
174    pub fn from_existing_index(
175        params: InvertedIndexParams,
176        store: Option<Arc<dyn IndexStore>>,
177        partitions: Vec<u64>,
178        token_set_format: TokenSetFormat,
179        fragment_mask: Option<u64>,
180        deleted_fragments: RoaringBitmap,
181    ) -> Self {
182        let format_version = params.resolved_format_version();
183        Self {
184            params,
185            partitions,
186            new_partitions: Vec::new(),
187            src_store: store,
188            token_set_format,
189            fragment_mask,
190            format_version,
191            posting_tail_codec: format_version.posting_tail_codec(),
192            progress: noop_progress(),
193            deleted_fragments,
194        }
195    }
196
197    pub fn with_posting_tail_codec(mut self, posting_tail_codec: PostingTailCodec) -> Self {
198        self.format_version = InvertedListFormatVersion::from_posting_tail_codec_and_block_size(
199            posting_tail_codec,
200            self.params.block_size,
201        )
202        .expect("invalid posting tail codec for posting block size");
203        self.posting_tail_codec = posting_tail_codec;
204        self
205    }
206
207    pub fn with_format_version(mut self, format_version: InvertedListFormatVersion) -> Self {
208        self.format_version = format_version;
209        self.posting_tail_codec = format_version.posting_tail_codec();
210        self
211    }
212
213    pub fn with_token_set_format(mut self, token_set_format: TokenSetFormat) -> Self {
214        self.token_set_format = token_set_format;
215        self
216    }
217
218    pub fn with_progress(mut self, progress: Arc<dyn IndexBuildProgress>) -> Self {
219        self.progress = progress;
220        self
221    }
222
223    pub async fn update(
224        &mut self,
225        new_data: SendableRecordBatchStream,
226        dest_store: &dyn IndexStore,
227        old_data_filter: Option<crate::scalar::OldIndexDataFilter>,
228    ) -> Result<Vec<IndexFile>> {
229        validate_format_version_block_size(self.format_version, self.params.block_size)?;
230        let schema = new_data.schema();
231        let doc_col = schema.field(0).name();
232
233        // infer lance_tokenizer based on document type
234        if self.params.lance_tokenizer.is_none() {
235            let schema = new_data.schema();
236            let field = schema.column_with_name(doc_col).expect_ok()?.1;
237            let doc_type = DocType::try_from(field)?;
238            self.params.lance_tokenizer = Some(doc_type.as_ref().to_string());
239        }
240
241        let new_data = document_input(new_data, doc_col)?;
242
243        self.progress
244            .stage_start("tokenize_docs", None, "rows")
245            .await?;
246        let mut files = self.update_index(new_data, dest_store).await?;
247
248        if let Some(OldIndexDataFilter::Fragments { to_remove, .. }) = old_data_filter {
249            self.deleted_fragments.extend(to_remove);
250        }
251
252        self.progress.stage_complete("tokenize_docs").await?;
253        files.extend(self.write(dest_store).await?);
254        Ok(files)
255    }
256
257    pub async fn update_from_segments(
258        &mut self,
259        new_data: SendableRecordBatchStream,
260        dest_store: &dyn IndexStore,
261        old_segments: &[Arc<InvertedIndex>],
262        old_data_filter: Option<crate::scalar::OldIndexDataFilter>,
263    ) -> Result<Vec<IndexFile>> {
264        validate_format_version_block_size(self.format_version, self.params.block_size)?;
265        let schema = new_data.schema();
266        let doc_col = schema.field(0).name();
267
268        if self.params.lance_tokenizer.is_none() {
269            let field = schema.column_with_name(doc_col).expect_ok()?.1;
270            let doc_type = DocType::try_from(field)?;
271            self.params.lance_tokenizer = Some(doc_type.as_ref().to_string());
272        }
273
274        let mut files = self
275            .merge_existing_segments(dest_store, old_segments, old_data_filter.as_ref())
276            .await?;
277
278        let new_data = document_input(new_data, doc_col)?;
279
280        self.progress
281            .stage_start("tokenize_docs", None, "rows")
282            .await?;
283        files.extend(self.update_index(new_data, dest_store).await?);
284        self.progress.stage_complete("tokenize_docs").await?;
285
286        files.extend(self.write(dest_store).await?);
287        Ok(files)
288    }
289
290    async fn merge_existing_segments(
291        &mut self,
292        dest_store: &dyn IndexStore,
293        old_segments: &[Arc<InvertedIndex>],
294        old_data_filter: Option<&crate::scalar::OldIndexDataFilter>,
295    ) -> Result<Vec<IndexFile>> {
296        let num_workers = resolve_num_workers(&self.params);
297        let memory_limit_bytes = resolve_worker_memory_limit_bytes(&self.params, num_workers);
298        let mut merged: Option<InnerBuilder> = None;
299        let mut files = Vec::new();
300        for index in old_segments {
301            if old_data_filter.is_none() {
302                self.deleted_fragments
303                    .extend(index.deleted_fragments().iter());
304            }
305            for partition in &index.partitions {
306                let mut partition_builder = partition.as_ref().clone().into_builder().await?;
307                if let Some(filter) = old_data_filter {
308                    partition_builder.filter_old_data(filter).await?;
309                }
310                if partition_builder.is_empty() {
311                    continue;
312                }
313                match &mut merged {
314                    Some(merged) => {
315                        let would_exceed_memory = merged
316                            .memory_size()
317                            .saturating_add(partition_builder.memory_size())
318                            >= memory_limit_bytes;
319                        let would_exceed_doc_ids = merged
320                            .docs
321                            .len()
322                            .saturating_add(partition_builder.docs.len())
323                            > u32::MAX as usize;
324                        if would_exceed_memory || would_exceed_doc_ids {
325                            let builder = std::mem::replace(merged, partition_builder);
326                            files.extend(self.write_new_partition(dest_store, builder).await?);
327                        } else {
328                            merged.merge_from(partition_builder)?;
329                        }
330                    }
331                    None => merged = Some(partition_builder),
332                }
333            }
334        }
335
336        if let Some(builder) = merged {
337            files.extend(self.write_new_partition(dest_store, builder).await?);
338        }
339        Ok(files)
340    }
341
342    async fn write_new_partition(
343        &mut self,
344        dest_store: &dyn IndexStore,
345        mut builder: InnerBuilder,
346    ) -> Result<Vec<IndexFile>> {
347        let partition_id = self.next_partition_id() | self.fragment_mask.unwrap_or(0);
348        builder.set_id(partition_id);
349        let files = builder
350            .write_to(dest_store, self.partition_write_target())
351            .await?;
352        self.new_partitions.push(partition_id);
353        Ok(files)
354    }
355
356    fn partition_write_target(&self) -> PartitionWriteTarget {
357        if self.fragment_mask.is_some() {
358            PartitionWriteTarget::Staged
359        } else {
360            PartitionWriteTarget::Final
361        }
362    }
363
364    fn next_partition_id(&self) -> u64 {
365        self.partitions
366            .iter()
367            .chain(self.new_partitions.iter())
368            .map(|id| id + 1)
369            .max()
370            .unwrap_or(0)
371    }
372
373    #[instrument(level = "debug", skip_all)]
374    async fn update_index(
375        &mut self,
376        stream: SendableRecordBatchStream,
377        dest_store: &dyn IndexStore,
378    ) -> Result<Vec<IndexFile>> {
379        let num_workers = resolve_num_workers(&self.params);
380        let tokenizer = self.params.build()?;
381        let with_position = self.params.with_position;
382        let worker_memory_limit_bytes =
383            resolve_worker_memory_limit_bytes(&self.params, num_workers);
384        let worker_config = IndexWorkerConfig {
385            with_position,
386            format_version: self.format_version,
387            fragment_mask: self.fragment_mask,
388            token_set_format: self.token_set_format,
389            worker_memory_limit_bytes,
390            block_size: self.params.block_size,
391        };
392        let next_id = self.next_partition_id();
393        let id_alloc = Arc::new(AtomicU64::new(next_id));
394        let tokenized_count = Arc::new(AtomicU64::new(0));
395        let (sender, receiver) = async_channel::bounded(num_workers);
396        let dest_store = dest_store.clone_arc();
397        let mut index_tasks = Vec::with_capacity(num_workers);
398        for _ in 0..num_workers {
399            let tokenizer = tokenizer.clone();
400            let receiver: async_channel::Receiver<RecordBatch> = receiver.clone();
401            let dest_store = dest_store.clone();
402            let id_alloc = id_alloc.clone();
403            let progress = self.progress.clone();
404            let tokenized_count = tokenized_count.clone();
405            index_tasks.push(tokio::task::spawn(async move {
406                let mut worker =
407                    IndexWorker::new(tokenizer, dest_store, id_alloc, worker_config).await?;
408                while let Ok(batch) = receiver.recv().await {
409                    let num_rows = batch.num_rows();
410                    worker.process_batch(batch).await?;
411                    let tokenized_count = tokenized_count
412                        .fetch_add(num_rows as u64, std::sync::atomic::Ordering::Relaxed)
413                        + num_rows as u64;
414                    progress
415                        .stage_progress("tokenize_docs", tokenized_count)
416                        .await?;
417                }
418                worker.finish().await
419            }));
420        }
421
422        let index_build = async {
423            // Keep the channel lifetime tied to the worker tasks so senders observe
424            // worker exits instead of blocking on an orphaned receiver handle.
425            drop(receiver);
426
427            let mut stream = Box::pin(stream);
428            log::info!("indexing FTS with {} workers", num_workers);
429
430            let mut last_num_rows = 0;
431            let mut total_num_rows = 0;
432            let start = std::time::Instant::now();
433            while let Some(batch) = stream.try_next().await? {
434                let num_rows = batch.num_rows();
435
436                if sender.send(batch).await.is_err() {
437                    // this only happens if all workers have exited,
438                    // so we don't return the send error here,
439                    // avoiding hiding the real error from workers.
440                    break;
441                }
442
443                total_num_rows += num_rows;
444                if total_num_rows >= last_num_rows + 1_000_000 {
445                    log::debug!(
446                        "indexed {} documents, elapsed: {:?}, speed: {}rows/s",
447                        total_num_rows,
448                        start.elapsed(),
449                        total_num_rows as f32 / start.elapsed().as_secs_f32()
450                    );
451                    last_num_rows = total_num_rows;
452                }
453            }
454            // drop the sender to stop receivers
455            drop(stream);
456            drop(sender);
457            log::info!("dispatching elapsed: {:?}", start.elapsed());
458
459            // wait for the workers to finish
460            let start = std::time::Instant::now();
461            let mut tail_partitions = Vec::new();
462            let mut files = Vec::new();
463            for index_task in index_tasks {
464                let output = index_task.await??;
465                self.new_partitions.extend(output.partitions);
466                files.extend(output.files);
467                if let Some(tail_partition) = output.tail_partition {
468                    tail_partitions.push(tail_partition);
469                }
470            }
471            let merged_tail_partitions = spawn_cpu(move || {
472                merge_all_tail_partitions(tail_partitions, worker_memory_limit_bytes)
473            })
474            .await?;
475            // Tail partitions hold most of the data when workers rarely hit the
476            // flush threshold; writing them one at a time serializes the
477            // posting-list compression of nearly the whole index behind a
478            // single producer thread. Compress and write them concurrently.
479            let write_target = self.partition_write_target();
480            let mut tail_writes =
481                futures::stream::iter(merged_tail_partitions.into_iter().map(|mut builder| {
482                    let dest_store = dest_store.clone();
483                    async move {
484                        let partition_id = builder.id();
485                        let files = builder.write_to(dest_store.as_ref(), write_target).await?;
486                        Result::Ok((partition_id, files))
487                    }
488                }))
489                .buffer_unordered(get_num_compute_intensive_cpus().clamp(1, 16));
490            while let Some((partition_id, partition_files)) = tail_writes.try_next().await? {
491                self.new_partitions.push(partition_id);
492                files.extend(partition_files);
493            }
494            log::info!("wait workers indexing elapsed: {:?}", start.elapsed());
495            Result::Ok(files)
496        };
497
498        index_build.await
499    }
500
501    pub async fn remap(
502        &mut self,
503        mapping: &RowAddrRemap,
504        src_store: Arc<dyn IndexStore>,
505        dest_store: &dyn IndexStore,
506    ) -> Result<Vec<IndexFile>> {
507        let mut files = Vec::new();
508        for part in self.partitions.iter() {
509            let part = InvertedPartition::load(
510                src_store.clone(),
511                *part,
512                None,
513                &LanceCache::no_cache(),
514                self.token_set_format,
515            )
516            .await?;
517            let mut builder = part.into_builder().await?;
518            builder.remap(mapping).await?;
519            files.extend(
520                builder
521                    .write_to(dest_store, self.partition_write_target())
522                    .await?,
523            );
524        }
525        if self.fragment_mask.is_none() {
526            files.push(self.write_metadata(dest_store, &self.partitions).await?);
527        } else {
528            // in distributed mode, the staged partition metadata is written by the worker
529            for &partition_id in &self.partitions {
530                files.push(self.write_part_metadata(dest_store, partition_id).await?);
531            }
532        }
533        Ok(files)
534    }
535
536    async fn write_metadata(
537        &self,
538        dest_store: &dyn IndexStore,
539        partitions: &[u64],
540    ) -> Result<IndexFile> {
541        validate_format_version_block_size(self.format_version, self.params.block_size)?;
542        let mut serialized_deleted_fragments =
543            Vec::with_capacity(self.deleted_fragments.serialized_size());
544        self.deleted_fragments
545            .serialize_into(&mut serialized_deleted_fragments)?;
546
547        let mut metadata = HashMap::from_iter(vec![
548            ("partitions".to_owned(), serde_json::to_string(&partitions)?),
549            ("params".to_owned(), serde_json::to_string(&self.params)?),
550            (
551                TOKEN_SET_FORMAT_KEY.to_owned(),
552                self.token_set_format.to_string(),
553            ),
554            (
555                POSTING_TAIL_CODEC_KEY.to_owned(),
556                self.posting_tail_codec.as_str().to_owned(),
557            ),
558            (
559                FTS_FORMAT_VERSION_KEY.to_owned(),
560                self.format_version.index_version().to_string(),
561            ),
562            (
563                POSTING_BLOCK_SIZE_KEY.to_owned(),
564                self.params.block_size.to_string(),
565            ),
566        ]);
567
568        if self.params.with_position && self.format_version.uses_shared_position_stream() {
569            metadata.insert(
570                POSITIONS_LAYOUT_KEY.to_owned(),
571                POSITIONS_LAYOUT_SHARED_STREAM_V2.to_owned(),
572            );
573            metadata.insert(
574                POSITIONS_CODEC_KEY.to_owned(),
575                self.format_version
576                    .position_codec()
577                    .expect("shared positions require a codec")
578                    .as_str()
579                    .to_owned(),
580            );
581        }
582
583        let metadata_file_schema = Arc::new(Schema::new(vec![Field::new(
584            DELETED_FRAGMENTS_COL,
585            DataType::Binary,
586            false,
587        )]));
588        let deleted_fragments_col = Arc::new(BinaryArray::from(vec![
589            serialized_deleted_fragments.as_slice(),
590        ])) as Arc<dyn Array>;
591        let record_batch =
592            RecordBatch::try_new(metadata_file_schema.clone(), vec![deleted_fragments_col])?;
593
594        let mut writer = dest_store
595            .new_index_file(METADATA_FILE, metadata_file_schema)
596            .await?;
597        writer.write_record_batch(record_batch).await?;
598        writer.finish_with_metadata(metadata).await
599    }
600
601    /// Write partition metadata file for a single partition
602    ///
603    /// In a distributed environment, each worker node can write partition metadata files for the partitions it processes,
604    /// which are then merged into a final metadata file using the `merge_metadata_files` function.
605    pub(crate) async fn write_part_metadata(
606        &self,
607        dest_store: &dyn IndexStore,
608        partition: u64, // Modify parameter type
609    ) -> Result<IndexFile> {
610        validate_format_version_block_size(self.format_version, self.params.block_size)?;
611        let partitions = vec![partition];
612        let mut metadata = HashMap::from_iter(vec![
613            ("partitions".to_owned(), serde_json::to_string(&partitions)?),
614            ("params".to_owned(), serde_json::to_string(&self.params)?),
615            (
616                TOKEN_SET_FORMAT_KEY.to_owned(),
617                self.token_set_format.to_string(),
618            ),
619            (
620                POSTING_TAIL_CODEC_KEY.to_owned(),
621                self.posting_tail_codec.as_str().to_owned(),
622            ),
623            (
624                FTS_FORMAT_VERSION_KEY.to_owned(),
625                self.format_version.index_version().to_string(),
626            ),
627            (
628                POSTING_BLOCK_SIZE_KEY.to_owned(),
629                self.params.block_size.to_string(),
630            ),
631        ]);
632        if self.params.with_position && self.format_version.uses_shared_position_stream() {
633            metadata.insert(
634                POSITIONS_LAYOUT_KEY.to_owned(),
635                POSITIONS_LAYOUT_SHARED_STREAM_V2.to_owned(),
636            );
637            metadata.insert(
638                POSITIONS_CODEC_KEY.to_owned(),
639                self.format_version
640                    .position_codec()
641                    .expect("shared positions require a codec")
642                    .as_str()
643                    .to_owned(),
644            );
645        }
646        // Use partition ID to generate a unique temporary filename
647        let file_name = part_metadata_file_path(partition);
648        let mut writer = dest_store
649            .new_index_file(&file_name, Arc::new(Schema::empty()))
650            .await?;
651        writer.finish_with_metadata(metadata).await
652    }
653
654    async fn write_metadata_with_progress(
655        &self,
656        dest_store: &dyn IndexStore,
657        partitions: &[u64],
658    ) -> Result<Vec<IndexFile>> {
659        let total = if self.fragment_mask.is_none() {
660            Some(1)
661        } else {
662            Some(partitions.len() as u64)
663        };
664        let mut files = Vec::new();
665        self.progress
666            .stage_start("write_metadata", total, "files")
667            .await?;
668        if self.fragment_mask.is_none() {
669            files.push(self.write_metadata(dest_store, partitions).await?);
670            self.progress.stage_progress("write_metadata", 1).await?;
671        } else {
672            let mut completed = 0;
673            for &partition_id in partitions {
674                files.push(self.write_part_metadata(dest_store, partition_id).await?);
675                completed += 1;
676                self.progress
677                    .stage_progress("write_metadata", completed)
678                    .await?;
679            }
680        }
681        self.progress.stage_complete("write_metadata").await?;
682        Ok(files)
683    }
684
685    async fn write(&self, dest_store: &dyn IndexStore) -> Result<Vec<IndexFile>> {
686        let mut partitions = Vec::with_capacity(self.partitions.len() + self.new_partitions.len());
687        partitions.extend_from_slice(&self.partitions);
688        partitions.extend_from_slice(&self.new_partitions);
689        partitions.sort_unstable();
690
691        self.progress
692            .stage_start(
693                "copy_partitions",
694                Some(partitions.len() as u64),
695                "partitions",
696            )
697            .await?;
698        let mut copied = 0;
699        let mut files = Vec::new();
700        let target = self.partition_write_target();
701        for part in self.partitions.iter() {
702            files.push(
703                self.src_store
704                    .as_ref()
705                    .expect("existing partitions require a source store")
706                    .copy_index_file_to(
707                        &token_file_path(*part),
708                        &target.token_path(*part),
709                        dest_store,
710                    )
711                    .await?,
712            );
713            files.push(
714                self.src_store
715                    .as_ref()
716                    .expect("existing partitions require a source store")
717                    .copy_index_file_to(
718                        &posting_file_path(*part),
719                        &target.posting_path(*part),
720                        dest_store,
721                    )
722                    .await?,
723            );
724            files.push(
725                self.src_store
726                    .as_ref()
727                    .expect("existing partitions require a source store")
728                    .copy_index_file_to(&doc_file_path(*part), &target.doc_path(*part), dest_store)
729                    .await?,
730            );
731            copied += 1;
732            self.progress
733                .stage_progress("copy_partitions", copied)
734                .await?;
735        }
736        for _part in self.new_partitions.iter() {
737            copied += 1;
738            self.progress
739                .stage_progress("copy_partitions", copied)
740                .await?;
741        }
742        self.progress.stage_complete("copy_partitions").await?;
743
744        files.extend(
745            self.write_metadata_with_progress(dest_store, &partitions)
746                .await?,
747        );
748        Ok(files)
749    }
750}
751
752impl Default for InvertedIndexBuilder {
753    fn default() -> Self {
754        let params = InvertedIndexParams::default();
755        Self::new(params)
756    }
757}
758
759// builder for single partition
760#[derive(Debug)]
761pub struct InnerBuilder {
762    id: u64,
763    with_position: bool,
764    token_set_format: TokenSetFormat,
765    format_version: InvertedListFormatVersion,
766    posting_tail_codec: PostingTailCodec,
767    block_size: usize,
768    pub(crate) tokens: TokenSet,
769    pub(crate) posting_lists: Vec<PostingListBuilder>,
770    pub(crate) docs: DocSet,
771}
772
773impl InnerBuilder {
774    pub fn new(id: u64, with_position: bool, token_set_format: TokenSetFormat) -> Self {
775        Self::new_with_format_version(
776            id,
777            with_position,
778            token_set_format,
779            current_fts_format_version(),
780        )
781    }
782
783    pub fn new_with_format_version(
784        id: u64,
785        with_position: bool,
786        token_set_format: TokenSetFormat,
787        format_version: InvertedListFormatVersion,
788    ) -> Self {
789        Self::new_with_format_version_and_block_size(
790            id,
791            with_position,
792            token_set_format,
793            format_version,
794            LEGACY_BLOCK_SIZE,
795        )
796    }
797
798    pub fn new_with_block_size(
799        id: u64,
800        with_position: bool,
801        token_set_format: TokenSetFormat,
802        block_size: usize,
803    ) -> Self {
804        let format_version = default_fts_format_version_for_block_size(block_size)
805            .expect("invalid posting list block size");
806        Self::new_with_format_version_and_block_size(
807            id,
808            with_position,
809            token_set_format,
810            format_version,
811            block_size,
812        )
813    }
814
815    pub fn new_with_format_version_and_block_size(
816        id: u64,
817        with_position: bool,
818        token_set_format: TokenSetFormat,
819        format_version: InvertedListFormatVersion,
820        block_size: usize,
821    ) -> Self {
822        validate_format_version_block_size(format_version, block_size)
823            .expect("invalid FTS format version for posting block size");
824        Self {
825            id,
826            with_position,
827            token_set_format,
828            format_version,
829            posting_tail_codec: format_version.posting_tail_codec(),
830            block_size,
831            tokens: TokenSet::default(),
832            posting_lists: Vec::new(),
833            docs: DocSet::default(),
834        }
835    }
836
837    pub fn new_with_posting_tail_codec(
838        id: u64,
839        with_position: bool,
840        token_set_format: TokenSetFormat,
841        posting_tail_codec: PostingTailCodec,
842    ) -> Self {
843        Self::new_with_posting_tail_codec_and_block_size(
844            id,
845            with_position,
846            token_set_format,
847            posting_tail_codec,
848            LEGACY_BLOCK_SIZE,
849        )
850    }
851
852    pub fn new_with_posting_tail_codec_and_block_size(
853        id: u64,
854        with_position: bool,
855        token_set_format: TokenSetFormat,
856        posting_tail_codec: PostingTailCodec,
857        block_size: usize,
858    ) -> Self {
859        let format_version = InvertedListFormatVersion::from_posting_tail_codec_and_block_size(
860            posting_tail_codec,
861            block_size,
862        )
863        .expect("invalid posting tail codec for posting block size");
864        let mut builder = Self::new_with_format_version_and_block_size(
865            id,
866            with_position,
867            token_set_format,
868            format_version,
869            block_size,
870        );
871        builder.posting_tail_codec = posting_tail_codec;
872        builder
873    }
874
875    pub fn id(&self) -> u64 {
876        self.id
877    }
878
879    fn set_id(&mut self, id: u64) {
880        self.id = id;
881    }
882
883    pub fn is_empty(&self) -> bool {
884        self.docs.is_empty()
885    }
886
887    /// Set the token set for this builder.
888    pub fn set_tokens(&mut self, tokens: TokenSet) {
889        self.tokens = tokens;
890    }
891
892    /// Set the document set for this builder.
893    pub fn set_docs(&mut self, docs: DocSet) {
894        self.docs = docs;
895    }
896
897    /// Set the posting lists for this builder.
898    pub fn set_posting_lists(&mut self, posting_lists: Vec<PostingListBuilder>) {
899        self.posting_lists = posting_lists;
900    }
901
902    pub async fn remap(&mut self, mapping: &RowAddrRemap) -> Result<()> {
903        // for the docs, we need to remove the rows that are removed from the doc set,
904        // and update the row ids of the rows that are updated
905        let removed = self.docs.remap(mapping);
906
907        // for the posting lists, we need to remap the doc ids:
908        // - if the a row is removed, we need to shift the doc ids of the following rows
909        // - if a row is updated (assigned a new row id), we don't need to do anything with the posting lists
910        let mut token_id = 0;
911        let mut removed_token_ids = Vec::new();
912        self.posting_lists.retain_mut(|posting_list| {
913            posting_list.remap(&removed);
914            let keep = !posting_list.is_empty();
915            if !keep {
916                removed_token_ids.push(token_id as u32);
917            }
918            token_id += 1;
919            keep
920        });
921
922        // for the tokens, remap the token ids if any posting list is empty
923        self.tokens.remap(&removed_token_ids);
924
925        Ok(())
926    }
927
928    async fn filter_old_data(&mut self, filter: &OldIndexDataFilter) -> Result<()> {
929        let mut mapping = HashMap::new();
930        for (row_id, _) in self.docs.iter() {
931            let keep = match filter {
932                OldIndexDataFilter::Fragments { to_keep, .. } => {
933                    to_keep.contains((*row_id >> 32) as u32)
934                }
935                OldIndexDataFilter::RowIds(valid_row_ids) => valid_row_ids.contains(*row_id),
936            };
937            if !keep {
938                mapping.insert(*row_id, None);
939            }
940        }
941        self.remap(&RowAddrRemap::direct(mapping)).await
942    }
943
944    pub fn merge_from(&mut self, other: Self) -> Result<()> {
945        let Self {
946            id: _,
947            with_position,
948            token_set_format,
949            format_version,
950            posting_tail_codec,
951            block_size,
952            tokens,
953            posting_lists,
954            docs,
955        } = other;
956
957        if self.with_position != with_position {
958            return Err(Error::index(format!(
959                "cannot merge partitions with mismatched positions settings: {} vs {}",
960                self.with_position, with_position
961            )));
962        }
963        if self.token_set_format != token_set_format {
964            return Err(Error::index(format!(
965                "cannot merge partitions with mismatched token set formats: {:?} vs {:?}",
966                self.token_set_format, token_set_format
967            )));
968        }
969        if self.format_version != format_version {
970            return Err(Error::index(format!(
971                "cannot merge partitions with mismatched FTS format versions: {:?} vs {:?}",
972                self.format_version, format_version
973            )));
974        }
975        if self.posting_tail_codec != posting_tail_codec {
976            return Err(Error::index(format!(
977                "cannot merge partitions with mismatched posting tail codecs: {:?} vs {:?}",
978                self.posting_tail_codec, posting_tail_codec
979            )));
980        }
981        if self.block_size != block_size {
982            return Err(Error::index(format!(
983                "cannot merge partitions with mismatched FTS block sizes: {} vs {}",
984                self.block_size, block_size
985            )));
986        }
987
988        let mut token_id_map = vec![u32::MAX; posting_lists.len()];
989        match tokens.tokens {
990            TokenMap::HashMap(map) => {
991                for (token, token_id) in map {
992                    let new_token_id = self.tokens.get_or_add(token.as_str());
993                    token_id_map[token_id as usize] = new_token_id;
994                }
995            }
996            TokenMap::Fst(map) => {
997                let mut stream = map.stream();
998                while let Some((token, token_id)) = stream.next() {
999                    let new_token_id = self
1000                        .tokens
1001                        .get_or_add(String::from_utf8_lossy(token).as_ref());
1002                    token_id_map[token_id as usize] = new_token_id;
1003                }
1004            }
1005        }
1006
1007        let doc_id_offset = self.docs.len() as u32;
1008        for (row_id, num_tokens) in docs.iter() {
1009            self.docs.append(*row_id, *num_tokens);
1010        }
1011        self.posting_lists.resize_with(self.tokens.len(), || {
1012            PostingListBuilder::new_with_posting_tail_codec_and_block_size(
1013                with_position,
1014                self.posting_tail_codec,
1015                self.block_size,
1016            )
1017        });
1018
1019        for (token_id, posting_list) in posting_lists.into_iter().enumerate() {
1020            if posting_list.is_empty() {
1021                continue;
1022            }
1023            let new_token_id = token_id_map[token_id];
1024            debug_assert_ne!(new_token_id, u32::MAX);
1025            let merged_posting = &mut self.posting_lists[new_token_id as usize];
1026            posting_list.for_each_entry(|doc_id, freq, positions| {
1027                let positions = match positions {
1028                    Some(positions) => PositionRecorder::Position(positions.into()),
1029                    None => PositionRecorder::Count(freq),
1030                };
1031                merged_posting.add(doc_id_offset + doc_id, positions);
1032                Ok::<(), Error>(())
1033            })?;
1034        }
1035
1036        Ok(())
1037    }
1038
1039    fn memory_size(&self) -> u64 {
1040        let posting_lists_overhead =
1041            self.posting_lists.capacity() * std::mem::size_of::<PostingListBuilder>();
1042        let posting_lists_size: u64 = self
1043            .posting_lists
1044            .iter()
1045            .map(|posting| posting.size())
1046            .sum();
1047        (self.tokens.memory_size() + self.docs.memory_size() + posting_lists_overhead) as u64
1048            + posting_lists_size
1049    }
1050
1051    pub async fn write(&mut self, store: &dyn IndexStore) -> Result<Vec<IndexFile>> {
1052        self.write_to(store, PartitionWriteTarget::Final).await
1053    }
1054
1055    async fn write_to(
1056        &mut self,
1057        store: &dyn IndexStore,
1058        target: PartitionWriteTarget,
1059    ) -> Result<Vec<IndexFile>> {
1060        let docs = Arc::new(std::mem::take(&mut self.docs));
1061        let files = vec![
1062            self.write_posting_lists(store, docs.clone(), &target.posting_path(self.id))
1063                .await?,
1064            self.write_tokens(store, &target.token_path(self.id))
1065                .await?,
1066            self.write_docs(store, docs, &target.doc_path(self.id))
1067                .await?,
1068        ];
1069        Ok(files)
1070    }
1071
1072    #[instrument(level = "debug", skip_all)]
1073    async fn write_posting_lists(
1074        &mut self,
1075        store: &dyn IndexStore,
1076        docs: Arc<DocSet>,
1077        path: &str,
1078    ) -> Result<IndexFile> {
1079        let id = self.id;
1080        let mut writer = store
1081            .new_index_file(
1082                path,
1083                inverted_list_schema_for_version_with_block_size(
1084                    self.with_position,
1085                    self.format_version,
1086                    self.block_size,
1087                ),
1088            )
1089            .await?;
1090        let posting_lists = std::mem::take(&mut self.posting_lists);
1091
1092        log::info!(
1093            "writing {} posting lists of partition {}, with position {}",
1094            posting_lists.len(),
1095            id,
1096            self.with_position
1097        );
1098        let with_position = self.with_position;
1099        let format_version = self.format_version;
1100        let schema = inverted_list_schema_for_version_with_block_size(
1101            self.with_position,
1102            self.format_version,
1103            self.block_size,
1104        );
1105        let docs_for_batches = docs.clone();
1106        let schema_for_batches = schema.clone();
1107        let batch_rows = *LANCE_FTS_POSTING_BATCH_ROWS;
1108        let (tx, rx) = async_channel::bounded(*LANCE_FTS_WRITE_QUEUE_SIZE);
1109        // The producer builds posting-list batches on the CPU pool and hands them
1110        // to the writer through a bounded channel. Each batch is built inside its
1111        // own `spawn_cpu` call and dispatched with an async `send().await` instead
1112        // of a blocking send: when the channel is full the producer *yields* its
1113        // task rather than parking the pool thread it is running on. Parking would
1114        // deadlock on hosts whose CPU pool has a single thread: once the consumer
1115        // accumulates enough data to flush an encoded column, the flush also needs
1116        // that pool. The parked producer and the starved consumer would then wait
1117        // on each other forever.
1118        let producer = tokio::spawn(async move {
1119            let mut batch_builder = PostingListBatchBuilder::new(
1120                schema_for_batches,
1121                with_position,
1122                format_version,
1123                batch_rows,
1124            );
1125            let mut posting_lists = posting_lists.into_iter();
1126            loop {
1127                let docs_for_batches = docs_for_batches.clone();
1128                // Build the next batch on the CPU pool. The builder and the
1129                // remaining posting lists are moved in and handed back so state
1130                // persists across batches.
1131                let (next_builder, next_posting_lists, batch) = spawn_cpu(move || {
1132                    let mut batch_builder = batch_builder;
1133                    let mut posting_lists = posting_lists;
1134                    let mut batch = None;
1135                    for posting_list in posting_lists.by_ref() {
1136                        posting_list.append_to_batch_with_docs(
1137                            &docs_for_batches,
1138                            &mut batch_builder,
1139                            format_version,
1140                        )?;
1141                        if batch_builder.len() >= batch_rows {
1142                            batch = Some(batch_builder.finish()?);
1143                            break;
1144                        }
1145                    }
1146                    if batch.is_none() && !batch_builder.is_empty() {
1147                        batch = Some(batch_builder.finish()?);
1148                    }
1149                    Result::Ok((batch_builder, posting_lists, batch))
1150                })
1151                .await?;
1152                batch_builder = next_builder;
1153                posting_lists = next_posting_lists;
1154
1155                let Some(batch) = batch else {
1156                    // No more batches: the posting lists are exhausted.
1157                    break;
1158                };
1159                if tx.send(batch).await.is_err() {
1160                    // The receiver is gone, which only happens when the writer
1161                    // failed; stop producing and let that error surface there.
1162                    break;
1163                }
1164            }
1165
1166            Result::Ok(())
1167        });
1168
1169        while let Ok(batch) = rx.recv().await {
1170            if let Err(err) = writer.write_record_batch(batch).await {
1171                drop(rx);
1172                // Wait for producer to stop; preserve the write error as the primary failure.
1173                let _ = producer.await;
1174                return Err(err);
1175            }
1176        }
1177        drop(rx);
1178        producer.await??;
1179        writer.finish().await
1180    }
1181
1182    #[instrument(level = "debug", skip_all)]
1183    async fn write_tokens(&mut self, store: &dyn IndexStore, path: &str) -> Result<IndexFile> {
1184        log::info!("writing tokens of partition {}", self.id);
1185        let tokens = std::mem::take(&mut self.tokens);
1186        let batch = tokens.to_batch(self.token_set_format)?;
1187        let mut writer = store.new_index_file(path, batch.schema()).await?;
1188        writer.write_record_batch(batch).await?;
1189        writer.finish().await
1190    }
1191
1192    #[instrument(level = "debug", skip_all)]
1193    async fn write_docs(
1194        &mut self,
1195        store: &dyn IndexStore,
1196        docs: Arc<DocSet>,
1197        path: &str,
1198    ) -> Result<IndexFile> {
1199        log::info!("writing docs of partition {}", self.id);
1200        let batch = docs.to_batch()?;
1201        let mut writer = store.new_index_file(path, batch.schema()).await?;
1202        writer.write_record_batch(batch).await?;
1203        writer
1204            .finish_with_metadata(HashMap::from([(
1205                super::documents::TOTAL_TOKENS_KEY.to_owned(),
1206                docs.total_tokens_num().to_string(),
1207            )]))
1208            .await
1209    }
1210}
1211
1212#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1213enum PartitionWriteTarget {
1214    Final,
1215    Staged,
1216}
1217
1218impl PartitionWriteTarget {
1219    fn file_path(self, partition_id: u64, suffix: &str) -> String {
1220        match self {
1221            Self::Final => partition_file_path(partition_id, suffix),
1222            Self::Staged => staged_partition_file_path(partition_id, suffix),
1223        }
1224    }
1225
1226    fn token_path(self, partition_id: u64) -> String {
1227        self.file_path(partition_id, TOKENS_FILE)
1228    }
1229
1230    fn posting_path(self, partition_id: u64) -> String {
1231        self.file_path(partition_id, INVERT_LIST_FILE)
1232    }
1233
1234    fn doc_path(self, partition_id: u64) -> String {
1235        self.file_path(partition_id, DOCS_FILE)
1236    }
1237}
1238
1239struct IndexWorker {
1240    tokenizer: Box<dyn LanceTokenizer>,
1241    dest_store: Arc<dyn IndexStore>,
1242    id_alloc: Arc<AtomicU64>,
1243    builder: InnerBuilder,
1244    partitions: Vec<u64>,
1245    files: Vec<IndexFile>,
1246    schema: SchemaRef,
1247    memory_size: u64,
1248    worker_memory_limit_bytes: u64,
1249    total_doc_length: usize,
1250    fragment_mask: Option<u64>,
1251    token_set_format: TokenSetFormat,
1252    token_ids: Vec<u32>,
1253    last_token_count: usize,
1254}
1255
1256struct TailPartition {
1257    builder: InnerBuilder,
1258}
1259
1260struct WorkerOutput {
1261    partitions: Vec<u64>,
1262    files: Vec<IndexFile>,
1263    tail_partition: Option<TailPartition>,
1264}
1265
1266enum DocumentSource<'a> {
1267    Text(&'a str),
1268    StringList(&'a dyn Array),
1269}
1270
1271#[derive(Debug, Clone, Copy)]
1272struct IndexWorkerConfig {
1273    with_position: bool,
1274    format_version: InvertedListFormatVersion,
1275    fragment_mask: Option<u64>,
1276    token_set_format: TokenSetFormat,
1277    worker_memory_limit_bytes: u64,
1278    block_size: usize,
1279}
1280
1281impl IndexWorker {
1282    fn posting_lists_overhead_size(&self) -> u64 {
1283        (self.builder.posting_lists.capacity() * std::mem::size_of::<PostingListBuilder>()) as u64
1284    }
1285
1286    fn adjust_tracked_value(tracked: &mut u64, old: u64, new: u64) {
1287        if new >= old {
1288            *tracked += new - old;
1289        } else {
1290            *tracked -= old - new;
1291        }
1292    }
1293
1294    fn adjust_tracked_memory_size(&mut self, old_memory_size: u64, new_memory_size: u64) {
1295        Self::adjust_tracked_value(&mut self.memory_size, old_memory_size, new_memory_size);
1296    }
1297
1298    fn apply_delta(total: &mut u64, delta: i64) {
1299        if delta >= 0 {
1300            *total += delta as u64;
1301        } else {
1302            *total -= (-delta) as u64;
1303        }
1304    }
1305
1306    fn temporary_memory_size(&self) -> u64 {
1307        (self.token_ids.capacity() * std::mem::size_of::<u32>()) as u64
1308    }
1309
1310    fn trim_temporary_buffers(&mut self) {
1311        if self.token_ids.capacity() > MAX_RETAINED_TOKEN_IDS {
1312            self.token_ids = Vec::with_capacity(self.last_token_count.min(MAX_RETAINED_TOKEN_IDS));
1313        }
1314    }
1315
1316    async fn new(
1317        tokenizer: Box<dyn LanceTokenizer>,
1318        dest_store: Arc<dyn IndexStore>,
1319        id_alloc: Arc<AtomicU64>,
1320        config: IndexWorkerConfig,
1321    ) -> Result<Self> {
1322        let schema = inverted_list_schema_for_version_with_block_size(
1323            config.with_position,
1324            config.format_version,
1325            config.block_size,
1326        );
1327
1328        Ok(Self {
1329            tokenizer,
1330            dest_store,
1331            builder: InnerBuilder::new_with_format_version_and_block_size(
1332                id_alloc.fetch_add(1, std::sync::atomic::Ordering::Relaxed)
1333                    | config.fragment_mask.unwrap_or(0),
1334                config.with_position,
1335                config.token_set_format,
1336                config.format_version,
1337                config.block_size,
1338            ),
1339            partitions: Vec::new(),
1340            files: Vec::new(),
1341            id_alloc,
1342            schema,
1343            memory_size: 0,
1344            worker_memory_limit_bytes: config.worker_memory_limit_bytes,
1345            total_doc_length: 0,
1346            fragment_mask: config.fragment_mask,
1347            token_set_format: config.token_set_format,
1348            token_ids: Vec::new(),
1349            last_token_count: 0,
1350        })
1351    }
1352
1353    fn has_position(&self) -> bool {
1354        self.schema
1355            .column_with_name(COMPRESSED_POSITION_COL)
1356            .is_some()
1357            || self.schema.column_with_name(POSITION_COL).is_some()
1358    }
1359
1360    async fn process_batch(&mut self, batch: RecordBatch) -> Result<()> {
1361        let doc_col = batch.column(0);
1362        let row_id_col = batch[ROW_ID].as_primitive::<datatypes::UInt64Type>();
1363        match doc_col.data_type() {
1364            DataType::Utf8 | DataType::LargeUtf8 => {
1365                let docs = iter_str_array(doc_col.as_ref())
1366                    .zip(row_id_col.values().iter())
1367                    .filter_map(|(doc, row_id)| doc.map(|doc| (doc, *row_id)));
1368
1369                for (doc, row_id) in docs {
1370                    self.process_document(row_id, DocumentSource::Text(doc))
1371                        .await?;
1372                }
1373            }
1374            DataType::List(_) => {
1375                self.process_string_list_batch::<i32>(doc_col, row_id_col)
1376                    .await?;
1377            }
1378            DataType::LargeList(_) => {
1379                self.process_string_list_batch::<i64>(doc_col, row_id_col)
1380                    .await?;
1381            }
1382            data_type => {
1383                return Err(Error::index(format!(
1384                    "expect data type String, LargeString, List(String), or LargeList(String) but got {}",
1385                    data_type
1386                )));
1387            }
1388        }
1389
1390        Ok(())
1391    }
1392
1393    async fn process_string_list_batch<Offset: arrow::array::OffsetSizeTrait>(
1394        &mut self,
1395        doc_col: &Arc<dyn Array>,
1396        row_id_col: &arrow_array::PrimitiveArray<datatypes::UInt64Type>,
1397    ) -> Result<()> {
1398        let docs = doc_col.as_list::<Offset>();
1399        match docs.value_type() {
1400            datatypes::DataType::Utf8 | datatypes::DataType::LargeUtf8 => {}
1401            data_type => {
1402                return Err(Error::index(format!(
1403                    "expect list item data type String or LargeString but got {}",
1404                    data_type
1405                )));
1406            }
1407        }
1408
1409        for (doc, row_id) in docs.iter().zip(row_id_col.values().iter()) {
1410            let Some(doc) = doc else {
1411                continue;
1412            };
1413
1414            self.process_document(*row_id, DocumentSource::StringList(doc.as_ref()))
1415                .await?;
1416        }
1417
1418        Ok(())
1419    }
1420
1421    fn checked_token_position(row_id: u64, token_position: usize) -> Result<u32> {
1422        u32::try_from(token_position).map_err(|_| {
1423            Error::invalid_input(format!(
1424                "token position overflow for row_id={row_id}: token_position={token_position}"
1425            ))
1426        })
1427    }
1428
1429    fn materialize_string_list(elements: &dyn Array) -> String {
1430        let mut doc = String::new();
1431        for element in iter_str_array(elements).flatten() {
1432            if !doc.is_empty() {
1433                doc.push(' ');
1434            }
1435            doc.push_str(element);
1436        }
1437        doc
1438    }
1439
1440    async fn process_document(&mut self, row_id: u64, document: DocumentSource<'_>) -> Result<()> {
1441        let with_position = self.has_position();
1442        let builder_was_empty = self.builder.docs.is_empty();
1443        let old_temporary_memory_size = self.temporary_memory_size();
1444        let old_token_memory_size = self.builder.tokens.memory_size() as u64;
1445        let doc_id = self.builder.docs.len() as u32;
1446        let mut token_num: u32 = 0;
1447        let mut doc_length_bytes = 0usize;
1448        let mut posting_memory_delta = 0i64;
1449        if with_position {
1450            {
1451                if self.token_ids.capacity() < self.last_token_count {
1452                    self.token_ids
1453                        .reserve(self.last_token_count - self.token_ids.capacity());
1454                }
1455                self.token_ids.clear();
1456                let tokenizer = &mut self.tokenizer;
1457                let builder = &mut self.builder;
1458                let token_ids = &mut self.token_ids;
1459                let memory_size = &mut self.memory_size;
1460                let posting_tail_codec = builder.posting_tail_codec;
1461
1462                let block_size = builder.block_size;
1463                let mut process_text = |text: &str| -> Result<()> {
1464                    doc_length_bytes += text.len();
1465                    let mut token_stream = tokenizer.token_stream_for_doc(text);
1466                    while token_stream.advance() {
1467                        let token = token_stream.token();
1468                        let position = Self::checked_token_position(row_id, token.position)?;
1469                        let token_id = builder.tokens.get_or_add(&token.text);
1470                        if token_id as usize == builder.posting_lists.len() {
1471                            let old_posting_lists_overhead_size = (builder.posting_lists.capacity()
1472                                * std::mem::size_of::<PostingListBuilder>())
1473                                as u64;
1474                            builder.posting_lists.push(
1475                                PostingListBuilder::new_with_posting_tail_codec_and_block_size(
1476                                    true,
1477                                    posting_tail_codec,
1478                                    block_size,
1479                                ),
1480                            );
1481                            let new_posting_lists_overhead_size = (builder.posting_lists.capacity()
1482                                * std::mem::size_of::<PostingListBuilder>())
1483                                as u64;
1484                            Self::adjust_tracked_value(
1485                                memory_size,
1486                                old_posting_lists_overhead_size,
1487                                new_posting_lists_overhead_size,
1488                            );
1489                        }
1490                        let posting_list = &mut builder.posting_lists[token_id as usize];
1491                        let old_posting_memory_size = posting_list.size();
1492                        if posting_list.add_occurrence(doc_id, position)? {
1493                            token_ids.push(token_id);
1494                        }
1495                        let new_posting_memory_size = posting_list.size();
1496                        posting_memory_delta +=
1497                            new_posting_memory_size as i64 - old_posting_memory_size as i64;
1498                        token_num += 1;
1499                    }
1500                    Ok(())
1501                };
1502
1503                match document {
1504                    DocumentSource::Text(doc) => {
1505                        process_text(doc)?;
1506                    }
1507                    DocumentSource::StringList(elements) => {
1508                        let doc = Self::materialize_string_list(elements);
1509                        process_text(&doc)?;
1510                    }
1511                }
1512            }
1513        } else {
1514            {
1515                if self.token_ids.capacity() < self.last_token_count {
1516                    self.token_ids
1517                        .reserve(self.last_token_count - self.token_ids.capacity());
1518                }
1519                self.token_ids.clear();
1520
1521                let tokenizer = &mut self.tokenizer;
1522                let builder = &mut self.builder;
1523                let token_ids = &mut self.token_ids;
1524                let mut process_text = |text: &str| {
1525                    doc_length_bytes += text.len();
1526                    let mut token_stream = tokenizer.token_stream_for_doc(text);
1527                    while token_stream.advance() {
1528                        let token_id = builder.tokens.get_or_add(&token_stream.token().text);
1529                        token_ids.push(token_id);
1530                        token_num += 1;
1531                    }
1532                };
1533
1534                match document {
1535                    DocumentSource::Text(doc) => process_text(doc),
1536                    DocumentSource::StringList(elements) => {
1537                        let doc = Self::materialize_string_list(elements);
1538                        process_text(&doc);
1539                    }
1540                }
1541            }
1542        }
1543        self.adjust_tracked_memory_size(
1544            old_token_memory_size,
1545            self.builder.tokens.memory_size() as u64,
1546        );
1547
1548        if token_num == 0 {
1549            self.last_token_count = 0;
1550            self.trim_temporary_buffers();
1551            self.adjust_tracked_memory_size(
1552                old_temporary_memory_size,
1553                self.temporary_memory_size(),
1554            );
1555            return Ok(());
1556        }
1557
1558        if !with_position {
1559            let old_posting_lists_overhead_size = self.posting_lists_overhead_size();
1560            self.builder
1561                .posting_lists
1562                .resize_with(self.builder.tokens.len(), || {
1563                    PostingListBuilder::new_with_posting_tail_codec_and_block_size(
1564                        false,
1565                        self.builder.posting_tail_codec,
1566                        self.builder.block_size,
1567                    )
1568                });
1569            let new_posting_lists_overhead_size = self.posting_lists_overhead_size();
1570            Self::adjust_tracked_value(
1571                &mut self.memory_size,
1572                old_posting_lists_overhead_size,
1573                new_posting_lists_overhead_size,
1574            );
1575        }
1576
1577        let old_doc_memory_size = self.builder.docs.memory_size() as u64;
1578        let appended_doc_id = self.builder.docs.append(row_id, token_num);
1579        debug_assert_eq!(appended_doc_id, doc_id);
1580        self.adjust_tracked_memory_size(
1581            old_doc_memory_size,
1582            self.builder.docs.memory_size() as u64,
1583        );
1584        self.total_doc_length += doc_length_bytes;
1585
1586        if with_position {
1587            for &token_id in &self.token_ids {
1588                let (old_posting_memory_size, new_posting_memory_size) = {
1589                    let posting_list = &mut self.builder.posting_lists[token_id as usize];
1590                    let old_posting_memory_size = posting_list.size();
1591                    posting_list.finish_open_doc(doc_id)?;
1592                    let new_posting_memory_size = posting_list.size();
1593                    (old_posting_memory_size, new_posting_memory_size)
1594                };
1595                posting_memory_delta +=
1596                    new_posting_memory_size as i64 - old_posting_memory_size as i64;
1597            }
1598            Self::apply_delta(&mut self.memory_size, posting_memory_delta);
1599        } else {
1600            self.token_ids.sort_unstable();
1601            let mut iter = self.token_ids.iter();
1602            let mut current = *iter.next().unwrap();
1603            let mut count = 1u32;
1604            for &token_id in iter {
1605                if token_id == current {
1606                    count += 1;
1607                    continue;
1608                }
1609
1610                let (old_posting_memory_size, new_posting_memory_size) = {
1611                    let posting_list = &mut self.builder.posting_lists[current as usize];
1612                    let old_posting_memory_size = posting_list.size();
1613                    posting_list.add(doc_id, PositionRecorder::Count(count));
1614                    let new_posting_memory_size = posting_list.size();
1615                    (old_posting_memory_size, new_posting_memory_size)
1616                };
1617                posting_memory_delta +=
1618                    new_posting_memory_size as i64 - old_posting_memory_size as i64;
1619
1620                current = token_id;
1621                count = 1;
1622            }
1623            let (old_posting_memory_size, new_posting_memory_size) = {
1624                let posting_list = &mut self.builder.posting_lists[current as usize];
1625                let old_posting_memory_size = posting_list.size();
1626                posting_list.add(doc_id, PositionRecorder::Count(count));
1627                let new_posting_memory_size = posting_list.size();
1628                (old_posting_memory_size, new_posting_memory_size)
1629            };
1630            posting_memory_delta += new_posting_memory_size as i64 - old_posting_memory_size as i64;
1631            Self::apply_delta(&mut self.memory_size, posting_memory_delta);
1632        }
1633        self.last_token_count = self.token_ids.len();
1634        self.trim_temporary_buffers();
1635        self.adjust_tracked_memory_size(old_temporary_memory_size, self.temporary_memory_size());
1636
1637        if self.builder.docs.len() == 1 && self.memory_size > self.worker_memory_limit_bytes {
1638            return Err(Error::invalid_input(format!(
1639                "single document row_id={} exceeds worker memory limit: {} > {} bytes",
1640                row_id, self.memory_size, self.worker_memory_limit_bytes
1641            )));
1642        }
1643
1644        if self.builder.docs.len() as u32 == u32::MAX
1645            || (!builder_was_empty && self.memory_size >= self.worker_memory_limit_bytes)
1646        {
1647            self.flush().await?;
1648        }
1649
1650        Ok(())
1651    }
1652
1653    #[instrument(level = "debug", skip_all)]
1654    async fn flush(&mut self) -> Result<()> {
1655        if self.builder.tokens.is_empty() {
1656            return Ok(());
1657        }
1658
1659        log::info!(
1660            "flushing posting lists, memory size: {} MiB",
1661            self.memory_size / (1024 * 1024)
1662        );
1663        self.memory_size = self.temporary_memory_size();
1664        let with_position = self.has_position();
1665        let format_version = self.builder.format_version;
1666        let block_size = self.builder.block_size;
1667        let builder = std::mem::replace(
1668            &mut self.builder,
1669            InnerBuilder::new_with_format_version_and_block_size(
1670                self.id_alloc
1671                    .fetch_add(1, std::sync::atomic::Ordering::Relaxed)
1672                    | self.fragment_mask.unwrap_or(0),
1673                with_position,
1674                self.token_set_format,
1675                format_version,
1676                block_size,
1677            ),
1678        );
1679        let written_partition_id = builder.id();
1680        let mut builder = builder;
1681        let target = if self.fragment_mask.is_some() {
1682            PartitionWriteTarget::Staged
1683        } else {
1684            PartitionWriteTarget::Final
1685        };
1686        let files = builder
1687            .write_to(self.dest_store.as_ref(), target)
1688            .await
1689            .map_err(|err| {
1690                Error::execution(format!(
1691                    "failed to write finalized partition {}: {err}",
1692                    written_partition_id
1693                ))
1694            })?;
1695        self.files.extend(files);
1696        self.partitions.push(written_partition_id);
1697        Ok(())
1698    }
1699
1700    async fn finish(self) -> Result<WorkerOutput> {
1701        let tail_partition = if self.builder.tokens.is_empty() {
1702            None
1703        } else {
1704            Some(TailPartition {
1705                builder: self.builder,
1706            })
1707        };
1708        Ok(WorkerOutput {
1709            partitions: self.partitions,
1710            files: self.files,
1711            tail_partition,
1712        })
1713    }
1714}
1715
1716#[derive(Debug, Clone)]
1717pub enum PositionRecorder {
1718    Position(SmallVec<[u32; 2]>),
1719    Count(u32),
1720}
1721
1722impl PositionRecorder {
1723    pub fn len(&self) -> u32 {
1724        match self {
1725            Self::Position(positions) => positions.len() as u32,
1726            Self::Count(count) => *count,
1727        }
1728    }
1729
1730    pub fn is_empty(&self) -> bool {
1731        self.len() == 0
1732    }
1733
1734    pub fn into_vec(self) -> Vec<u32> {
1735        match self {
1736            Self::Position(positions) => positions.into_vec(),
1737            Self::Count(_) => vec![0],
1738        }
1739    }
1740}
1741
1742#[derive(Debug, Eq, PartialEq, Clone, DeepSizeOf)]
1743pub struct ScoredDoc {
1744    pub row_id: u64,
1745    pub score: OrderedFloat,
1746}
1747
1748impl ScoredDoc {
1749    pub fn new(row_id: u64, score: f32) -> Self {
1750        Self {
1751            row_id,
1752            score: OrderedFloat(score),
1753        }
1754    }
1755}
1756
1757impl PartialOrd for ScoredDoc {
1758    fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
1759        Some(self.cmp(other))
1760    }
1761}
1762
1763impl Ord for ScoredDoc {
1764    fn cmp(&self, other: &Self) -> std::cmp::Ordering {
1765        self.score.cmp(&other.score)
1766    }
1767}
1768
1769pub fn legacy_inverted_list_schema(with_position: bool) -> SchemaRef {
1770    let mut fields = vec![
1771        arrow_schema::Field::new(ROW_ID, arrow_schema::DataType::UInt64, false),
1772        arrow_schema::Field::new(FREQUENCY_COL, arrow_schema::DataType::Float32, false),
1773    ];
1774    if with_position {
1775        fields.push(arrow_schema::Field::new(
1776            POSITION_COL,
1777            arrow_schema::DataType::List(Arc::new(arrow_schema::Field::new(
1778                "item",
1779                arrow_schema::DataType::Int32,
1780                true,
1781            ))),
1782            false,
1783        ));
1784    }
1785    Arc::new(arrow_schema::Schema::new(fields))
1786}
1787
1788pub fn inverted_list_schema(with_position: bool) -> SchemaRef {
1789    inverted_list_schema_for_version(with_position, current_fts_format_version())
1790}
1791
1792pub fn inverted_list_schema_for_version(
1793    with_position: bool,
1794    format_version: InvertedListFormatVersion,
1795) -> SchemaRef {
1796    inverted_list_schema_for_version_with_block_size(
1797        with_position,
1798        format_version,
1799        LEGACY_BLOCK_SIZE,
1800    )
1801}
1802
1803pub fn inverted_list_schema_for_version_with_block_size(
1804    with_position: bool,
1805    format_version: InvertedListFormatVersion,
1806    block_size: usize,
1807) -> SchemaRef {
1808    inverted_list_schema_for_version_with_block_size_and_impacts(
1809        with_position,
1810        format_version,
1811        block_size,
1812        true,
1813    )
1814}
1815
1816pub(crate) fn inverted_list_schema_for_version_with_block_size_and_impacts(
1817    with_position: bool,
1818    format_version: InvertedListFormatVersion,
1819    block_size: usize,
1820    with_impacts: bool,
1821) -> SchemaRef {
1822    validate_format_version_block_size(format_version, block_size)
1823        .expect("invalid FTS format version for posting block size");
1824    match format_version {
1825        InvertedListFormatVersion::V1 => {
1826            inverted_list_schema_v1(with_position, block_size, with_impacts)
1827        }
1828        InvertedListFormatVersion::V2 | InvertedListFormatVersion::V3 => {
1829            inverted_list_schema_with_tail_codec_and_position_codec(
1830                with_position,
1831                format_version,
1832                PostingTailCodec::VarintDelta,
1833                Some(PositionStreamCodec::PackedDelta),
1834                block_size,
1835                with_impacts,
1836            )
1837        }
1838    }
1839}
1840
1841fn inverted_list_schema_v1(
1842    with_position: bool,
1843    block_size: usize,
1844    with_impacts: bool,
1845) -> SchemaRef {
1846    let mut fields = vec![
1847        arrow_schema::Field::new(
1848            POSTING_COL,
1849            datatypes::DataType::List(Arc::new(Field::new(
1850                "item",
1851                datatypes::DataType::LargeBinary,
1852                true,
1853            ))),
1854            false,
1855        ),
1856        arrow_schema::Field::new(MAX_SCORE_COL, datatypes::DataType::Float32, false),
1857        arrow_schema::Field::new(LENGTH_COL, datatypes::DataType::UInt32, false),
1858    ];
1859    if with_impacts {
1860        fields.push(arrow_schema::Field::new(
1861            IMPACT_COL,
1862            datatypes::DataType::List(Arc::new(Field::new(
1863                "item",
1864                datatypes::DataType::LargeBinary,
1865                true,
1866            ))),
1867            false,
1868        ));
1869    }
1870    if with_position {
1871        fields.push(arrow_schema::Field::new(
1872            POSITION_COL,
1873            arrow_schema::DataType::List(Arc::new(arrow_schema::Field::new(
1874                "item",
1875                arrow_schema::DataType::List(Arc::new(arrow_schema::Field::new(
1876                    "item",
1877                    arrow_schema::DataType::LargeBinary,
1878                    true,
1879                ))),
1880                true,
1881            ))),
1882            false,
1883        ));
1884    }
1885    Arc::new(arrow_schema::Schema::new_with_metadata(
1886        fields,
1887        HashMap::from([
1888            (POSTING_BLOCK_SIZE_KEY.to_owned(), block_size.to_string()),
1889            (
1890                FTS_FORMAT_VERSION_KEY.to_owned(),
1891                InvertedListFormatVersion::V1.index_version().to_string(),
1892            ),
1893        ]),
1894    ))
1895}
1896
1897pub fn inverted_list_schema_with_tail_codec(
1898    with_position: bool,
1899    posting_tail_codec: PostingTailCodec,
1900) -> SchemaRef {
1901    let format_version = InvertedListFormatVersion::from_posting_tail_codec_and_block_size(
1902        posting_tail_codec,
1903        LEGACY_BLOCK_SIZE,
1904    )
1905    .expect("invalid posting tail codec for posting block size");
1906    inverted_list_schema_with_tail_codec_and_position_codec(
1907        with_position,
1908        format_version,
1909        posting_tail_codec,
1910        Some(PositionStreamCodec::PackedDelta),
1911        LEGACY_BLOCK_SIZE,
1912        false,
1913    )
1914}
1915
1916fn inverted_list_schema_with_tail_codec_and_position_codec(
1917    with_position: bool,
1918    format_version: InvertedListFormatVersion,
1919    posting_tail_codec: PostingTailCodec,
1920    position_codec: Option<PositionStreamCodec>,
1921    block_size: usize,
1922    with_impacts: bool,
1923) -> SchemaRef {
1924    let mut fields = vec![
1925        // we compress the posting lists (including row ids and frequencies),
1926        // and store the compressed posting lists, so it's a large binary array
1927        arrow_schema::Field::new(
1928            POSTING_COL,
1929            datatypes::DataType::List(Arc::new(Field::new(
1930                "item",
1931                datatypes::DataType::LargeBinary,
1932                true,
1933            ))),
1934            false,
1935        ),
1936        arrow_schema::Field::new(MAX_SCORE_COL, datatypes::DataType::Float32, false),
1937        arrow_schema::Field::new(LENGTH_COL, datatypes::DataType::UInt32, false),
1938    ];
1939    if with_impacts {
1940        fields.push(arrow_schema::Field::new(
1941            IMPACT_COL,
1942            datatypes::DataType::List(Arc::new(Field::new(
1943                "item",
1944                datatypes::DataType::LargeBinary,
1945                true,
1946            ))),
1947            false,
1948        ));
1949    }
1950    if with_position {
1951        fields.push(arrow_schema::Field::new(
1952            COMPRESSED_POSITION_COL,
1953            arrow_schema::DataType::LargeBinary,
1954            false,
1955        ));
1956        fields.push(arrow_schema::Field::new(
1957            POSITION_BLOCK_OFFSET_COL,
1958            arrow_schema::DataType::List(Arc::new(arrow_schema::Field::new(
1959                "item",
1960                arrow_schema::DataType::UInt32,
1961                true,
1962            ))),
1963            false,
1964        ));
1965    }
1966    let mut metadata = HashMap::from([(
1967        POSTING_TAIL_CODEC_KEY.to_owned(),
1968        posting_tail_codec.as_str().to_owned(),
1969    )]);
1970    metadata.insert(
1971        FTS_FORMAT_VERSION_KEY.to_owned(),
1972        format_version.index_version().to_string(),
1973    );
1974    metadata.insert(POSTING_BLOCK_SIZE_KEY.to_owned(), block_size.to_string());
1975    if let Some(position_codec) = position_codec.filter(|_| with_position) {
1976        metadata.insert(
1977            POSITIONS_LAYOUT_KEY.to_owned(),
1978            POSITIONS_LAYOUT_SHARED_STREAM_V2.to_owned(),
1979        );
1980        metadata.insert(
1981            POSITIONS_CODEC_KEY.to_owned(),
1982            position_codec.as_str().to_owned(),
1983        );
1984    }
1985    Arc::new(arrow_schema::Schema::new_with_metadata(fields, metadata))
1986}
1987
1988pub(crate) fn token_file_path(partition_id: u64) -> String {
1989    format!("part_{}_{}", partition_id, TOKENS_FILE)
1990}
1991
1992pub(crate) fn posting_file_path(partition_id: u64) -> String {
1993    format!("part_{}_{}", partition_id, INVERT_LIST_FILE)
1994}
1995
1996pub(crate) fn doc_file_path(partition_id: u64) -> String {
1997    format!("part_{}_{}", partition_id, DOCS_FILE)
1998}
1999
2000pub(crate) fn part_metadata_file_path(partition_id: u64) -> String {
2001    staged_partition_file_path(partition_id, METADATA_FILE)
2002}
2003
2004const PARTITION_FILE_SUFFIXES: [&str; 3] = [TOKENS_FILE, INVERT_LIST_FILE, DOCS_FILE];
2005const STAGED_PARTITION_DIR: &str = "staging";
2006
2007fn partition_file_path(partition_id: u64, suffix: &str) -> String {
2008    format!("part_{}_{}", partition_id, suffix)
2009}
2010
2011fn staged_partition_file_path(partition_id: u64, suffix: &str) -> String {
2012    format!(
2013        "{}/{}",
2014        STAGED_PARTITION_DIR,
2015        partition_file_path(partition_id, suffix)
2016    )
2017}
2018
2019pub async fn merge_index_files(
2020    object_store: &ObjectStore,
2021    index_dir: &Path,
2022    store: Arc<dyn IndexStore>,
2023    progress: Arc<dyn IndexBuildProgress>,
2024) -> Result<()> {
2025    let metadata_path = index_dir.clone().join(METADATA_FILE);
2026    if object_store.exists(&metadata_path).await? {
2027        return Ok(());
2028    }
2029
2030    // List all staged partition metadata files in the index directory
2031    let index_files = list_index_files(object_store, index_dir).await?;
2032    let part_metadata_files = metadata_files(&index_files);
2033    if part_metadata_files.is_empty() {
2034        return Err(Error::invalid_input_source(
2035            format!(
2036                "No partition metadata files found in index directory: {}",
2037                index_dir
2038            )
2039            .into(),
2040        ));
2041    }
2042
2043    // Call merge_metadata_files function for inverted index
2044    merge_metadata_files(store, &part_metadata_files, progress).await
2045}
2046
2047async fn list_index_files(object_store: &ObjectStore, index_dir: &Path) -> Result<Vec<String>> {
2048    let mut index_files = Vec::new();
2049    let mut list_stream = object_store.read_dir_all(index_dir, None);
2050
2051    while let Some(item) = list_stream.next().await {
2052        match item {
2053            Ok(meta) => {
2054                let location = meta.location.as_ref().trim_start_matches('/');
2055                let index_dir = index_dir.as_ref().trim_start_matches('/');
2056                let relative_path = location
2057                    .strip_prefix(index_dir)
2058                    .map(|s| s.trim_start_matches('/').to_string())
2059                    .unwrap_or_else(|| meta.location.filename().unwrap_or("").to_string());
2060                index_files.push(relative_path);
2061            }
2062            Err(err) => return Err(err),
2063        }
2064    }
2065
2066    Ok(index_files)
2067}
2068
2069fn metadata_files(index_files: &[String]) -> Vec<String> {
2070    index_files
2071        .iter()
2072        .filter(|file_name| {
2073            file_name.starts_with(&format!("{}/part_", STAGED_PARTITION_DIR))
2074                && file_name.ends_with("_metadata.lance")
2075        })
2076        .cloned()
2077        .collect()
2078}
2079
2080#[cfg(test)]
2081async fn list_metadata_files(object_store: &ObjectStore, index_dir: &Path) -> Result<Vec<String>> {
2082    let index_files = list_index_files(object_store, index_dir).await?;
2083    let part_metadata_files = metadata_files(&index_files);
2084    if part_metadata_files.is_empty() {
2085        return Err(Error::invalid_input_source(
2086            format!(
2087                "No partition metadata files found in index directory: {}",
2088                index_dir
2089            )
2090            .into(),
2091        ));
2092    }
2093
2094    Ok(part_metadata_files)
2095}
2096
2097/// Merge partition metadata files with partition ID remapping to sequential IDs starting from 0
2098async fn merge_metadata_files(
2099    store: Arc<dyn IndexStore>,
2100    part_metadata_files: &[String],
2101    progress: Arc<dyn IndexBuildProgress>,
2102) -> Result<()> {
2103    // Collect all partition IDs and params
2104    let mut all_partitions = Vec::new();
2105    let mut params = None;
2106    let mut token_set_format = None;
2107    let mut format_version = None;
2108    let mut deleted_fragments = RoaringBitmap::new();
2109    progress
2110        .stage_start(
2111            "read_partition_metadata",
2112            Some(part_metadata_files.len() as u64),
2113            "files",
2114        )
2115        .await?;
2116
2117    for (idx, file_name) in part_metadata_files.iter().enumerate() {
2118        let reader = store.open_index_file(file_name).await?;
2119        let metadata = &reader.schema().metadata;
2120
2121        let partitions_str = metadata.get("partitions").ok_or(Error::index(format!(
2122            "partitions not found in {}",
2123            file_name
2124        )))?;
2125
2126        let partition_ids: Vec<u64> = serde_json::from_str(partitions_str)
2127            .map_err(|e| Error::index(format!("Failed to parse partitions: {}", e)))?;
2128
2129        all_partitions.extend(partition_ids);
2130
2131        if params.is_none() {
2132            let params_str = metadata
2133                .get("params")
2134                .ok_or(Error::index(format!("params not found in {}", file_name)))?;
2135            params = Some(
2136                serde_json::from_str::<InvertedIndexParams>(params_str)
2137                    .map_err(|e| Error::index(format!("Failed to parse params: {}", e)))?,
2138            );
2139        }
2140
2141        if token_set_format.is_none()
2142            && let Some(name) = metadata.get(TOKEN_SET_FORMAT_KEY)
2143        {
2144            token_set_format = Some(TokenSetFormat::from_str(name)?);
2145        }
2146        if format_version.is_none() {
2147            format_version = Some(parse_format_version_from_metadata(metadata)?);
2148        }
2149
2150        if reader.num_rows() > 0 {
2151            let metadata_batch = reader.read_range(0..1, None).await?;
2152            let deleted_fragments_col = metadata_batch
2153                .column_by_name(DELETED_FRAGMENTS_COL)
2154                .expect_ok()?;
2155            let deleted_fragments_arr = deleted_fragments_col
2156                .as_any()
2157                .downcast_ref::<BinaryArray>()
2158                .expect_ok()?;
2159            let part_deleted_fragments =
2160                RoaringBitmap::deserialize_from(deleted_fragments_arr.value(0))?;
2161            deleted_fragments.extend(part_deleted_fragments);
2162        }
2163        progress
2164            .stage_progress("read_partition_metadata", idx as u64 + 1)
2165            .await?;
2166    }
2167    progress.stage_complete("read_partition_metadata").await?;
2168
2169    // Create ID mapping: sorted original IDs -> 0,1,2...
2170    let mut sorted_ids = all_partitions;
2171    sorted_ids.sort();
2172    sorted_ids.dedup();
2173
2174    let id_mapping: Vec<(u64, u64)> = sorted_ids
2175        .iter()
2176        .enumerate()
2177        .map(|(new_id, &old_id)| (old_id, new_id as u64))
2178        .collect();
2179
2180    let total_copies = id_mapping.len() as u64 * PARTITION_FILE_SUFFIXES.len() as u64;
2181    progress
2182        .stage_start("remap_partition_files", Some(total_copies), "files")
2183        .await?;
2184
2185    let mut copied_files = 0u64;
2186
2187    for &(old_id, new_id) in &id_mapping {
2188        for suffix in PARTITION_FILE_SUFFIXES {
2189            let staged_path = staged_partition_file_path(old_id, suffix);
2190            let final_path = partition_file_path(new_id, suffix);
2191            store
2192                .copy_index_file_to(&staged_path, &final_path, store.as_ref())
2193                .await?;
2194            copied_files += 1;
2195            progress
2196                .stage_progress("remap_partition_files", copied_files)
2197                .await?;
2198        }
2199    }
2200    progress.stage_complete("remap_partition_files").await?;
2201
2202    // Write merged metadata with remapped IDs
2203    let remapped_partitions: Vec<u64> = (0..id_mapping.len() as u64).collect();
2204    let params = params.unwrap_or_default();
2205    let token_set_format = token_set_format.unwrap_or(TokenSetFormat::Arrow);
2206    let builder = InvertedIndexBuilder::from_existing_index(
2207        params,
2208        None,
2209        remapped_partitions.clone(),
2210        token_set_format,
2211        None,
2212        deleted_fragments,
2213    )
2214    .with_format_version(format_version.unwrap_or(InvertedListFormatVersion::V1));
2215    progress
2216        .stage_start("write_merged_metadata", Some(1), "files")
2217        .await?;
2218    builder
2219        .write_metadata(&*store, &remapped_partitions)
2220        .await?;
2221    progress.stage_progress("write_merged_metadata", 1).await?;
2222    progress.stage_complete("write_merged_metadata").await?;
2223
2224    // Cleanup staged partition metadata files
2225    for file_name in part_metadata_files {
2226        let _ = store.delete_index_file(file_name).await;
2227    }
2228    for &(old_id, _) in &id_mapping {
2229        for suffix in PARTITION_FILE_SUFFIXES {
2230            let _ = store
2231                .delete_index_file(&staged_partition_file_path(old_id, suffix))
2232                .await;
2233        }
2234    }
2235
2236    Ok(())
2237}
2238
2239/// Convert input stream into a stream of documents.
2240///
2241/// The input stream must be one of:
2242/// 1. Document in Utf8 or LargeUtf8 format.
2243/// 2. Document in List(Utf8) or List(LargeUtf8) format.
2244/// 3. Json document in LargeBinary format.
2245pub fn document_input(
2246    input: SendableRecordBatchStream,
2247    column: &str,
2248) -> Result<SendableRecordBatchStream> {
2249    let schema = input.schema();
2250    let field = schema.column_with_name(column).expect_ok()?.1;
2251    match field.data_type() {
2252        DataType::Utf8 | DataType::LargeUtf8 => Ok(input),
2253        DataType::List(field) | DataType::LargeList(field)
2254            if matches!(field.data_type(), DataType::Utf8 | DataType::LargeUtf8) =>
2255        {
2256            Ok(input)
2257        }
2258        DataType::LargeBinary => match field.metadata().get(ARROW_EXT_NAME_KEY) {
2259            Some(name) if name.as_str() == JSON_EXT_NAME => {
2260                Ok(Box::pin(JsonTextStream::new(input, column.to_string())))
2261            }
2262            _ => Err(Error::invalid_input_source(
2263                format!("column {} is not json", column).into(),
2264            )),
2265        },
2266        _ => Err(Error::invalid_input_source(
2267            format!(
2268                "column {} has type {}, is not utf8, large utf8 type/list, or large binary",
2269                column,
2270                field.data_type()
2271            )
2272            .into(),
2273        )),
2274    }
2275}
2276
2277#[cfg(test)]
2278mod tests {
2279    use super::*;
2280    use crate::Index;
2281    use crate::metrics::NoOpMetricsCollector;
2282    use crate::progress::IndexBuildProgress;
2283    use crate::scalar::inverted::{MemBM25Scorer, Scorer};
2284    use crate::scalar::{IndexFile, IndexReader, IndexWriter, ScalarIndex};
2285    use arrow_array::{RecordBatch, StringArray, UInt64Array};
2286    use arrow_schema::{DataType, Field, Schema};
2287    use async_trait::async_trait;
2288    use bytes::Bytes;
2289    use datafusion::physical_plan::stream::RecordBatchStreamAdapter;
2290    use futures::stream::{self, BoxStream};
2291    use lance_core::ROW_ID;
2292    use lance_core::cache::LanceCache;
2293    use lance_core::utils::tempfile::TempDir;
2294    use object_store::memory::InMemory;
2295    use object_store::{
2296        CopyOptions, GetOptions, GetResult, ListResult, MultipartUpload, ObjectMeta,
2297        ObjectStore as OSObjectStore, PutMultipartOptions, PutOptions, PutPayload, PutResult,
2298        Result as OSResult,
2299    };
2300    use std::any::Any;
2301    use std::fmt::{Display, Formatter};
2302    use std::ops::Range;
2303    use std::sync::atomic::{AtomicU64, AtomicUsize, Ordering};
2304    use std::time::Duration;
2305
2306    fn make_doc_batch(doc: &str, row_id: u64) -> RecordBatch {
2307        let schema = Arc::new(Schema::new(vec![
2308            Field::new("doc", DataType::Utf8, true),
2309            Field::new(ROW_ID, DataType::UInt64, false),
2310        ]));
2311        let docs = Arc::new(StringArray::from(vec![Some(doc)]));
2312        let row_ids = Arc::new(UInt64Array::from(vec![row_id]));
2313        RecordBatch::try_new(schema, vec![docs, row_ids]).unwrap()
2314    }
2315
2316    fn make_doc_batch_from_docs(docs: Vec<Option<&str>>) -> RecordBatch {
2317        let schema = Arc::new(Schema::new(vec![
2318            Field::new("doc", DataType::Utf8, true),
2319            Field::new(ROW_ID, DataType::UInt64, false),
2320        ]));
2321        let num_rows = docs.len();
2322        let docs = Arc::new(StringArray::from(docs));
2323        let row_ids = Arc::new(UInt64Array::from_iter_values(0..num_rows as u64));
2324        RecordBatch::try_new(schema, vec![docs, row_ids]).unwrap()
2325    }
2326
2327    struct FailingListObjectStore {
2328        inner: InMemory,
2329    }
2330
2331    impl Default for FailingListObjectStore {
2332        fn default() -> Self {
2333            Self {
2334                inner: InMemory::new(),
2335            }
2336        }
2337    }
2338
2339    impl Display for FailingListObjectStore {
2340        fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
2341            write!(f, "FailingListObjectStore")
2342        }
2343    }
2344
2345    impl Debug for FailingListObjectStore {
2346        fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
2347            f.debug_struct("FailingListObjectStore").finish()
2348        }
2349    }
2350
2351    #[async_trait]
2352    impl OSObjectStore for FailingListObjectStore {
2353        async fn put_opts(
2354            &self,
2355            location: &Path,
2356            bytes: PutPayload,
2357            opts: PutOptions,
2358        ) -> OSResult<PutResult> {
2359            self.inner.put_opts(location, bytes, opts).await
2360        }
2361
2362        async fn put_multipart_opts(
2363            &self,
2364            location: &Path,
2365            opts: PutMultipartOptions,
2366        ) -> OSResult<Box<dyn MultipartUpload>> {
2367            self.inner.put_multipart_opts(location, opts).await
2368        }
2369
2370        async fn get_opts(&self, location: &Path, options: GetOptions) -> OSResult<GetResult> {
2371            self.inner.get_opts(location, options).await
2372        }
2373
2374        async fn get_ranges(&self, location: &Path, ranges: &[Range<u64>]) -> OSResult<Vec<Bytes>> {
2375            self.inner.get_ranges(location, ranges).await
2376        }
2377
2378        fn delete_stream(
2379            &self,
2380            locations: BoxStream<'static, OSResult<Path>>,
2381        ) -> BoxStream<'static, OSResult<Path>> {
2382            self.inner.delete_stream(locations)
2383        }
2384
2385        fn list(&self, _prefix: Option<&Path>) -> BoxStream<'static, OSResult<ObjectMeta>> {
2386            stream::iter(vec![Err(object_store::Error::Generic {
2387                store: "failing-list",
2388                source: "boom listing metadata".into(),
2389            })])
2390            .boxed()
2391        }
2392
2393        fn list_with_offset(
2394            &self,
2395            prefix: Option<&Path>,
2396            offset: &Path,
2397        ) -> BoxStream<'static, OSResult<ObjectMeta>> {
2398            self.inner.list_with_offset(prefix, offset)
2399        }
2400
2401        async fn list_with_delimiter(&self, prefix: Option<&Path>) -> OSResult<ListResult> {
2402            self.inner.list_with_delimiter(prefix).await
2403        }
2404
2405        async fn copy_opts(&self, from: &Path, to: &Path, opts: CopyOptions) -> OSResult<()> {
2406            self.inner.copy_opts(from, to, opts).await
2407        }
2408    }
2409
2410    #[tokio::test]
2411    async fn test_list_metadata_files_propagates_list_error() -> Result<()> {
2412        let mut object_store = ObjectStore::memory();
2413        object_store.inner = Arc::new(FailingListObjectStore::default());
2414
2415        let err = list_metadata_files(&object_store, &Path::from("index"))
2416            .await
2417            .unwrap_err();
2418
2419        assert!(
2420            err.to_string().contains("boom listing metadata"),
2421            "expected original list error, got: {err}"
2422        );
2423        Ok(())
2424    }
2425
2426    #[tokio::test]
2427    async fn test_list_metadata_files_empty_directory_returns_no_files_error() -> Result<()> {
2428        let object_store = ObjectStore::memory();
2429
2430        let err = list_metadata_files(&object_store, &Path::from("empty-index"))
2431            .await
2432            .unwrap_err();
2433
2434        assert!(
2435            err.to_string()
2436                .contains("No partition metadata files found"),
2437            "expected empty-directory error, got: {err}"
2438        );
2439        Ok(())
2440    }
2441
2442    #[derive(Debug, Default, Clone)]
2443    struct CountingStore {
2444        write_count: Arc<AtomicUsize>,
2445    }
2446
2447    impl CountingStore {
2448        fn new() -> Self {
2449            Self {
2450                write_count: Arc::new(AtomicUsize::new(0)),
2451            }
2452        }
2453
2454        fn write_count(&self) -> usize {
2455            self.write_count.load(Ordering::SeqCst)
2456        }
2457    }
2458
2459    impl DeepSizeOf for CountingStore {
2460        fn deep_size_of_children(&self, _context: &mut lance_core::deepsize::Context) -> usize {
2461            0
2462        }
2463    }
2464
2465    #[derive(Debug, Clone)]
2466    struct NoRenameStore {
2467        inner: Arc<dyn IndexStore>,
2468        final_delete_count: Option<Arc<AtomicUsize>>,
2469    }
2470
2471    impl NoRenameStore {
2472        fn new(inner: Arc<dyn IndexStore>) -> Self {
2473            Self {
2474                inner,
2475                final_delete_count: None,
2476            }
2477        }
2478
2479        fn with_final_delete_tracking(inner: Arc<dyn IndexStore>) -> Self {
2480            Self {
2481                inner,
2482                final_delete_count: Some(Arc::new(AtomicUsize::new(0))),
2483            }
2484        }
2485
2486        fn final_delete_count(&self) -> usize {
2487            self.final_delete_count
2488                .as_ref()
2489                .map(|count| count.load(Ordering::SeqCst))
2490                .unwrap_or_default()
2491        }
2492
2493        fn unwrap_dest_store(dest_store: &dyn IndexStore) -> &dyn IndexStore {
2494            dest_store
2495                .as_any()
2496                .downcast_ref::<Self>()
2497                .map(|store| store.inner.as_ref())
2498                .unwrap_or(dest_store)
2499        }
2500    }
2501
2502    impl DeepSizeOf for NoRenameStore {
2503        fn deep_size_of_children(&self, context: &mut lance_core::deepsize::Context) -> usize {
2504            self.inner.deep_size_of_children(context)
2505        }
2506    }
2507
2508    #[async_trait]
2509    impl IndexStore for NoRenameStore {
2510        fn as_any(&self) -> &dyn Any {
2511            self
2512        }
2513
2514        fn clone_arc(&self) -> Arc<dyn IndexStore> {
2515            Arc::new(self.clone())
2516        }
2517
2518        fn io_parallelism(&self) -> usize {
2519            self.inner.io_parallelism()
2520        }
2521
2522        fn with_io_priority(&self, io_priority: u64) -> Arc<dyn IndexStore> {
2523            self.inner.with_io_priority(io_priority)
2524        }
2525
2526        async fn new_index_file(
2527            &self,
2528            name: &str,
2529            schema: Arc<Schema>,
2530        ) -> Result<Box<dyn IndexWriter>> {
2531            self.inner.new_index_file(name, schema).await
2532        }
2533
2534        async fn open_index_file(&self, name: &str) -> Result<Arc<dyn IndexReader>> {
2535            self.inner.open_index_file(name).await
2536        }
2537
2538        async fn copy_index_file(
2539            &self,
2540            name: &str,
2541            dest_store: &dyn IndexStore,
2542        ) -> Result<IndexFile> {
2543            self.inner
2544                .copy_index_file(name, Self::unwrap_dest_store(dest_store))
2545                .await
2546        }
2547
2548        async fn copy_index_file_to(
2549            &self,
2550            name: &str,
2551            new_name: &str,
2552            dest_store: &dyn IndexStore,
2553        ) -> Result<IndexFile> {
2554            self.inner
2555                .copy_index_file_to(name, new_name, Self::unwrap_dest_store(dest_store))
2556                .await
2557        }
2558
2559        async fn rename_index_file(&self, name: &str, new_name: &str) -> Result<IndexFile> {
2560            Err(Error::internal(format!(
2561                "merge_index_files should not rename partition file {name} to {new_name}"
2562            )))
2563        }
2564
2565        async fn delete_index_file(&self, name: &str) -> Result<()> {
2566            if name.starts_with("part_")
2567                && let Some(count) = &self.final_delete_count
2568            {
2569                count.fetch_add(1, Ordering::SeqCst);
2570            }
2571            self.inner.delete_index_file(name).await
2572        }
2573
2574        async fn list_files_with_sizes(&self) -> Result<Vec<IndexFile>> {
2575            self.inner.list_files_with_sizes().await
2576        }
2577    }
2578
2579    #[derive(Debug)]
2580    struct FailMetadataStore {
2581        inner: Arc<dyn IndexStore>,
2582    }
2583
2584    impl FailMetadataStore {
2585        fn new(inner: Arc<dyn IndexStore>) -> Self {
2586            Self { inner }
2587        }
2588
2589        fn unwrap_dest_store(dest_store: &dyn IndexStore) -> &dyn IndexStore {
2590            dest_store
2591                .as_any()
2592                .downcast_ref::<Self>()
2593                .map(|store| store.inner.as_ref())
2594                .unwrap_or(dest_store)
2595        }
2596    }
2597
2598    impl DeepSizeOf for FailMetadataStore {
2599        fn deep_size_of_children(&self, context: &mut lance_core::deepsize::Context) -> usize {
2600            self.inner.deep_size_of_children(context)
2601        }
2602    }
2603
2604    #[async_trait]
2605    impl IndexStore for FailMetadataStore {
2606        fn as_any(&self) -> &dyn Any {
2607            self
2608        }
2609
2610        fn clone_arc(&self) -> Arc<dyn IndexStore> {
2611            Arc::new(Self {
2612                inner: self.inner.clone(),
2613            })
2614        }
2615
2616        fn io_parallelism(&self) -> usize {
2617            self.inner.io_parallelism()
2618        }
2619
2620        fn with_io_priority(&self, io_priority: u64) -> Arc<dyn IndexStore> {
2621            self.inner.with_io_priority(io_priority)
2622        }
2623
2624        async fn new_index_file(
2625            &self,
2626            name: &str,
2627            schema: Arc<Schema>,
2628        ) -> Result<Box<dyn IndexWriter>> {
2629            let writer = self.inner.new_index_file(name, schema).await?;
2630            if name == METADATA_FILE {
2631                Ok(Box::new(FailFinishWriter { inner: writer }))
2632            } else {
2633                Ok(writer)
2634            }
2635        }
2636
2637        async fn open_index_file(&self, name: &str) -> Result<Arc<dyn IndexReader>> {
2638            self.inner.open_index_file(name).await
2639        }
2640
2641        async fn copy_index_file(
2642            &self,
2643            name: &str,
2644            dest_store: &dyn IndexStore,
2645        ) -> Result<IndexFile> {
2646            self.inner
2647                .copy_index_file(name, Self::unwrap_dest_store(dest_store))
2648                .await
2649        }
2650
2651        async fn copy_index_file_to(
2652            &self,
2653            name: &str,
2654            new_name: &str,
2655            dest_store: &dyn IndexStore,
2656        ) -> Result<IndexFile> {
2657            self.inner
2658                .copy_index_file_to(name, new_name, Self::unwrap_dest_store(dest_store))
2659                .await
2660        }
2661
2662        async fn rename_index_file(&self, name: &str, new_name: &str) -> Result<IndexFile> {
2663            self.inner.rename_index_file(name, new_name).await
2664        }
2665
2666        async fn delete_index_file(&self, name: &str) -> Result<()> {
2667            self.inner.delete_index_file(name).await
2668        }
2669
2670        async fn list_files_with_sizes(&self) -> Result<Vec<IndexFile>> {
2671            self.inner.list_files_with_sizes().await
2672        }
2673    }
2674
2675    struct FailFinishWriter {
2676        inner: Box<dyn IndexWriter>,
2677    }
2678
2679    #[async_trait]
2680    impl IndexWriter for FailFinishWriter {
2681        async fn write_record_batch(&mut self, batch: RecordBatch) -> Result<u64> {
2682            self.inner.write_record_batch(batch).await
2683        }
2684
2685        async fn add_global_buffer(&mut self, data: Bytes) -> Result<u32> {
2686            self.inner.add_global_buffer(data).await
2687        }
2688
2689        async fn finish(&mut self) -> Result<IndexFile> {
2690            Err(Error::internal("injected metadata write failure"))
2691        }
2692
2693        async fn finish_with_metadata(
2694            &mut self,
2695            _metadata: HashMap<String, String>,
2696        ) -> Result<IndexFile> {
2697            Err(Error::internal("injected metadata write failure"))
2698        }
2699    }
2700
2701    #[derive(Debug)]
2702    struct CountingWriter {
2703        path: String,
2704        write_count: Arc<AtomicUsize>,
2705    }
2706
2707    #[async_trait]
2708    impl IndexWriter for CountingWriter {
2709        async fn write_record_batch(&mut self, _batch: RecordBatch) -> Result<u64> {
2710            Ok(self.write_count.fetch_add(1, Ordering::SeqCst) as u64)
2711        }
2712
2713        async fn add_global_buffer(&mut self, _data: Bytes) -> Result<u32> {
2714            // Mirror the real writer's 1-indexed return value.
2715            Ok(1)
2716        }
2717
2718        async fn finish(&mut self) -> Result<IndexFile> {
2719            Ok(IndexFile {
2720                path: self.path.clone(),
2721                size_bytes: 0,
2722            })
2723        }
2724
2725        async fn finish_with_metadata(
2726            &mut self,
2727            _metadata: HashMap<String, String>,
2728        ) -> Result<IndexFile> {
2729            Ok(IndexFile {
2730                path: self.path.clone(),
2731                size_bytes: 0,
2732            })
2733        }
2734    }
2735
2736    #[async_trait]
2737    impl IndexStore for CountingStore {
2738        fn as_any(&self) -> &dyn Any {
2739            self
2740        }
2741
2742        fn clone_arc(&self) -> Arc<dyn IndexStore> {
2743            Arc::new(self.clone())
2744        }
2745
2746        fn io_parallelism(&self) -> usize {
2747            1
2748        }
2749
2750        fn with_io_priority(&self, _io_priority: u64) -> Arc<dyn IndexStore> {
2751            // No backing scheduler, so priority is meaningless here.
2752            self.clone_arc()
2753        }
2754
2755        async fn new_index_file(
2756            &self,
2757            name: &str,
2758            _schema: Arc<Schema>,
2759        ) -> Result<Box<dyn IndexWriter>> {
2760            Ok(Box::new(CountingWriter {
2761                path: name.to_string(),
2762                write_count: self.write_count.clone(),
2763            }))
2764        }
2765
2766        async fn open_index_file(&self, _name: &str) -> Result<Arc<dyn IndexReader>> {
2767            Err(Error::not_supported(
2768                "CountingStore does not support reading",
2769            ))
2770        }
2771
2772        async fn copy_index_file(
2773            &self,
2774            _name: &str,
2775            _dest_store: &dyn IndexStore,
2776        ) -> Result<IndexFile> {
2777            Err(Error::not_supported(
2778                "CountingStore does not support copying",
2779            ))
2780        }
2781
2782        async fn rename_index_file(&self, _name: &str, _new_name: &str) -> Result<IndexFile> {
2783            Err(Error::not_supported(
2784                "CountingStore does not support renaming",
2785            ))
2786        }
2787
2788        async fn delete_index_file(&self, _name: &str) -> Result<()> {
2789            Err(Error::not_supported(
2790                "CountingStore does not support deleting",
2791            ))
2792        }
2793
2794        async fn list_files_with_sizes(&self) -> Result<Vec<IndexFile>> {
2795            Ok(vec![])
2796        }
2797    }
2798
2799    #[tokio::test]
2800    async fn test_write_posting_lists_batches_multiple_rows() -> Result<()> {
2801        let mut builder = InnerBuilder::new(0, false, TokenSetFormat::default());
2802        for doc_id in 0..3u64 {
2803            builder.docs.append(doc_id, 1);
2804        }
2805
2806        for doc_id in 0..3u32 {
2807            let mut posting_list = PostingListBuilder::new(false);
2808            posting_list.add(doc_id, PositionRecorder::Count(1));
2809            builder.posting_lists.push(posting_list);
2810        }
2811
2812        let store = CountingStore::new();
2813        let docs = Arc::new(std::mem::take(&mut builder.docs));
2814        builder
2815            .write_posting_lists(&store, docs, &posting_file_path(0))
2816            .await?;
2817
2818        assert_eq!(store.write_count(), 1);
2819        Ok(())
2820    }
2821
2822    async fn write_partition_file_marker(
2823        store: &dyn IndexStore,
2824        path: &str,
2825        partition_id: u64,
2826    ) -> Result<()> {
2827        let schema = Arc::new(Schema::new(vec![Field::new(
2828            "partition_id",
2829            DataType::UInt64,
2830            false,
2831        )]));
2832        let batch = RecordBatch::try_new(
2833            schema.clone(),
2834            vec![Arc::new(UInt64Array::from(vec![partition_id]))],
2835        )?;
2836        let mut writer = store.new_index_file(path, schema).await?;
2837        writer.write_record_batch(batch).await?;
2838        writer.finish().await?;
2839        Ok(())
2840    }
2841
2842    async fn write_partition_files(
2843        store: &dyn IndexStore,
2844        partition_id: u64,
2845        target: PartitionWriteTarget,
2846    ) -> Result<()> {
2847        write_partition_file_marker(store, &target.token_path(partition_id), partition_id).await?;
2848        write_partition_file_marker(store, &target.posting_path(partition_id), partition_id)
2849            .await?;
2850        write_partition_file_marker(store, &target.doc_path(partition_id), partition_id).await?;
2851        Ok(())
2852    }
2853
2854    async fn read_partition_file_marker(store: &dyn IndexStore, path: &str) -> Result<u64> {
2855        let reader = store.open_index_file(path).await?;
2856        let batch = reader.read_range(0..1, None).await?;
2857        let partition_ids = batch.column(0).as_primitive::<datatypes::UInt64Type>();
2858        Ok(partition_ids.value(0))
2859    }
2860
2861    async fn assert_partition_file_markers(
2862        store: &dyn IndexStore,
2863        partition_id: u64,
2864        expected_marker: u64,
2865    ) -> Result<()> {
2866        assert_eq!(
2867            read_partition_file_marker(store, &token_file_path(partition_id)).await?,
2868            expected_marker
2869        );
2870        assert_eq!(
2871            read_partition_file_marker(store, &posting_file_path(partition_id)).await?,
2872            expected_marker
2873        );
2874        assert_eq!(
2875            read_partition_file_marker(store, &doc_file_path(partition_id)).await?,
2876            expected_marker
2877        );
2878        Ok(())
2879    }
2880
2881    #[tokio::test]
2882    async fn test_merge_index_files_remaps_staged_partitions_without_rename() -> Result<()> {
2883        let index_dir = TempDir::default();
2884        let object_store = Arc::new(ObjectStore::local());
2885        let base_store: Arc<dyn IndexStore> = Arc::new(LanceIndexStore::new(
2886            object_store.clone(),
2887            index_dir.obj_path(),
2888            Arc::new(LanceCache::no_cache()),
2889        ));
2890        let store = Arc::new(NoRenameStore::new(base_store.clone()));
2891        let partitions = vec![5_u64, 1_u64, (17_u64 << 32) | 2];
2892        let metadata_builder = InvertedIndexBuilder::from_existing_index(
2893            InvertedIndexParams::default(),
2894            None,
2895            Vec::new(),
2896            TokenSetFormat::default(),
2897            None,
2898            RoaringBitmap::new(),
2899        );
2900
2901        for partition_id in &partitions {
2902            write_partition_files(
2903                base_store.as_ref(),
2904                *partition_id,
2905                PartitionWriteTarget::Staged,
2906            )
2907            .await?;
2908            metadata_builder
2909                .write_part_metadata(base_store.as_ref(), *partition_id)
2910                .await?;
2911        }
2912
2913        merge_index_files(
2914            object_store.as_ref(),
2915            &index_dir.obj_path(),
2916            store,
2917            noop_progress(),
2918        )
2919        .await?;
2920
2921        let metadata_reader = base_store.open_index_file(METADATA_FILE).await?;
2922        let metadata = &metadata_reader.schema().metadata;
2923        let written_partitions: Vec<u64> = serde_json::from_str(
2924            metadata
2925                .get("partitions")
2926                .expect("partitions missing from metadata"),
2927        )?;
2928        let mut expected_partitions = partitions.clone();
2929        expected_partitions.sort_unstable();
2930        expected_partitions.dedup();
2931        let remapped_partitions = (0..expected_partitions.len() as u64).collect::<Vec<_>>();
2932        assert_eq!(written_partitions, remapped_partitions);
2933        assert_eq!(
2934            parse_format_version_from_metadata(metadata)?,
2935            InvertedListFormatVersion::V2
2936        );
2937
2938        for (new_id, old_id) in expected_partitions.iter().enumerate() {
2939            assert_partition_file_markers(base_store.as_ref(), new_id as u64, *old_id).await?;
2940            assert!(
2941                base_store
2942                    .open_index_file(&part_metadata_file_path(*old_id))
2943                    .await
2944                    .is_err(),
2945                "partition metadata should be cleaned up after final metadata is written"
2946            );
2947            for suffix in PARTITION_FILE_SUFFIXES {
2948                assert!(
2949                    base_store
2950                        .open_index_file(&staged_partition_file_path(*old_id, suffix))
2951                        .await
2952                        .is_err(),
2953                    "staged partition files should be cleaned up after final metadata is written"
2954                );
2955            }
2956        }
2957
2958        Ok(())
2959    }
2960
2961    #[tokio::test]
2962    async fn test_merge_index_files_rewrites_partial_final_files_from_staging() -> Result<()> {
2963        let index_dir = TempDir::default();
2964        let object_store = Arc::new(ObjectStore::local());
2965        let base_store: Arc<dyn IndexStore> = Arc::new(LanceIndexStore::new(
2966            object_store.clone(),
2967            index_dir.obj_path(),
2968            Arc::new(LanceCache::no_cache()),
2969        ));
2970        let store = Arc::new(NoRenameStore::with_final_delete_tracking(
2971            base_store.clone(),
2972        ));
2973        let partitions = vec![1_u64, 5_u64];
2974        let metadata_builder = InvertedIndexBuilder::from_existing_index(
2975            InvertedIndexParams::default(),
2976            None,
2977            Vec::new(),
2978            TokenSetFormat::default(),
2979            None,
2980            RoaringBitmap::new(),
2981        );
2982
2983        for partition_id in &partitions {
2984            write_partition_files(
2985                base_store.as_ref(),
2986                *partition_id,
2987                PartitionWriteTarget::Staged,
2988            )
2989            .await?;
2990            metadata_builder
2991                .write_part_metadata(base_store.as_ref(), *partition_id)
2992                .await?;
2993        }
2994
2995        for suffix in PARTITION_FILE_SUFFIXES {
2996            write_partition_file_marker(base_store.as_ref(), &partition_file_path(1, suffix), 999)
2997                .await?;
2998        }
2999
3000        merge_index_files(
3001            object_store.as_ref(),
3002            &index_dir.obj_path(),
3003            store.clone(),
3004            noop_progress(),
3005        )
3006        .await?;
3007
3008        assert_partition_file_markers(base_store.as_ref(), 0, 1).await?;
3009        assert_partition_file_markers(base_store.as_ref(), 1, 5).await?;
3010        assert_eq!(
3011            store.final_delete_count(),
3012            0,
3013            "merge should overwrite final partition files without deleting them first"
3014        );
3015
3016        Ok(())
3017    }
3018
3019    #[tokio::test]
3020    async fn test_distributed_from_existing_copies_existing_partitions_to_staging_and_finalizes()
3021    -> Result<()> {
3022        let object_store = Arc::new(ObjectStore::local());
3023        let source_dir = TempDir::default();
3024        let dest_dir = TempDir::default();
3025        let source_store: Arc<dyn IndexStore> = Arc::new(LanceIndexStore::new(
3026            object_store.clone(),
3027            source_dir.obj_path(),
3028            Arc::new(LanceCache::no_cache()),
3029        ));
3030        let dest_store: Arc<dyn IndexStore> = Arc::new(LanceIndexStore::new(
3031            object_store.clone(),
3032            dest_dir.obj_path(),
3033            Arc::new(LanceCache::no_cache()),
3034        ));
3035        let merge_store = Arc::new(NoRenameStore::new(dest_store.clone()));
3036        let fragment_mask = 7_u64 << 32;
3037        let partitions = vec![fragment_mask | 5, fragment_mask | 1];
3038
3039        for partition_id in &partitions {
3040            write_partition_files(
3041                source_store.as_ref(),
3042                *partition_id,
3043                PartitionWriteTarget::Final,
3044            )
3045            .await?;
3046        }
3047
3048        let builder = InvertedIndexBuilder::from_existing_index(
3049            InvertedIndexParams::default(),
3050            Some(source_store.clone()),
3051            partitions.clone(),
3052            TokenSetFormat::default(),
3053            Some(fragment_mask),
3054            RoaringBitmap::new(),
3055        );
3056        builder.write(dest_store.as_ref()).await?;
3057
3058        for partition_id in &partitions {
3059            assert_partition_file_markers(source_store.as_ref(), *partition_id, *partition_id)
3060                .await?;
3061            for suffix in PARTITION_FILE_SUFFIXES {
3062                let staged_path = staged_partition_file_path(*partition_id, suffix);
3063                assert_eq!(
3064                    read_partition_file_marker(dest_store.as_ref(), &staged_path).await?,
3065                    *partition_id
3066                );
3067                assert!(
3068                    dest_store
3069                        .open_index_file(&partition_file_path(*partition_id, suffix))
3070                        .await
3071                        .is_err(),
3072                    "distributed existing partition should be staged instead of copied to root"
3073                );
3074            }
3075            dest_store
3076                .open_index_file(&part_metadata_file_path(*partition_id))
3077                .await?;
3078        }
3079
3080        merge_index_files(
3081            object_store.as_ref(),
3082            &dest_dir.obj_path(),
3083            merge_store,
3084            noop_progress(),
3085        )
3086        .await?;
3087
3088        let mut expected_partitions = partitions.clone();
3089        expected_partitions.sort_unstable();
3090        for (new_id, old_id) in expected_partitions.iter().enumerate() {
3091            assert_partition_file_markers(dest_store.as_ref(), new_id as u64, *old_id).await?;
3092            for suffix in PARTITION_FILE_SUFFIXES {
3093                assert!(
3094                    dest_store
3095                        .open_index_file(&staged_partition_file_path(*old_id, suffix))
3096                        .await
3097                        .is_err(),
3098                    "staged partition files should be cleaned after final metadata is written"
3099                );
3100            }
3101        }
3102
3103        Ok(())
3104    }
3105
3106    #[tokio::test]
3107    async fn test_merge_index_files_keeps_staging_when_final_metadata_write_fails() -> Result<()> {
3108        let index_dir = TempDir::default();
3109        let object_store = Arc::new(ObjectStore::local());
3110        let base_store: Arc<dyn IndexStore> = Arc::new(LanceIndexStore::new(
3111            object_store.clone(),
3112            index_dir.obj_path(),
3113            Arc::new(LanceCache::no_cache()),
3114        ));
3115        let failing_store = Arc::new(FailMetadataStore::new(base_store.clone()));
3116        let partitions = vec![1_u64, 5_u64];
3117        let metadata_builder = InvertedIndexBuilder::from_existing_index(
3118            InvertedIndexParams::default(),
3119            None,
3120            Vec::new(),
3121            TokenSetFormat::default(),
3122            None,
3123            RoaringBitmap::new(),
3124        );
3125
3126        for partition_id in &partitions {
3127            write_partition_files(
3128                base_store.as_ref(),
3129                *partition_id,
3130                PartitionWriteTarget::Staged,
3131            )
3132            .await?;
3133            metadata_builder
3134                .write_part_metadata(base_store.as_ref(), *partition_id)
3135                .await?;
3136        }
3137
3138        let err = merge_index_files(
3139            object_store.as_ref(),
3140            &index_dir.obj_path(),
3141            failing_store,
3142            noop_progress(),
3143        )
3144        .await
3145        .unwrap_err();
3146        assert!(
3147            err.to_string().contains("metadata write failure"),
3148            "expected injected metadata failure, got: {err}"
3149        );
3150
3151        for partition_id in &partitions {
3152            base_store
3153                .open_index_file(&part_metadata_file_path(*partition_id))
3154                .await?;
3155            for suffix in PARTITION_FILE_SUFFIXES {
3156                let staged_path = staged_partition_file_path(*partition_id, suffix);
3157                assert_eq!(
3158                    read_partition_file_marker(base_store.as_ref(), &staged_path).await?,
3159                    *partition_id
3160                );
3161            }
3162        }
3163
3164        Ok(())
3165    }
3166
3167    #[tokio::test]
3168    async fn test_distributed_build_writes_partition_data_to_staging() -> Result<()> {
3169        let index_dir = TempDir::default();
3170        let object_store = ObjectStore::local();
3171        let store = Arc::new(LanceIndexStore::new(
3172            object_store.into(),
3173            index_dir.obj_path(),
3174            Arc::new(LanceCache::no_cache()),
3175        ));
3176
3177        let fragment_mask = 7_u64 << 32;
3178        let batch = make_doc_batch("hello world", fragment_mask);
3179        let stream = RecordBatchStreamAdapter::new(batch.schema(), stream::iter(vec![Ok(batch)]));
3180        let stream = Box::pin(stream);
3181        let mut builder = InvertedIndexBuilder::new_with_fragment_mask(
3182            InvertedIndexParams::default(),
3183            Some(fragment_mask),
3184        );
3185        builder.update(stream, store.as_ref(), None).await?;
3186
3187        let part_metadata_files =
3188            list_metadata_files(&ObjectStore::local(), &index_dir.obj_path()).await?;
3189        assert_eq!(part_metadata_files.len(), 1);
3190        assert!(
3191            part_metadata_files[0].starts_with("staging/part_"),
3192            "partition metadata should be written to staging"
3193        );
3194        let reader = store.open_index_file(&part_metadata_files[0]).await?;
3195        let partition_ids: Vec<u64> = serde_json::from_str(
3196            reader
3197                .schema()
3198                .metadata
3199                .get("partitions")
3200                .expect("partitions missing from metadata"),
3201        )?;
3202        assert_eq!(partition_ids.len(), 1);
3203        let partition_id = partition_ids[0];
3204
3205        store
3206            .open_index_file(&staged_partition_file_path(partition_id, TOKENS_FILE))
3207            .await?;
3208        assert!(
3209            store
3210                .open_index_file(&partition_file_path(partition_id, METADATA_FILE))
3211                .await
3212                .is_err(),
3213            "distributed build-only metadata should not be written to root partition metadata paths"
3214        );
3215        assert!(
3216            store
3217                .open_index_file(&token_file_path(partition_id))
3218                .await
3219                .is_err(),
3220            "distributed build-only data should not be written to final partition paths"
3221        );
3222
3223        Ok(())
3224    }
3225
3226    #[tokio::test]
3227    async fn test_merge_index_files_is_noop_when_metadata_exists() -> Result<()> {
3228        let index_dir = TempDir::default();
3229        let object_store = Arc::new(ObjectStore::local());
3230        let store: Arc<dyn IndexStore> = Arc::new(LanceIndexStore::new(
3231            object_store.clone(),
3232            index_dir.obj_path(),
3233            Arc::new(LanceCache::no_cache()),
3234        ));
3235        let metadata_builder = InvertedIndexBuilder::from_existing_index(
3236            InvertedIndexParams::default(),
3237            None,
3238            vec![42],
3239            TokenSetFormat::default(),
3240            None,
3241            RoaringBitmap::new(),
3242        );
3243        metadata_builder
3244            .write_metadata(store.as_ref(), &[42])
3245            .await?;
3246
3247        merge_index_files(
3248            object_store.as_ref(),
3249            &index_dir.obj_path(),
3250            store,
3251            noop_progress(),
3252        )
3253        .await?;
3254
3255        Ok(())
3256    }
3257
3258    #[tokio::test]
3259    async fn test_build_only_path_writes_partitions_as_is() -> Result<()> {
3260        let src_dir = TempDir::default();
3261        let dest_dir = TempDir::default();
3262        let src_store = Arc::new(LanceIndexStore::new(
3263            ObjectStore::local().into(),
3264            src_dir.obj_path(),
3265            Arc::new(LanceCache::no_cache()),
3266        ));
3267        let dest_store = Arc::new(LanceIndexStore::new(
3268            ObjectStore::local().into(),
3269            dest_dir.obj_path(),
3270            Arc::new(LanceCache::no_cache()),
3271        ));
3272
3273        let params = InvertedIndexParams::default();
3274        let tokenizer = params.build()?;
3275        let token_set_format = TokenSetFormat::default();
3276        let id_alloc = Arc::new(AtomicU64::new(0));
3277
3278        let mut worker1 = IndexWorker::new(
3279            tokenizer.clone(),
3280            src_store.clone(),
3281            id_alloc.clone(),
3282            IndexWorkerConfig {
3283                with_position: params.with_position,
3284                format_version: InvertedListFormatVersion::V1,
3285                fragment_mask: None,
3286                token_set_format,
3287                worker_memory_limit_bytes: u64::MAX,
3288                block_size: params.block_size,
3289            },
3290        )
3291        .await?;
3292        worker1
3293            .process_batch(make_doc_batch("hello world", 0))
3294            .await?;
3295        let output1 = worker1.finish().await?;
3296        let mut partitions = output1.partitions;
3297        if let Some(mut tail_partition) = output1.tail_partition {
3298            partitions.push(tail_partition.builder.id());
3299            tail_partition.builder.write(src_store.as_ref()).await?;
3300        }
3301
3302        let mut worker2 = IndexWorker::new(
3303            tokenizer.clone(),
3304            src_store.clone(),
3305            id_alloc.clone(),
3306            IndexWorkerConfig {
3307                with_position: params.with_position,
3308                format_version: InvertedListFormatVersion::V1,
3309                fragment_mask: None,
3310                token_set_format,
3311                worker_memory_limit_bytes: u64::MAX,
3312                block_size: params.block_size,
3313            },
3314        )
3315        .await?;
3316        worker2
3317            .process_batch(make_doc_batch("goodbye world", 1))
3318            .await?;
3319        let output2 = worker2.finish().await?;
3320        partitions.extend(output2.partitions);
3321        if let Some(mut tail_partition) = output2.tail_partition {
3322            partitions.push(tail_partition.builder.id());
3323            tail_partition.builder.write(src_store.as_ref()).await?;
3324        }
3325        partitions.sort_unstable();
3326        assert_eq!(partitions.len(), 2);
3327        assert_ne!(partitions[0], partitions[1]);
3328
3329        let builder = InvertedIndexBuilder::from_existing_index(
3330            InvertedIndexParams::default(),
3331            Some(src_store.clone()),
3332            partitions.clone(),
3333            token_set_format,
3334            None,
3335            RoaringBitmap::new(),
3336        )
3337        .with_format_version(InvertedListFormatVersion::V1);
3338        builder.write(dest_store.as_ref()).await?;
3339
3340        let metadata_reader = dest_store.open_index_file(METADATA_FILE).await?;
3341        let metadata = &metadata_reader.schema().metadata;
3342        let partitions_str = metadata
3343            .get("partitions")
3344            .expect("partitions missing from metadata");
3345        let written_partitions: Vec<u64> = serde_json::from_str(partitions_str).unwrap();
3346        assert_eq!(written_partitions, partitions);
3347
3348        for id in &partitions {
3349            dest_store.open_index_file(&token_file_path(*id)).await?;
3350            dest_store.open_index_file(&posting_file_path(*id)).await?;
3351            dest_store.open_index_file(&doc_file_path(*id)).await?;
3352        }
3353
3354        Ok(())
3355    }
3356
3357    #[tokio::test]
3358    async fn test_update_preserves_existing_posting_tail_codec() -> Result<()> {
3359        let src_dir = TempDir::default();
3360        let dest_dir = TempDir::default();
3361        let src_store = Arc::new(LanceIndexStore::new(
3362            ObjectStore::local().into(),
3363            src_dir.obj_path(),
3364            Arc::new(LanceCache::no_cache()),
3365        ));
3366        let dest_store = Arc::new(LanceIndexStore::new(
3367            ObjectStore::local().into(),
3368            dest_dir.obj_path(),
3369            Arc::new(LanceCache::no_cache()),
3370        ));
3371
3372        let posting_tail_codec = PostingTailCodec::Fixed32;
3373        let mut partition = InnerBuilder::new_with_posting_tail_codec(
3374            0,
3375            false,
3376            TokenSetFormat::default(),
3377            posting_tail_codec,
3378        );
3379        partition.tokens.add("hello".to_owned());
3380        let mut posting_list =
3381            PostingListBuilder::new_with_posting_tail_codec(false, posting_tail_codec);
3382        posting_list.add(0, PositionRecorder::Count(1));
3383        partition.posting_lists.push(posting_list);
3384        partition.docs.append(100, 1);
3385        partition.write(src_store.as_ref()).await?;
3386
3387        let metadata_writer = InvertedIndexBuilder::from_existing_index(
3388            InvertedIndexParams::default(),
3389            Some(src_store.clone()),
3390            vec![0],
3391            TokenSetFormat::default(),
3392            None,
3393            RoaringBitmap::new(),
3394        )
3395        .with_posting_tail_codec(posting_tail_codec);
3396        metadata_writer
3397            .write_metadata(src_store.as_ref(), &[0])
3398            .await?;
3399
3400        let index = InvertedIndex::load(src_store, None, &LanceCache::no_cache()).await?;
3401        let derived_params = index.derive_index_params()?;
3402        let derived_params: InvertedIndexParams =
3403            serde_json::from_str(derived_params.params.as_deref().unwrap())?;
3404        assert_eq!(
3405            derived_params.format_version,
3406            Some(InvertedListFormatVersion::V1)
3407        );
3408
3409        let schema = Arc::new(Schema::new(vec![
3410            Field::new("doc", DataType::Utf8, true),
3411            Field::new(ROW_ID, DataType::UInt64, false),
3412        ]));
3413        let docs = Arc::new(StringArray::from(vec![Some("hello again")]));
3414        let row_ids = Arc::new(UInt64Array::from(vec![101u64]));
3415        let batch = RecordBatch::try_new(schema.clone(), vec![docs, row_ids])?;
3416        let stream = RecordBatchStreamAdapter::new(schema, stream::iter(vec![Ok(batch)]));
3417        index
3418            .update(Box::pin(stream), dest_store.as_ref(), None)
3419            .await?;
3420
3421        let updated =
3422            InvertedIndex::load(dest_store.clone(), None, &LanceCache::no_cache()).await?;
3423        assert_eq!(updated.partitions.len(), 2);
3424        for partition in &updated.partitions {
3425            assert_eq!(
3426                partition.inverted_list.posting_tail_codec(),
3427                posting_tail_codec
3428            );
3429        }
3430
3431        let metadata = dest_store.open_index_file(METADATA_FILE).await?;
3432        assert_eq!(
3433            metadata.schema().metadata.get(POSTING_TAIL_CODEC_KEY),
3434            Some(&posting_tail_codec.as_str().to_owned())
3435        );
3436
3437        Ok(())
3438    }
3439
3440    #[test]
3441    fn test_with_posting_tail_codec_syncs_format_version() {
3442        let builder = InvertedIndexBuilder::from_existing_index(
3443            InvertedIndexParams::default(),
3444            None,
3445            Vec::new(),
3446            TokenSetFormat::default(),
3447            None,
3448            RoaringBitmap::new(),
3449        )
3450        .with_format_version(InvertedListFormatVersion::V2)
3451        .with_posting_tail_codec(PostingTailCodec::Fixed32);
3452        assert_eq!(builder.format_version, InvertedListFormatVersion::V1);
3453        assert_eq!(builder.posting_tail_codec, PostingTailCodec::Fixed32);
3454
3455        let builder = builder.with_posting_tail_codec(PostingTailCodec::VarintDelta);
3456        assert_eq!(builder.format_version, InvertedListFormatVersion::V2);
3457        assert_eq!(builder.posting_tail_codec, PostingTailCodec::VarintDelta);
3458    }
3459
3460    #[test]
3461    fn test_v3_128_reuses_v2_physical_layout() {
3462        for with_position in [false, true] {
3463            for with_impacts in [false, true] {
3464                let v2 = inverted_list_schema_for_version_with_block_size_and_impacts(
3465                    with_position,
3466                    InvertedListFormatVersion::V2,
3467                    LEGACY_BLOCK_SIZE,
3468                    with_impacts,
3469                );
3470                let v3 = inverted_list_schema_for_version_with_block_size_and_impacts(
3471                    with_position,
3472                    InvertedListFormatVersion::V3,
3473                    LEGACY_BLOCK_SIZE,
3474                    with_impacts,
3475                );
3476
3477                assert_eq!(v2.fields(), v3.fields());
3478                let mut v2_metadata = v2.metadata.clone();
3479                let mut v3_metadata = v3.metadata.clone();
3480                assert_eq!(
3481                    v2_metadata.remove(FTS_FORMAT_VERSION_KEY).as_deref(),
3482                    Some("2")
3483                );
3484                assert_eq!(
3485                    v3_metadata.remove(FTS_FORMAT_VERSION_KEY).as_deref(),
3486                    Some("3")
3487                );
3488                assert_eq!(v2_metadata, v3_metadata);
3489            }
3490        }
3491    }
3492
3493    #[tokio::test]
3494    async fn test_inverted_index_without_positions_tracks_frequency() -> Result<()> {
3495        let index_dir = TempDir::default();
3496        let store = Arc::new(LanceIndexStore::new(
3497            ObjectStore::local().into(),
3498            index_dir.obj_path(),
3499            Arc::new(LanceCache::no_cache()),
3500        ));
3501
3502        let schema = Arc::new(Schema::new(vec![
3503            Field::new("doc", DataType::Utf8, true),
3504            Field::new(ROW_ID, DataType::UInt64, false),
3505        ]));
3506        let docs = Arc::new(StringArray::from(vec![Some("hello hello world")]));
3507        let row_ids = Arc::new(UInt64Array::from(vec![0u64]));
3508        let batch = RecordBatch::try_new(schema.clone(), vec![docs, row_ids])?;
3509        let stream = RecordBatchStreamAdapter::new(schema, stream::iter(vec![Ok(batch)]));
3510        let stream = Box::pin(stream);
3511
3512        let params =
3513            InvertedIndexParams::new("whitespace".to_string(), lance_tokenizer::Language::English)
3514                .with_position(false)
3515                .remove_stop_words(false)
3516                .stem(false)
3517                .max_token_length(None);
3518
3519        let mut builder = InvertedIndexBuilder::new(params);
3520        builder.update(stream, store.as_ref(), None).await?;
3521
3522        let index = InvertedIndex::load(store, None, &LanceCache::no_cache()).await?;
3523        assert_eq!(index.partitions.len(), 1);
3524        let partition = &index.partitions[0];
3525        let token_id = partition.tokens.get("hello").unwrap();
3526        let posting = partition
3527            .inverted_list
3528            .posting_list(token_id, false, &NoOpMetricsCollector)
3529            .await?;
3530
3531        let mut iter = posting.iter();
3532        let (doc_id, freq, positions) = iter.next().unwrap();
3533        assert_eq!(doc_id, 0);
3534        assert_eq!(freq, 2);
3535        assert!(positions.is_none());
3536        assert!(iter.next().is_none());
3537
3538        Ok(())
3539    }
3540
3541    #[tokio::test]
3542    async fn test_zero_token_string_documents_are_skipped_in_corpus_stats() -> Result<()> {
3543        let index_dir = TempDir::default();
3544        let store = Arc::new(LanceIndexStore::new(
3545            ObjectStore::local().into(),
3546            index_dir.obj_path(),
3547            Arc::new(LanceCache::no_cache()),
3548        ));
3549
3550        let batch = make_doc_batch_from_docs(vec![
3551            Some(""),
3552            Some("   "),
3553            Some("the"),
3554            Some("overlength"),
3555            None,
3556            Some("hello"),
3557        ]);
3558        let stream = RecordBatchStreamAdapter::new(batch.schema(), stream::iter(vec![Ok(batch)]));
3559        let params =
3560            InvertedIndexParams::new("whitespace".to_string(), lance_tokenizer::Language::English)
3561                .with_position(false)
3562                .remove_stop_words(true)
3563                .stem(false)
3564                .max_token_length(Some(6))
3565                .num_workers(1);
3566
3567        let mut builder = InvertedIndexBuilder::new(params);
3568        builder
3569            .update(Box::pin(stream), store.as_ref(), None)
3570            .await?;
3571
3572        let index = InvertedIndex::load(store, None, &LanceCache::no_cache()).await?;
3573        let (total_tokens, num_docs, token_docs) = index
3574            .bm25_stats_for_terms(&["hello".to_string()], None)
3575            .await?;
3576        assert_eq!(total_tokens, 1);
3577        assert_eq!(num_docs, 1);
3578        assert_eq!(token_docs, vec![1]);
3579
3580        let actual_scorer = MemBM25Scorer::new(
3581            total_tokens,
3582            num_docs,
3583            HashMap::from([("hello".to_string(), token_docs[0])]),
3584        );
3585        let expected_scorer = MemBM25Scorer::new(1, 1, HashMap::from([("hello".to_string(), 1)]));
3586        assert_eq!(
3587            actual_scorer.avg_doc_length(),
3588            expected_scorer.avg_doc_length()
3589        );
3590        assert_eq!(
3591            actual_scorer.query_weight("hello"),
3592            expected_scorer.query_weight("hello")
3593        );
3594
3595        Ok(())
3596    }
3597
3598    #[tokio::test]
3599    async fn test_all_empty_string_documents_build_empty_index() -> Result<()> {
3600        let index_dir = TempDir::default();
3601        let store = Arc::new(LanceIndexStore::new(
3602            ObjectStore::local().into(),
3603            index_dir.obj_path(),
3604            Arc::new(LanceCache::no_cache()),
3605        ));
3606
3607        let batch = make_doc_batch_from_docs(vec![Some(""), Some("   "), None]);
3608        let stream = RecordBatchStreamAdapter::new(batch.schema(), stream::iter(vec![Ok(batch)]));
3609        let params =
3610            InvertedIndexParams::new("whitespace".to_string(), lance_tokenizer::Language::English)
3611                .with_position(false)
3612                .remove_stop_words(false)
3613                .stem(false)
3614                .max_token_length(None)
3615                .num_workers(1);
3616
3617        let mut builder = InvertedIndexBuilder::new(params);
3618        builder
3619            .update(Box::pin(stream), store.as_ref(), None)
3620            .await?;
3621
3622        let index = InvertedIndex::load(store, None, &LanceCache::no_cache()).await?;
3623        assert!(index.partitions.is_empty());
3624        let statistics = index.statistics()?;
3625        assert_eq!(statistics["num_tokens"], 0);
3626        assert_eq!(statistics["num_docs"], 0);
3627
3628        Ok(())
3629    }
3630
3631    #[tokio::test]
3632    async fn test_all_empty_string_documents_do_not_create_tail_partition() -> Result<()> {
3633        let tokenizer = InvertedIndexParams::default().build()?;
3634        let store = Arc::new(CountingStore::new());
3635        let id_alloc = Arc::new(AtomicU64::new(0));
3636        let mut worker = IndexWorker::new(
3637            tokenizer,
3638            store,
3639            id_alloc,
3640            IndexWorkerConfig {
3641                with_position: false,
3642                format_version: InvertedListFormatVersion::V1,
3643                fragment_mask: None,
3644                token_set_format: TokenSetFormat::default(),
3645                worker_memory_limit_bytes: u64::MAX,
3646                block_size: InvertedIndexParams::default().block_size,
3647            },
3648        )
3649        .await?;
3650
3651        worker
3652            .process_batch(make_doc_batch_from_docs(vec![Some(""), Some("   "), None]))
3653            .await?;
3654        let output = worker.finish().await?;
3655
3656        assert!(output.partitions.is_empty());
3657        assert!(output.tail_partition.is_none());
3658
3659        Ok(())
3660    }
3661
3662    lance_testing::define_stage_event_progress!(RecordingProgress, IndexBuildProgress, Result<()>);
3663
3664    #[derive(Debug, Default)]
3665    struct FailingProgress;
3666
3667    #[async_trait]
3668    impl IndexBuildProgress for FailingProgress {
3669        async fn stage_start(&self, _stage: &str, _total: Option<u64>, _unit: &str) -> Result<()> {
3670            Ok(())
3671        }
3672
3673        async fn stage_progress(&self, _stage: &str, _completed: u64) -> Result<()> {
3674            Err(Error::io("injected progress failure"))
3675        }
3676
3677        async fn stage_complete(&self, _stage: &str) -> Result<()> {
3678            Ok(())
3679        }
3680    }
3681
3682    #[tokio::test]
3683    async fn test_builder_reports_progress_stages() -> Result<()> {
3684        let index_dir = TempDir::default();
3685        let store = Arc::new(LanceIndexStore::new(
3686            ObjectStore::local().into(),
3687            index_dir.obj_path(),
3688            Arc::new(LanceCache::no_cache()),
3689        ));
3690
3691        let batch1 = make_doc_batch("hello world", 0);
3692        let batch2 = make_doc_batch("goodbye world", 1);
3693        let total_rows = 2u64;
3694        let stream = RecordBatchStreamAdapter::new(
3695            batch1.schema(),
3696            stream::iter(vec![Ok(batch1), Ok(batch2)]),
3697        );
3698        let stream = Box::pin(stream);
3699
3700        let progress = Arc::new(RecordingProgress::default());
3701        let mut builder = InvertedIndexBuilder::new(InvertedIndexParams::default())
3702            .with_progress(progress.clone());
3703        builder.update(stream, store.as_ref(), None).await?;
3704
3705        let events = progress.recorded_events();
3706        let tags = events
3707            .iter()
3708            .map(|(kind, stage, _)| format!("{kind}:{stage}"))
3709            .collect::<Vec<_>>();
3710        let tokenize_progress = events
3711            .iter()
3712            .filter_map(|(kind, stage, completed)| {
3713                if kind == "progress" && stage == "tokenize_docs" {
3714                    Some(*completed)
3715                } else {
3716                    None
3717                }
3718            })
3719            .collect::<Vec<_>>();
3720
3721        let tokenize_start = tags
3722            .iter()
3723            .position(|e| e == "start:tokenize_docs")
3724            .expect("missing tokenize_docs start");
3725        let tokenize_complete = tags
3726            .iter()
3727            .position(|e| e == "complete:tokenize_docs")
3728            .expect("missing tokenize_docs complete");
3729        let copy_start = tags
3730            .iter()
3731            .position(|e| e == "start:copy_partitions")
3732            .expect("missing copy_partitions start");
3733        let copy_complete = tags
3734            .iter()
3735            .position(|e| e == "complete:copy_partitions")
3736            .expect("missing copy_partitions complete");
3737        let metadata_start = tags
3738            .iter()
3739            .position(|e| e == "start:write_metadata")
3740            .expect("missing write_metadata start");
3741        let metadata_complete = tags
3742            .iter()
3743            .position(|e| e == "complete:write_metadata")
3744            .expect("missing write_metadata complete");
3745
3746        assert!(tokenize_start < tokenize_complete);
3747        assert!(tokenize_complete < copy_start);
3748        assert!(copy_start < copy_complete);
3749        assert!(copy_complete < metadata_start);
3750        assert!(metadata_start < metadata_complete);
3751
3752        assert!(
3753            tags.iter().any(|e| e == "progress:tokenize_docs"),
3754            "expected progress callback for tokenize_docs"
3755        );
3756        assert!(
3757            tokenize_progress.len() >= 2,
3758            "expected at least two progress callbacks for tokenize_docs, got {tokenize_progress:?}"
3759        );
3760        assert_eq!(
3761            tokenize_progress.iter().copied().max().unwrap_or_default(),
3762            total_rows,
3763            "expected tokenize_docs progress to reach all rows"
3764        );
3765        assert!(
3766            tags.iter().any(|e| e == "progress:copy_partitions"),
3767            "expected progress callback for copy_partitions"
3768        );
3769        assert!(
3770            tags.iter().any(|e| e == "progress:write_metadata"),
3771            "expected progress callback for write_metadata"
3772        );
3773        assert!(
3774            !tags.iter().any(|e| e == "start:merge_partitions"),
3775            "merge_partitions should not run in the build-only path"
3776        );
3777
3778        Ok(())
3779    }
3780
3781    #[tokio::test]
3782    async fn test_builder_default_path_skips_merge_stage() -> Result<()> {
3783        let index_dir = TempDir::default();
3784        let store = Arc::new(LanceIndexStore::new(
3785            ObjectStore::local().into(),
3786            index_dir.obj_path(),
3787            Arc::new(LanceCache::no_cache()),
3788        ));
3789
3790        let batch = make_doc_batch("hello world", 0);
3791        let stream = RecordBatchStreamAdapter::new(batch.schema(), stream::iter(vec![Ok(batch)]));
3792        let stream = Box::pin(stream);
3793
3794        let progress = Arc::new(RecordingProgress::default());
3795        let mut builder = InvertedIndexBuilder::new(InvertedIndexParams::default())
3796            .with_progress(progress.clone());
3797        builder.update(stream, store.as_ref(), None).await?;
3798
3799        let tags = progress
3800            .recorded_events()
3801            .iter()
3802            .map(|(kind, stage, _)| format!("{kind}:{stage}"))
3803            .collect::<Vec<_>>();
3804
3805        assert!(
3806            tags.iter().any(|e| e == "start:copy_partitions"),
3807            "default path should copy finalized partitions"
3808        );
3809        assert!(
3810            !tags.iter().any(|e| e == "start:merge_partitions"),
3811            "default path should not run merge_partitions"
3812        );
3813        Ok(())
3814    }
3815
3816    #[tokio::test]
3817    async fn test_merge_index_files_reports_progress_stages() -> Result<()> {
3818        let index_dir = TempDir::default();
3819        let index_path = index_dir.obj_path();
3820        let object_store = ObjectStore::local();
3821        let store = Arc::new(LanceIndexStore::new(
3822            object_store.clone().into(),
3823            index_path.clone(),
3824            Arc::new(LanceCache::no_cache()),
3825        ));
3826
3827        for (fragment_id, row_id, doc) in [
3828            (1_u64 << 32, 0_u64, "hello world"),
3829            (2_u64 << 32, 1_u64, "goodbye world"),
3830        ] {
3831            let batch = make_doc_batch(doc, row_id);
3832            let stream =
3833                RecordBatchStreamAdapter::new(batch.schema(), stream::iter(vec![Ok(batch)]));
3834            let stream = Box::pin(stream);
3835            let mut builder = InvertedIndexBuilder::new_with_fragment_mask(
3836                InvertedIndexParams::default(),
3837                Some(fragment_id),
3838            )
3839            .with_progress(noop_progress());
3840            builder.update(stream, store.as_ref(), None).await?;
3841        }
3842
3843        let progress = Arc::new(RecordingProgress::default());
3844        merge_index_files(&object_store, &index_path, store.clone(), progress.clone()).await?;
3845
3846        let events = progress.recorded_events();
3847        let tags = events
3848            .iter()
3849            .map(|(kind, stage, _)| format!("{kind}:{stage}"))
3850            .collect::<Vec<_>>();
3851        let remap_progress = events
3852            .iter()
3853            .filter_map(|(kind, stage, completed)| {
3854                if kind == "progress" && stage == "remap_partition_files" {
3855                    Some(*completed)
3856                } else {
3857                    None
3858                }
3859            })
3860            .collect::<Vec<_>>();
3861        let read_start = tags
3862            .iter()
3863            .position(|e| e == "start:read_partition_metadata")
3864            .expect("missing read_partition_metadata start");
3865        let read_complete = tags
3866            .iter()
3867            .position(|e| e == "complete:read_partition_metadata")
3868            .expect("missing read_partition_metadata complete");
3869        let remap_start = tags
3870            .iter()
3871            .position(|e| e == "start:remap_partition_files")
3872            .expect("missing remap_partition_files start");
3873        let remap_complete = tags
3874            .iter()
3875            .position(|e| e == "complete:remap_partition_files")
3876            .expect("missing remap_partition_files complete");
3877        let metadata_start = tags
3878            .iter()
3879            .position(|e| e == "start:write_merged_metadata")
3880            .expect("missing write_merged_metadata start");
3881        let metadata_complete = tags
3882            .iter()
3883            .position(|e| e == "complete:write_merged_metadata")
3884            .expect("missing write_merged_metadata complete");
3885
3886        assert!(read_start < read_complete);
3887        assert!(read_complete < remap_start);
3888        assert!(remap_start < remap_complete);
3889        assert!(remap_complete < metadata_start);
3890        assert!(metadata_start < metadata_complete);
3891
3892        assert!(
3893            tags.iter().any(|e| e == "progress:read_partition_metadata"),
3894            "expected progress callback for read_partition_metadata"
3895        );
3896        assert_eq!(
3897            remap_progress.last().copied().unwrap_or_default(),
3898            6,
3899            "expected remap_partition_files progress to cover staged-to-final copies"
3900        );
3901        assert!(
3902            tags.iter().any(|e| e == "progress:write_merged_metadata"),
3903            "expected progress callback for write_merged_metadata"
3904        );
3905
3906        Ok(())
3907    }
3908
3909    #[tokio::test]
3910    async fn test_worker_memory_limit_rejects_single_large_doc() {
3911        let index_dir = TempDir::default();
3912        let store = Arc::new(LanceIndexStore::new(
3913            ObjectStore::local().into(),
3914            index_dir.obj_path(),
3915            Arc::new(LanceCache::no_cache()),
3916        ));
3917
3918        let batch = make_doc_batch("hello world", 42);
3919        let stream = RecordBatchStreamAdapter::new(batch.schema(), stream::iter(vec![Ok(batch)]));
3920        let stream = Box::pin(stream);
3921
3922        let mut builder =
3923            InvertedIndexBuilder::new(InvertedIndexParams::default().memory_limit_mb(0));
3924        let err = builder
3925            .update(stream, store.as_ref(), None)
3926            .await
3927            .expect_err("single doc should exceed zero worker memory limit");
3928        assert!(
3929            err.to_string().contains("row_id=42"),
3930            "unexpected error: {err}"
3931        );
3932    }
3933
3934    #[tokio::test]
3935    async fn test_worker_trims_position_temp_buffers() -> Result<()> {
3936        let tokenizer = InvertedIndexParams::default().with_position(true).build()?;
3937        let store = Arc::new(CountingStore::new());
3938        let id_alloc = Arc::new(AtomicU64::new(0));
3939        let mut worker = IndexWorker::new(
3940            tokenizer,
3941            store,
3942            id_alloc,
3943            IndexWorkerConfig {
3944                with_position: true,
3945                format_version: InvertedListFormatVersion::V1,
3946                fragment_mask: None,
3947                token_set_format: TokenSetFormat::default(),
3948                worker_memory_limit_bytes: u64::MAX,
3949                block_size: InvertedIndexParams::default().block_size,
3950            },
3951        )
3952        .await?;
3953
3954        let doc = (0..(MAX_RETAINED_TOKEN_IDS * 2))
3955            .map(|i| format!("tok{i}"))
3956            .collect::<Vec<_>>()
3957            .join(" ");
3958        worker.process_batch(make_doc_batch(&doc, 0)).await?;
3959
3960        assert!(worker.token_ids.is_empty());
3961        assert!(worker.token_ids.capacity() <= MAX_RETAINED_TOKEN_IDS);
3962        assert!(worker.memory_size >= worker.temporary_memory_size());
3963        Ok(())
3964    }
3965
3966    #[tokio::test]
3967    async fn test_worker_flush_keeps_position_temp_memory_bounded() -> Result<()> {
3968        let tokenizer = InvertedIndexParams::default().with_position(true).build()?;
3969        let store = Arc::new(CountingStore::new());
3970        let id_alloc = Arc::new(AtomicU64::new(0));
3971        let mut worker = IndexWorker::new(
3972            tokenizer,
3973            store,
3974            id_alloc,
3975            IndexWorkerConfig {
3976                with_position: true,
3977                format_version: InvertedListFormatVersion::V1,
3978                fragment_mask: None,
3979                token_set_format: TokenSetFormat::default(),
3980                worker_memory_limit_bytes: u64::MAX,
3981                block_size: InvertedIndexParams::default().block_size,
3982            },
3983        )
3984        .await?;
3985
3986        let doc = std::iter::repeat_n("common", 32_768)
3987            .collect::<Vec<_>>()
3988            .join(" ");
3989        let mut observed_post_flush_memory = Vec::new();
3990        for row_id in 0..8 {
3991            worker.process_batch(make_doc_batch(&doc, row_id)).await?;
3992            worker.flush().await?;
3993            observed_post_flush_memory.push(worker.memory_size);
3994        }
3995
3996        let max_memory = *observed_post_flush_memory.iter().max().unwrap();
3997        let min_memory = *observed_post_flush_memory.iter().min().unwrap();
3998        assert!(
3999            max_memory <= min_memory.saturating_add(256 * 1024),
4000            "post-flush worker memory drifted upward: {observed_post_flush_memory:?}"
4001        );
4002        Ok(())
4003    }
4004
4005    #[tokio::test]
4006    async fn test_worker_flush_writes_partition_directly() -> Result<()> {
4007        let tokenizer = InvertedIndexParams::default().with_position(true).build()?;
4008        let store = Arc::new(CountingStore::new());
4009        let id_alloc = Arc::new(AtomicU64::new(0));
4010        let mut worker = IndexWorker::new(
4011            tokenizer,
4012            store.clone(),
4013            id_alloc,
4014            IndexWorkerConfig {
4015                with_position: true,
4016                format_version: InvertedListFormatVersion::V1,
4017                fragment_mask: None,
4018                token_set_format: TokenSetFormat::default(),
4019                worker_memory_limit_bytes: u64::MAX,
4020                block_size: InvertedIndexParams::default().block_size,
4021            },
4022        )
4023        .await?;
4024        worker
4025            .process_batch(make_doc_batch("alpha beta gamma", 0))
4026            .await?;
4027        worker.flush().await?;
4028        assert!(store.write_count() > 0);
4029        Ok(())
4030    }
4031
4032    #[test]
4033    fn test_resolve_worker_memory_limit_uses_default_when_unset() {
4034        let params = InvertedIndexParams::default();
4035        assert_eq!(
4036            resolve_worker_memory_limit_bytes(&params, 8),
4037            *LANCE_FTS_PARTITION_SIZE << 20
4038        );
4039    }
4040
4041    #[test]
4042    fn test_resolve_num_workers_uses_default_when_unset() {
4043        let expected = default_num_workers().clamp(1, get_num_compute_intensive_cpus().max(1));
4044        assert_eq!(
4045            resolve_num_workers(&InvertedIndexParams::default()),
4046            expected
4047        );
4048    }
4049
4050    #[test]
4051    fn test_resolve_num_workers_clamps_requested_value() {
4052        let max_workers = get_num_compute_intensive_cpus().max(1);
4053        assert_eq!(
4054            resolve_num_workers(&InvertedIndexParams::default().num_workers(0)),
4055            1
4056        );
4057        assert_eq!(
4058            resolve_num_workers(&InvertedIndexParams::default().num_workers(max_workers + 10)),
4059            max_workers
4060        );
4061    }
4062
4063    #[test]
4064    fn test_resolve_worker_memory_limit_splits_total_memory_limit() {
4065        let params = InvertedIndexParams::default().memory_limit_mb(4096);
4066        assert_eq!(resolve_worker_memory_limit_bytes(&params, 16), 256 << 20);
4067    }
4068
4069    fn tail_with_docs(id: u64, num_docs: u64) -> TailPartition {
4070        let mut builder = InnerBuilder::new(id, false, TokenSetFormat::default());
4071        let token = builder.tokens.add(format!("token{}", id));
4072        builder
4073            .posting_lists
4074            .resize_with(builder.tokens.len(), || PostingListBuilder::new(false));
4075        for row in 0..num_docs {
4076            let doc = builder.docs.append(row, 1);
4077            builder.posting_lists[token as usize].add(doc, PositionRecorder::Count(1));
4078        }
4079        TailPartition { builder }
4080    }
4081
4082    #[test]
4083    fn test_merge_all_tail_partitions_combines_under_budget() -> Result<()> {
4084        let merged = merge_all_tail_partitions(
4085            vec![
4086                tail_with_docs(0, 4),
4087                tail_with_docs(1, 4),
4088                tail_with_docs(2, 4),
4089            ],
4090            u64::MAX,
4091        )?;
4092        assert_eq!(merged.len(), 1);
4093        assert_eq!(merged[0].id(), 0);
4094        assert_eq!(merged[0].docs.len(), 12);
4095        Ok(())
4096    }
4097
4098    #[test]
4099    fn test_merge_all_tail_partitions_splits_on_memory_budget() -> Result<()> {
4100        let tails = vec![
4101            tail_with_docs(0, 64),
4102            tail_with_docs(1, 64),
4103            tail_with_docs(2, 64),
4104            tail_with_docs(3, 64),
4105        ];
4106        let single = tails[0].builder.memory_size();
4107        // A budget below two builders' footprint must keep them separate.
4108        let merged = merge_all_tail_partitions(tails, single + 1)?;
4109        assert_eq!(merged.len(), 4);
4110        assert!(merged.iter().all(|builder| builder.docs.len() == 64));
4111        Ok(())
4112    }
4113
4114    #[test]
4115    fn test_merge_all_tail_partitions_returns_none_for_empty_input() -> Result<()> {
4116        assert!(merge_all_tail_partitions(Vec::new(), u64::MAX)?.is_empty());
4117        Ok(())
4118    }
4119
4120    /// Build an inverted index over `batches` with an explicit worker/memory
4121    /// layout and return it loaded, so tests can compare query behavior
4122    /// across partition shapes of the same corpus.
4123    async fn build_fuzzy_corpus_index(
4124        batches: Vec<RecordBatch>,
4125        num_workers: usize,
4126        memory_limit_mb: u64,
4127    ) -> Result<(TempDir, Arc<InvertedIndex>)> {
4128        let index_dir = TempDir::default();
4129        let store = Arc::new(LanceIndexStore::new(
4130            ObjectStore::local().into(),
4131            index_dir.obj_path(),
4132            Arc::new(LanceCache::no_cache()),
4133        ));
4134        let schema = batches[0].schema();
4135        let stream =
4136            RecordBatchStreamAdapter::new(schema, stream::iter(batches.into_iter().map(Ok)));
4137        let params =
4138            InvertedIndexParams::new("whitespace".to_string(), lance_tokenizer::Language::English)
4139                .with_position(false)
4140                .remove_stop_words(false)
4141                .stem(false)
4142                .max_token_length(None)
4143                .num_workers(num_workers)
4144                .memory_limit_mb(memory_limit_mb);
4145        let mut builder = InvertedIndexBuilder::new(params);
4146        builder
4147            .update(Box::pin(stream), store.as_ref(), None)
4148            .await?;
4149        let index = InvertedIndex::load(store, None, &LanceCache::no_cache()).await?;
4150        // The caller keeps the TempDir alive for as long as it queries the
4151        // loaded index.
4152        Ok((index_dir, index))
4153    }
4154
4155    /// Regression test for tail-partition splitting vs fuzzy queries
4156    /// (https://github.com/lance-format/lance/pull/7601#pullrequestreview):
4157    /// splitting the leftover tails into budget-sized partitions must not
4158    /// change fuzzy results while `max_expansions` is not the binding
4159    /// constraint. Every doc carries one member of two dense fuzzy families
4160    /// plus unique filler tokens, so a small worker memory budget forces
4161    /// flushes and a tail split, while a fuzzy query matches every doc.
4162    #[tokio::test]
4163    async fn test_tail_partition_split_preserves_fuzzy_results() -> Result<()> {
4164        use std::collections::HashMap;
4165
4166        use crate::prefilter::NoFilter;
4167        use crate::scalar::inverted::document_tokenizer::DocType;
4168        use crate::scalar::inverted::query::{FtsSearchParams, Operator, Tokens};
4169
4170        const NUM_DOCS: usize = 4000;
4171        const DOCS_PER_BATCH: usize = 20;
4172        let alpha_variants = ["alpha", "alphb", "alphc", "alphd", "alphe"];
4173        let beta_variants = ["beta", "betb", "betc", "betd", "bete"];
4174
4175        let schema = Arc::new(Schema::new(vec![
4176            Field::new("doc", DataType::Utf8, true),
4177            Field::new(ROW_ID, DataType::UInt64, false),
4178        ]));
4179        let batches = (0..NUM_DOCS / DOCS_PER_BATCH)
4180            .map(|batch_idx| {
4181                let mut docs = Vec::with_capacity(DOCS_PER_BATCH);
4182                let mut row_ids = Vec::with_capacity(DOCS_PER_BATCH);
4183                for row in 0..DOCS_PER_BATCH {
4184                    let i = batch_idx * DOCS_PER_BATCH + row;
4185                    // 8 unique filler tokens per doc grow the vocabulary so
4186                    // the corpus comfortably exceeds the small worker budget.
4187                    docs.push(format!(
4188                        "{} {} f{i}a f{i}b f{i}c f{i}d f{i}e f{i}f f{i}g f{i}h",
4189                        alpha_variants[i % alpha_variants.len()],
4190                        beta_variants[i % beta_variants.len()],
4191                    ));
4192                    row_ids.push(i as u64);
4193                }
4194                RecordBatch::try_new(
4195                    schema.clone(),
4196                    vec![
4197                        Arc::new(StringArray::from(docs)),
4198                        Arc::new(UInt64Array::from(row_ids)),
4199                    ],
4200                )
4201                .unwrap()
4202            })
4203            .collect::<Vec<_>>();
4204
4205        // Reference shape: everything in one partition.
4206        let (_ref_dir, ref_index) = build_fuzzy_corpus_index(batches.clone(), 1, 1024).await?;
4207        assert_eq!(
4208            ref_index.partitions.len(),
4209            1,
4210            "reference build should stay in a single partition"
4211        );
4212
4213        // Tail-heavy shape: a 1MB budget across 2 workers forces flushes and
4214        // splits the leftover tails by the same budget.
4215        let (_split_dir, split_index) = build_fuzzy_corpus_index(batches, 2, 1).await?;
4216        assert!(
4217            split_index.partitions.len() > 1,
4218            "small worker budget should produce multiple partitions, got {}",
4219            split_index.partitions.len()
4220        );
4221
4222        async fn fuzzy_search(
4223            index: &InvertedIndex,
4224            tokens: &[&str],
4225            operator: Operator,
4226        ) -> HashMap<u64, f32> {
4227            let tokens = Arc::new(Tokens::new(
4228                tokens.iter().map(|t| t.to_string()).collect(),
4229                DocType::Text,
4230            ));
4231            // max_expansions=50 is far above the 10 family variants, so the
4232            // per-partition vs global cap distinction cannot bind here.
4233            let params = Arc::new(
4234                FtsSearchParams::new()
4235                    .with_limit(Some(NUM_DOCS))
4236                    .with_fuzziness(Some(1))
4237                    .with_max_expansions(50),
4238            );
4239            let (row_ids, scores) = index
4240                .bm25_search(
4241                    tokens,
4242                    params,
4243                    operator,
4244                    Arc::new(NoFilter),
4245                    Arc::new(NoOpMetricsCollector),
4246                    None,
4247                )
4248                .await
4249                .unwrap();
4250            row_ids.into_iter().zip(scores).collect()
4251        }
4252
4253        for (tokens, operator) in [
4254            (vec!["alphx"], Operator::Or),
4255            (vec!["alphx", "betx"], Operator::Or),
4256            (vec!["alphx", "betx"], Operator::And),
4257        ] {
4258            let reference = fuzzy_search(&ref_index, &tokens, operator).await;
4259            let split = fuzzy_search(&split_index, &tokens, operator).await;
4260            assert_eq!(
4261                reference.len(),
4262                NUM_DOCS,
4263                "every doc carries a family variant, {tokens:?} {operator:?} should match all"
4264            );
4265            assert_eq!(
4266                reference, split,
4267                "fuzzy {operator:?} results must not depend on the tail partition shape for {tokens:?}"
4268            );
4269        }
4270
4271        Ok(())
4272    }
4273
4274    #[test]
4275    fn test_merge_tail_partition_group_combines_tail_builders() -> Result<()> {
4276        let mut first = InnerBuilder::new(0, false, TokenSetFormat::default());
4277        let hello = first.tokens.add("hello".to_owned());
4278        first
4279            .posting_lists
4280            .resize_with(first.tokens.len(), || PostingListBuilder::new(false));
4281        let first_doc = first.docs.append(10, 1);
4282        first.posting_lists[hello as usize].add(first_doc, PositionRecorder::Count(1));
4283
4284        let mut second = InnerBuilder::new(1, false, TokenSetFormat::default());
4285        let world = second.tokens.add("world".to_owned());
4286        second
4287            .posting_lists
4288            .resize_with(second.tokens.len(), || PostingListBuilder::new(false));
4289        let second_doc = second.docs.append(20, 2);
4290        second.posting_lists[world as usize].add(second_doc, PositionRecorder::Count(2));
4291
4292        let merged = merge_all_tail_partitions(
4293            vec![
4294                TailPartition { builder: first },
4295                TailPartition { builder: second },
4296            ],
4297            u64::MAX,
4298        )?;
4299        assert_eq!(merged.len(), 1);
4300        let merged = &merged[0];
4301
4302        assert_eq!(merged.id(), 0);
4303        assert_eq!(merged.docs.len(), 2);
4304        assert_eq!(merged.tokens.len(), 2);
4305        assert_eq!(merged.posting_lists.len(), 2);
4306        assert_eq!(
4307            merged.posting_lists[merged.tokens.get("hello").unwrap() as usize].len(),
4308            1
4309        );
4310        assert_eq!(
4311            merged.posting_lists[merged.tokens.get("world").unwrap() as usize].len(),
4312            1
4313        );
4314        Ok(())
4315    }
4316
4317    #[test]
4318    fn test_merge_from_after_remap_does_not_panic() {
4319        // `first` is the merge accumulator. Give it three tokens, then remap away the
4320        // middle one, mirroring filter_old_data dropping a token whose postings emptied.
4321        let mut first = InnerBuilder::new(0, false, TokenSetFormat::default());
4322        for token in ["a", "b", "c"] {
4323            first.tokens.add(token.to_owned());
4324        }
4325        first
4326            .posting_lists
4327            .resize_with(first.tokens.len(), || PostingListBuilder::new(false));
4328        let first_doc = first.docs.append(10, 1);
4329        first.posting_lists[0].add(first_doc, PositionRecorder::Count(1)); // "a"
4330        first.posting_lists[2].add(first_doc, PositionRecorder::Count(1)); // "c"
4331
4332        // Remove token "b" (id 1) and compact its (empty) posting list to match.
4333        first.tokens.remap(&[1]);
4334        first.posting_lists.remove(1);
4335        assert_eq!(first.tokens.len(), first.posting_lists.len());
4336
4337        // `second` contributes a brand-new token absent from `first`. Before the fix,
4338        // get_or_add returned the stale next_id, indexing past posting_lists.
4339        let mut second = InnerBuilder::new(1, false, TokenSetFormat::default());
4340        let zeta = second.tokens.add("zeta".to_owned());
4341        second
4342            .posting_lists
4343            .resize_with(second.tokens.len(), || PostingListBuilder::new(false));
4344        let second_doc = second.docs.append(20, 1);
4345        second.posting_lists[zeta as usize].add(second_doc, PositionRecorder::Count(1));
4346
4347        first.merge_from(second).unwrap();
4348
4349        assert_eq!(first.tokens.len(), 3);
4350        assert_eq!(first.posting_lists.len(), 3);
4351        let zeta_id = first.tokens.get("zeta").expect("zeta should be merged in");
4352        assert!((zeta_id as usize) < first.posting_lists.len());
4353    }
4354
4355    // FST token file with a stale next_id (above the token count), as a pre-#7115 writer left.
4356    async fn write_stale_next_id_token_file(store: &dyn IndexStore, partition_id: u64) {
4357        let mut tokens = TokenSet::default();
4358        tokens.add("alpha".to_owned());
4359        tokens.add("gamma".to_owned());
4360        assert_eq!(tokens.len(), 2);
4361        tokens.next_id = 9;
4362        let batch = tokens.to_batch(TokenSetFormat::Fst).unwrap();
4363        let mut writer = store
4364            .new_index_file(&token_file_path(partition_id), batch.schema())
4365            .await
4366            .unwrap();
4367        writer.write_record_batch(batch).await.unwrap();
4368        writer.finish().await.unwrap();
4369    }
4370
4371    // load_fst recomputes next_id from the token count rather than trusting the persisted value.
4372    #[tokio::test]
4373    async fn test_load_fst_recomputes_stale_next_id() {
4374        let index_dir = TempDir::default();
4375        let store = Arc::new(LanceIndexStore::new(
4376            ObjectStore::local().into(),
4377            index_dir.obj_path(),
4378            Arc::new(LanceCache::no_cache()),
4379        ));
4380
4381        write_stale_next_id_token_file(store.as_ref(), 0).await;
4382        let reader = store.open_index_file(&token_file_path(0)).await.unwrap();
4383        let tokens = TokenSet::load(reader, TokenSetFormat::Fst).await.unwrap();
4384        assert_eq!(tokens.len(), 2);
4385        assert_eq!(tokens.next_id(), 2);
4386    }
4387
4388    // A stale next_id loaded from disk must not leak an out-of-range token id into a merge.
4389    #[tokio::test]
4390    async fn test_merge_with_stale_next_id_token_file_does_not_panic() {
4391        let index_dir = TempDir::default();
4392        let store = Arc::new(LanceIndexStore::new(
4393            ObjectStore::local().into(),
4394            index_dir.obj_path(),
4395            Arc::new(LanceCache::no_cache()),
4396        ));
4397
4398        write_stale_next_id_token_file(store.as_ref(), 0).await;
4399        let reader = store.open_index_file(&token_file_path(0)).await.unwrap();
4400        let tokens = TokenSet::load(reader, TokenSetFormat::Fst)
4401            .await
4402            .unwrap()
4403            .into_mutable();
4404
4405        let mut first = InnerBuilder::new(0, false, TokenSetFormat::Fst);
4406        first.set_tokens(tokens);
4407        first
4408            .posting_lists
4409            .resize_with(first.tokens.len(), || PostingListBuilder::new(false));
4410        let doc = first.docs.append(10, 1);
4411        first.posting_lists[0].add(doc, PositionRecorder::Count(1));
4412        first.posting_lists[1].add(doc, PositionRecorder::Count(1));
4413
4414        let mut second = InnerBuilder::new(1, false, TokenSetFormat::Fst);
4415        let zeta = second.tokens.add("zeta".to_owned());
4416        second
4417            .posting_lists
4418            .resize_with(second.tokens.len(), || PostingListBuilder::new(false));
4419        let second_doc = second.docs.append(20, 1);
4420        second.posting_lists[zeta as usize].add(second_doc, PositionRecorder::Count(1));
4421
4422        first.merge_from(second).unwrap();
4423        assert_eq!(first.tokens.len(), 3);
4424        assert_eq!(first.posting_lists.len(), 3);
4425        let zeta_id = first.tokens.get("zeta").expect("zeta should be merged in");
4426        assert!((zeta_id as usize) < first.posting_lists.len());
4427    }
4428
4429    #[tokio::test]
4430    async fn test_update_index_returns_worker_error_when_workers_exit_during_dispatch() {
4431        let num_batches = (*LANCE_FTS_NUM_SHARDS * 2 + 1) as u64;
4432        let index_dir = TempDir::default();
4433        let store = Arc::new(LanceIndexStore::new(
4434            ObjectStore::local().into(),
4435            index_dir.obj_path(),
4436            Arc::new(LanceCache::no_cache()),
4437        ));
4438        let schema = make_doc_batch("hello world", 0).schema();
4439        let stream = RecordBatchStreamAdapter::new(
4440            schema,
4441            stream::iter((0..num_batches).map(|row_id| Ok(make_doc_batch("hello world", row_id)))),
4442        );
4443        let stream = Box::pin(stream);
4444
4445        let mut builder = InvertedIndexBuilder::new(InvertedIndexParams::default())
4446            .with_progress(Arc::new(FailingProgress));
4447
4448        let result = tokio::time::timeout(
4449            Duration::from_secs(5),
4450            builder.update_index(stream, store.as_ref()),
4451        )
4452        .await
4453        .expect("update_index should not hang")
4454        .expect_err("worker failure should be returned");
4455
4456        assert!(
4457            result.to_string().contains("injected progress failure"),
4458            "unexpected error: {result}"
4459        );
4460    }
4461
4462    #[tokio::test]
4463    async fn test_new_index_has_empty_deleted_fragments() {
4464        let index_dir = TempDir::default();
4465        let store = Arc::new(LanceIndexStore::new(
4466            ObjectStore::local().into(),
4467            index_dir.obj_path(),
4468            Arc::new(LanceCache::no_cache()),
4469        ));
4470
4471        let batch = make_doc_batch("hello world", 0);
4472        let stream = RecordBatchStreamAdapter::new(batch.schema(), stream::iter(vec![Ok(batch)]));
4473        let stream = Box::pin(stream);
4474
4475        let mut builder = InvertedIndexBuilder::new(InvertedIndexParams::default());
4476        builder.update(stream, store.as_ref(), None).await.unwrap();
4477
4478        let index = InvertedIndex::load(store, None, &LanceCache::no_cache())
4479            .await
4480            .unwrap();
4481        assert!(
4482            index.deleted_fragments().is_empty(),
4483            "new index should have empty deleted fragments, got {:?}",
4484            index.deleted_fragments()
4485        );
4486    }
4487
4488    #[tokio::test]
4489    async fn test_remap_preserves_deleted_fragments() {
4490        let src_dir = TempDir::default();
4491        let dest_dir = TempDir::default();
4492        let src_store = Arc::new(LanceIndexStore::new(
4493            ObjectStore::local().into(),
4494            src_dir.obj_path(),
4495            Arc::new(LanceCache::no_cache()),
4496        ));
4497        let dest_store = Arc::new(LanceIndexStore::new(
4498            ObjectStore::local().into(),
4499            dest_dir.obj_path(),
4500            Arc::new(LanceCache::no_cache()),
4501        ));
4502
4503        // Build an initial index with some deleted fragments
4504        let batch = make_doc_batch("hello world", 0);
4505        let stream = RecordBatchStreamAdapter::new(batch.schema(), stream::iter(vec![Ok(batch)]));
4506        let stream = Box::pin(stream);
4507
4508        let initial_deleted = RoaringBitmap::from_iter([5, 10, 42]);
4509        let mut builder = InvertedIndexBuilder::from_existing_index(
4510            InvertedIndexParams::default(),
4511            None,
4512            Vec::new(),
4513            TokenSetFormat::default(),
4514            None,
4515            initial_deleted.clone(),
4516        );
4517        builder
4518            .update(stream, src_store.as_ref(), None)
4519            .await
4520            .unwrap();
4521
4522        // Load it back and confirm the invalidated fragments are set
4523        let index = InvertedIndex::load(src_store.clone(), None, &LanceCache::no_cache())
4524            .await
4525            .unwrap();
4526        assert_eq!(index.deleted_fragments(), &initial_deleted);
4527
4528        // Remap the index via the ScalarIndex trait method
4529        use crate::scalar::ScalarIndex;
4530        let mapping = HashMap::from([(0u64, Some(50 << 32))]);
4531        index
4532            .remap(&RowAddrRemap::direct(mapping), dest_store.as_ref())
4533            .await
4534            .unwrap();
4535
4536        // Reload from dest and verify deleted fragments are preserved
4537        let remapped_index = InvertedIndex::load(dest_store.clone(), None, &LanceCache::no_cache())
4538            .await
4539            .unwrap();
4540        assert_eq!(
4541            remapped_index.deleted_fragments(),
4542            &initial_deleted,
4543            "remap should preserve deleted fragments"
4544        );
4545    }
4546
4547    #[tokio::test]
4548    async fn test_update_grows_deleted_fragments_from_old_data_filter() {
4549        let index_dir = TempDir::default();
4550        let store = Arc::new(LanceIndexStore::new(
4551            ObjectStore::local().into(),
4552            index_dir.obj_path(),
4553            Arc::new(LanceCache::no_cache()),
4554        ));
4555
4556        // Build an initial index with no deleted fragments
4557        let batch = make_doc_batch("hello world", 0);
4558        let stream = RecordBatchStreamAdapter::new(batch.schema(), stream::iter(vec![Ok(batch)]));
4559        let stream = Box::pin(stream);
4560
4561        let mut builder = InvertedIndexBuilder::new(InvertedIndexParams::default());
4562        builder.update(stream, store.as_ref(), None).await.unwrap();
4563
4564        // Load the index and update it with an old_data_filter that invalidates fragments
4565        let index = InvertedIndex::load(store.clone(), None, &LanceCache::no_cache())
4566            .await
4567            .unwrap();
4568        assert!(index.deleted_fragments().is_empty());
4569
4570        let update_dir = TempDir::default();
4571        let update_store = Arc::new(LanceIndexStore::new(
4572            ObjectStore::local().into(),
4573            update_dir.obj_path(),
4574            Arc::new(LanceCache::no_cache()),
4575        ));
4576
4577        let batch2 = make_doc_batch("new document", 1 << 32 | 1);
4578        let stream2 =
4579            RecordBatchStreamAdapter::new(batch2.schema(), stream::iter(vec![Ok(batch2)]));
4580        let stream2 = Box::pin(stream2);
4581
4582        let old_data_filter = Some(crate::scalar::OldIndexDataFilter::Fragments {
4583            to_keep: RoaringBitmap::from_iter([0]),
4584            to_remove: RoaringBitmap::from_iter([3, 7]),
4585        });
4586
4587        // Use ScalarIndex::update trait method
4588        use crate::scalar::ScalarIndex;
4589        index
4590            .update(stream2, update_store.as_ref(), old_data_filter)
4591            .await
4592            .unwrap();
4593
4594        let updated_index =
4595            InvertedIndex::load(update_store.clone(), None, &LanceCache::no_cache())
4596                .await
4597                .unwrap();
4598        assert_eq!(
4599            updated_index.deleted_fragments(),
4600            &RoaringBitmap::from_iter([3, 7]),
4601            "update should add deleted fragments from old_data_filter"
4602        );
4603    }
4604
4605    #[tokio::test]
4606    async fn test_update_accumulates_deleted_fragments() {
4607        let dir1 = TempDir::default();
4608        let store1 = Arc::new(LanceIndexStore::new(
4609            ObjectStore::local().into(),
4610            dir1.obj_path(),
4611            Arc::new(LanceCache::no_cache()),
4612        ));
4613
4614        // Build initial index
4615        let batch = make_doc_batch("hello world", 0);
4616        let stream = RecordBatchStreamAdapter::new(batch.schema(), stream::iter(vec![Ok(batch)]));
4617        let stream = Box::pin(stream);
4618
4619        let mut builder = InvertedIndexBuilder::new(InvertedIndexParams::default());
4620        builder.update(stream, store1.as_ref(), None).await.unwrap();
4621
4622        // First update: delete fragments 3 and 7
4623        let index = InvertedIndex::load(store1.clone(), None, &LanceCache::no_cache())
4624            .await
4625            .unwrap();
4626
4627        let dir2 = TempDir::default();
4628        let store2 = Arc::new(LanceIndexStore::new(
4629            ObjectStore::local().into(),
4630            dir2.obj_path(),
4631            Arc::new(LanceCache::no_cache()),
4632        ));
4633
4634        let batch2 = make_doc_batch("second doc", 1 << 32 | 1);
4635        let stream2 =
4636            RecordBatchStreamAdapter::new(batch2.schema(), stream::iter(vec![Ok(batch2)]));
4637        let stream2 = Box::pin(stream2);
4638
4639        use crate::scalar::ScalarIndex;
4640        index
4641            .update(
4642                stream2,
4643                store2.as_ref(),
4644                Some(crate::scalar::OldIndexDataFilter::Fragments {
4645                    to_keep: RoaringBitmap::from_iter([0]),
4646                    to_remove: RoaringBitmap::from_iter([3, 7]),
4647                }),
4648            )
4649            .await
4650            .unwrap();
4651
4652        // Second update: invalidate additional fragments 12 and 15
4653        let index2 = InvertedIndex::load(store2.clone(), None, &LanceCache::no_cache())
4654            .await
4655            .unwrap();
4656        assert_eq!(
4657            index2.deleted_fragments(),
4658            &RoaringBitmap::from_iter([3, 7])
4659        );
4660
4661        let dir3 = TempDir::default();
4662        let store3 = Arc::new(LanceIndexStore::new(
4663            ObjectStore::local().into(),
4664            dir3.obj_path(),
4665            Arc::new(LanceCache::no_cache()),
4666        ));
4667
4668        let batch3 = make_doc_batch("third doc", 2 << 32 | 2);
4669        let stream3 =
4670            RecordBatchStreamAdapter::new(batch3.schema(), stream::iter(vec![Ok(batch3)]));
4671        let stream3 = Box::pin(stream3);
4672
4673        index2
4674            .update(
4675                stream3,
4676                store3.as_ref(),
4677                Some(crate::scalar::OldIndexDataFilter::Fragments {
4678                    to_keep: RoaringBitmap::from_iter([0, 1]),
4679                    to_remove: RoaringBitmap::from_iter([12, 15]),
4680                }),
4681            )
4682            .await
4683            .unwrap();
4684
4685        let index3 = InvertedIndex::load(store3.clone(), None, &LanceCache::no_cache())
4686            .await
4687            .unwrap();
4688        assert_eq!(
4689            index3.deleted_fragments(),
4690            &RoaringBitmap::from_iter([3, 7, 12, 15]),
4691            "deleted fragments should accumulate across updates"
4692        );
4693    }
4694
4695    #[tokio::test]
4696    async fn test_update_with_rowid_filter_does_not_grow_deleted_fragments() {
4697        let index_dir = TempDir::default();
4698        let store = Arc::new(LanceIndexStore::new(
4699            ObjectStore::local().into(),
4700            index_dir.obj_path(),
4701            Arc::new(LanceCache::no_cache()),
4702        ));
4703
4704        let batch = make_doc_batch("hello world", 0);
4705        let stream = RecordBatchStreamAdapter::new(batch.schema(), stream::iter(vec![Ok(batch)]));
4706        let stream = Box::pin(stream);
4707
4708        let mut builder = InvertedIndexBuilder::new(InvertedIndexParams::default());
4709        builder.update(stream, store.as_ref(), None).await.unwrap();
4710
4711        let index = InvertedIndex::load(store.clone(), None, &LanceCache::no_cache())
4712            .await
4713            .unwrap();
4714
4715        let update_dir = TempDir::default();
4716        let update_store = Arc::new(LanceIndexStore::new(
4717            ObjectStore::local().into(),
4718            update_dir.obj_path(),
4719            Arc::new(LanceCache::no_cache()),
4720        ));
4721
4722        let batch2 = make_doc_batch("new doc", 1);
4723        let stream2 =
4724            RecordBatchStreamAdapter::new(batch2.schema(), stream::iter(vec![Ok(batch2)]));
4725        let stream2 = Box::pin(stream2);
4726
4727        // Use RowIds filter instead of Fragments — should not affect deleted_fragments
4728        let mut valid_ids = lance_select::RowAddrTreeMap::new();
4729        valid_ids.insert(0);
4730        let old_data_filter = Some(crate::scalar::OldIndexDataFilter::RowIds(valid_ids));
4731
4732        use crate::scalar::ScalarIndex;
4733        index
4734            .update(stream2, update_store.as_ref(), old_data_filter)
4735            .await
4736            .unwrap();
4737
4738        let updated_index =
4739            InvertedIndex::load(update_store.clone(), None, &LanceCache::no_cache())
4740                .await
4741                .unwrap();
4742        assert!(
4743            updated_index.deleted_fragments().is_empty(),
4744            "RowIds filter should not add to deleted fragments"
4745        );
4746    }
4747}