1use 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
42pub const BLOCK_SIZE: usize = BitPacker4x::BLOCK_LEN;
46
47pub 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});
56pub 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
98fn 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 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 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 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 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(stream);
456 drop(sender);
457 log::info!("dispatching elapsed: {:?}", start.elapsed());
458
459 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 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 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 pub(crate) async fn write_part_metadata(
606 &self,
607 dest_store: &dyn IndexStore,
608 partition: u64, ) -> 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 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#[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 pub fn set_tokens(&mut self, tokens: TokenSet) {
889 self.tokens = tokens;
890 }
891
892 pub fn set_docs(&mut self, docs: DocSet) {
894 self.docs = docs;
895 }
896
897 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 let removed = self.docs.remap(mapping);
906
907 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 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 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 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 break;
1158 };
1159 if tx.send(batch).await.is_err() {
1160 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 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 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 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 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
2097async fn merge_metadata_files(
2099 store: Arc<dyn IndexStore>,
2100 part_metadata_files: &[String],
2101 progress: Arc<dyn IndexBuildProgress>,
2102) -> Result<()> {
2103 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 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 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 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
2239pub 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 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 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(¶ms, 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(¶ms, 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 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 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 Ok((index_dir, index))
4153 }
4154
4155 #[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 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 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 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 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 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)); first.posting_lists[2].add(first_doc, PositionRecorder::Count(1)); first.tokens.remap(&[1]);
4334 first.posting_lists.remove(1);
4335 assert_eq!(first.tokens.len(), first.posting_lists.len());
4336
4337 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 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 #[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 #[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 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 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 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 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 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 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 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 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 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 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 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}