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