1mod dense;
4mod fast_fields;
5mod postings;
6mod sparse;
7mod store;
8
9pub(crate) use dense::AnnWriteMode;
10
11use std::io::Write as _;
12use std::sync::Arc;
13use std::sync::atomic::{AtomicBool, Ordering};
14
15use rustc_hash::FxHashMap;
16
17use super::OffsetWriter;
18use super::reader::SegmentReader;
19use super::types::{FieldStats, SegmentFiles, SegmentId, SegmentMeta};
20use crate::Result;
21use crate::directories::{Directory, DirectoryWriter};
22use crate::dsl::{FieldType, Schema};
23use crate::index::{ReorderConcurrencyGate, ReorderPriority};
24use crate::structures::SparseFormat;
25
26fn doc_offsets(segments: &[SegmentReader]) -> Result<Vec<u32>> {
30 let mut offsets = Vec::with_capacity(segments.len());
31 let mut acc = 0u32;
32 for seg in segments {
33 offsets.push(acc);
34 acc = acc.checked_add(seg.num_docs()).ok_or_else(|| {
35 crate::Error::Internal(format!(
36 "Total document count across segments exceeds u32::MAX ({})",
37 u32::MAX
38 ))
39 })?;
40 }
41 Ok(offsets)
42}
43
44#[derive(Clone, Copy, Debug, Default)]
49struct MergeCapacity(u64);
50
51impl MergeCapacity {
52 #[inline]
53 fn add(&mut self, count: u64) -> Option<u64> {
54 self.0 = self.0.saturating_add(count);
55 (self.0 > u64::from(u32::MAX)).then_some(self.0)
56 }
57}
58
59fn field_capacity_error(
60 field_id: u32,
61 field_name: &str,
62 value_kind: &str,
63 count: u64,
64) -> crate::Error {
65 crate::Error::Schema(format!(
66 "merge would produce {count} {value_kind} for field {field_id} ('{field_name}'), \
67 exceeding the segment format limit {}; lower max_segment_docs for this \
68 multi-valued field",
69 u32::MAX,
70 ))
71}
72
73#[derive(Debug, Clone, Default)]
75pub struct MergeStats {
76 pub terms_processed: usize,
78 pub term_dict_bytes: usize,
80 pub postings_bytes: usize,
82 pub store_bytes: usize,
84 pub vectors_bytes: usize,
86 pub sparse_bytes: usize,
88 pub bp_converged: bool,
94 pub fast_bytes: usize,
96}
97
98impl std::fmt::Display for MergeStats {
99 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
100 write!(
101 f,
102 "terms={}, term_dict={}, postings={}, store={}, dense_vectors={}, sparse_vectors={}, fast_fields={}",
103 self.terms_processed,
104 crate::format_bytes(self.term_dict_bytes as u64),
105 crate::format_bytes(self.postings_bytes as u64),
106 crate::format_bytes(self.store_bytes as u64),
107 crate::format_bytes(self.vectors_bytes as u64),
108 crate::format_bytes(self.sparse_bytes as u64),
109 crate::format_bytes(self.fast_bytes as u64),
110 )
111 }
112}
113
114pub use super::types::TrainedVectorStructures;
116
117pub(crate) fn block_in_place_if_multithread<R>(f: impl FnOnce() -> R) -> R {
121 if tokio::runtime::Handle::try_current()
122 .map(|h| h.runtime_flavor() == tokio::runtime::RuntimeFlavor::MultiThread)
123 .unwrap_or(false)
124 {
125 tokio::task::block_in_place(f)
126 } else {
127 f()
128 }
129}
130
131fn try_copy_local_file_range(
135 writer: &mut OffsetWriter,
136 source_path: &std::path::Path,
137 source_range: std::ops::Range<u64>,
138 cancellation: Option<&AtomicBool>,
139 context: &str,
140) -> Result<bool> {
141 const COPY_CHUNK: usize = 16 * 1024 * 1024;
142
143 let source = std::fs::File::open(source_path).map_err(crate::Error::Io)?;
144 let expected = source_range
145 .end
146 .checked_sub(source_range.start)
147 .ok_or_else(|| crate::Error::Corruption(format!("{context} source range is inverted")))?;
148 let mut source_offset = source_range.start;
149 let mut copied = 0u64;
150 while copied < expected {
151 if cancellation.is_some_and(|flag| flag.load(Ordering::Acquire)) {
152 return Err(crate::Error::IndexClosed);
153 }
154 let requested = usize::try_from((expected - copied).min(COPY_CHUNK as u64))
155 .expect("bounded copy chunk fits usize");
156 match writer.copy_from_file_range(&source, &mut source_offset, requested) {
157 Ok(0) => {
158 return Err(crate::Error::Io(std::io::Error::new(
159 std::io::ErrorKind::UnexpectedEof,
160 format!(
161 "kernel {context} copy stopped after {copied} of {expected} bytes from \
162 {source_path:?}",
163 ),
164 )));
165 }
166 Ok(count) => {
167 copied = copied.checked_add(count as u64).ok_or_else(|| {
168 crate::Error::Internal(format!("{context} copied-byte count exceeds u64"))
169 })?;
170 }
171 Err(error) if error.kind() == std::io::ErrorKind::Interrupted => continue,
172 Err(error) if error.kind() == std::io::ErrorKind::Unsupported && copied == 0 => {
173 return Ok(false);
174 }
175 Err(error) => return Err(crate::Error::Io(error)),
176 }
177 }
178
179 static LOGGED: AtomicBool = AtomicBool::new(false);
180 if !LOGGED.swap(true, Ordering::Relaxed) {
181 log::info!("[merge] byte-identical local ranges use kernel-assisted file copies");
182 }
183 Ok(true)
184}
185
186pub(super) fn copy_local_range_or_bytes(
189 writer: &mut OffsetWriter,
190 source_path: Option<&std::path::Path>,
191 source_range: std::ops::Range<u64>,
192 bytes: &[u8],
193 cancellation: Option<&AtomicBool>,
194 context: &str,
195) -> Result<()> {
196 let range_len = source_range
197 .end
198 .checked_sub(source_range.start)
199 .ok_or_else(|| crate::Error::Corruption(format!("{context} source range is inverted")))?;
200 if range_len != bytes.len() as u64 {
201 return Err(crate::Error::Corruption(format!(
202 "{context} source range is {range_len} bytes but mapped section is {} bytes",
203 bytes.len(),
204 )));
205 }
206 if bytes.is_empty() {
207 return Ok(());
208 }
209
210 if let Some(path) = source_path
211 && try_copy_local_file_range(writer, path, source_range, cancellation, context)?
212 {
213 return Ok(());
214 }
215
216 for chunk in bytes.chunks(4 * 1024 * 1024) {
217 if cancellation.is_some_and(|flag| flag.load(Ordering::Acquire)) {
218 return Err(crate::Error::IndexClosed);
219 }
220 writer.write_all(chunk).map_err(crate::Error::Io)?;
221 }
222 Ok(())
223}
224
225pub(crate) async fn append_and_delete_temp<D: DirectoryWriter>(
229 directory: &D,
230 path: &std::path::Path,
231 expected_bytes: u64,
232 writer: &mut OffsetWriter,
233 index_label: &str,
234) -> Result<()> {
235 use std::io::Write as _;
236
237 const COPY_CHUNK: u64 = 4 * 1024 * 1024;
238 let actual_bytes = directory.file_size(path).await?;
239 if actual_bytes != expected_bytes {
240 return Err(crate::Error::Corruption(format!(
241 "temporary sparse section {:?} has {} bytes, expected {}",
242 path, actual_bytes, expected_bytes,
243 )));
244 }
245 let copied_in_kernel = if let Some(local_path) = directory.local_path(path) {
246 block_in_place_if_multithread(|| {
247 try_copy_local_file_range(
248 writer,
249 &local_path,
250 0..expected_bytes,
251 None,
252 "sparse scratch",
253 )
254 })?
255 } else {
256 false
257 };
258 if !copied_in_kernel {
259 let mut offset = 0u64;
260 while offset < expected_bytes {
261 let end = (offset + COPY_CHUNK).min(expected_bytes);
262 let chunk = directory.read_range(path, offset..end).await?;
263 writer
264 .write_all(chunk.as_slice())
265 .map_err(crate::Error::Io)?;
266 offset = end;
267 }
268 }
269 if let Err(error) = directory.delete(path).await {
270 log::warn!(
274 "[merge] index={} failed to remove temporary sparse section {:?}: {}",
275 index_label,
276 path,
277 error,
278 );
279 }
280 Ok(())
281}
282
283pub struct SegmentMerger {
285 schema: Arc<Schema>,
286 reorder_bmp: bool,
290 background_pool: Option<Arc<rayon::ThreadPool>>,
294 pub(crate) force_ann_compaction: bool,
298 granularity: crate::segment::reorder::BpGranularity,
302 bp_budget: crate::segment::BpBudget,
307 cancellation: Option<Arc<AtomicBool>>,
310 bp_memory_budget: usize,
312 reorder_permits: Option<Arc<ReorderConcurrencyGate>>,
315 reorder_priority: ReorderPriority,
318}
319
320impl SegmentMerger {
321 pub fn new(schema: Arc<Schema>) -> Self {
322 Self {
323 schema,
324 reorder_bmp: false,
325 background_pool: None,
326 force_ann_compaction: false,
327 granularity: crate::segment::reorder::BpGranularity::Auto,
328 bp_budget: crate::segment::BpBudget::full(),
329 cancellation: None,
330 bp_memory_budget: crate::segment::reorder::DEFAULT_MEMORY_BUDGET,
331 reorder_permits: None,
332 reorder_priority: ReorderPriority::AutomaticMerge,
333 }
334 }
335
336 pub fn with_bmp_reorder(mut self, reorder: bool) -> Self {
338 self.reorder_bmp = reorder;
339 self
340 }
341
342 pub fn with_forced_ann_compaction(mut self, force: bool) -> Self {
345 self.force_ann_compaction = force;
346 self
347 }
348
349 pub fn with_background_pool(mut self, pool: Option<Arc<rayon::ThreadPool>>) -> Self {
350 self.background_pool = pool;
351 self
352 }
353
354 pub fn with_granularity(mut self, granularity: crate::segment::reorder::BpGranularity) -> Self {
356 self.granularity = granularity;
357 self
358 }
359
360 pub fn with_bp_budget(mut self, budget: crate::segment::BpBudget) -> Self {
362 self.bp_budget = budget;
363 self
364 }
365
366 pub(crate) fn with_cancellation(mut self, cancellation: Arc<AtomicBool>) -> Self {
367 self.cancellation = Some(cancellation);
368 self
369 }
370
371 pub fn with_bp_memory_budget(mut self, bytes: usize) -> Self {
373 self.bp_memory_budget = bytes;
374 self
375 }
376
377 pub fn with_reorder_permits(mut self, permits: Arc<ReorderConcurrencyGate>) -> Self {
379 self.reorder_permits = Some(permits);
380 self
381 }
382
383 pub(crate) fn with_reorder_priority(mut self, priority: ReorderPriority) -> Self {
384 self.reorder_priority = priority;
385 self
386 }
387
388 pub(super) fn ensure_not_cancelled(&self) -> Result<()> {
389 if self
390 .cancellation
391 .as_ref()
392 .is_some_and(|cancelled| cancelled.load(Ordering::Acquire))
393 {
394 Err(crate::Error::IndexClosed)
395 } else {
396 Ok(())
397 }
398 }
399
400 fn validate_merge_capacities(&self, segments: &[SegmentReader]) -> Result<()> {
404 let mut maxscore_skip_entries = MergeCapacity::default();
406
407 for (field, entry) in self.schema.fields() {
408 match entry.field_type {
409 FieldType::DenseVector | FieldType::BinaryDenseVector => {
410 let mut vectors = MergeCapacity::default();
411 for segment in segments {
412 let Some(flat) = segment.flat_vectors().get(&field.0) else {
413 continue;
414 };
415 if let Some(total) = vectors.add(flat.num_vectors as u64) {
416 let value_kind = if entry.field_type == FieldType::BinaryDenseVector {
417 "binary vectors"
418 } else {
419 "dense vectors"
420 };
421 return Err(field_capacity_error(
422 field.0,
423 &entry.name,
424 value_kind,
425 total,
426 ));
427 }
428 }
429 }
430 FieldType::SparseVector => {
431 let format = entry
432 .sparse_vector_config
433 .as_ref()
434 .map(|config| config.format)
435 .unwrap_or_default();
436 match format {
437 SparseFormat::Bmp => {
438 let mut vectors = MergeCapacity::default();
439 let mut blocks = MergeCapacity::default();
440 let mut real_slots = MergeCapacity::default();
441 let mut virtual_slots = MergeCapacity::default();
442
443 for segment in segments {
444 let Some(index) = segment.bmp_indexes().get(&field.0) else {
445 continue;
446 };
447 for (capacity, count, value_kind) in [
448 (&mut vectors, u64::from(index.total_vectors), "BMP vectors"),
449 (&mut blocks, u64::from(index.num_blocks), "BMP blocks"),
450 (
451 &mut real_slots,
452 u64::from(index.num_real_docs()),
453 "BMP real vector slots",
454 ),
455 (
456 &mut virtual_slots,
457 u64::from(index.num_virtual_docs),
458 "BMP padded virtual slots",
459 ),
460 ] {
461 if let Some(total) = capacity.add(count) {
462 return Err(field_capacity_error(
463 field.0,
464 &entry.name,
465 value_kind,
466 total,
467 ));
468 }
469 }
470 }
471 }
472 SparseFormat::MaxScore => {
473 let mut vectors = MergeCapacity::default();
474 let mut dimensions: FxHashMap<u32, (MergeCapacity, MergeCapacity)> =
475 FxHashMap::default();
476
477 for segment in segments {
478 let Some(index) = segment.sparse_indexes().get(&field.0) else {
479 continue;
480 };
481 if let Some(total) = vectors.add(u64::from(index.total_vectors)) {
482 return Err(field_capacity_error(
483 field.0,
484 &entry.name,
485 "MaxScore vectors",
486 total,
487 ));
488 }
489
490 for (dimension, doc_count, block_count) in index.dimension_counts()
491 {
492 let (docs, blocks) = dimensions.entry(dimension).or_default();
493 if let Some(total) = docs.add(u64::from(doc_count)) {
494 return Err(field_capacity_error(
495 field.0,
496 &entry.name,
497 &format!("MaxScore postings for dimension {dimension}"),
498 total,
499 ));
500 }
501 if let Some(total) = blocks.add(u64::from(block_count)) {
502 return Err(field_capacity_error(
503 field.0,
504 &entry.name,
505 &format!("MaxScore blocks for dimension {dimension}"),
506 total,
507 ));
508 }
509 if let Some(total) =
510 maxscore_skip_entries.add(u64::from(block_count))
511 {
512 return Err(crate::Error::Schema(format!(
513 "merge would produce {total} MaxScore skip entries \
514 across sparse fields, exceeding the segment format \
515 limit {}; lower max_segment_docs for multi-valued \
516 sparse fields",
517 u32::MAX,
518 )));
519 }
520 }
521 }
522 }
523 }
524 }
525 _ => {}
526 }
527 }
528 Ok(())
529 }
530
531 pub async fn merge<D: Directory + DirectoryWriter>(
541 &self,
542 dir: &D,
543 segments: &[SegmentReader],
544 new_segment_id: SegmentId,
545 trained: Option<&TrainedVectorStructures>,
546 ) -> Result<(SegmentMeta, MergeStats)> {
547 self.ensure_not_cancelled()?;
548 let total_docs: u32 = segments
552 .iter()
553 .try_fold(0u32, |acc, segment| acc.checked_add(segment.num_docs()))
554 .ok_or_else(|| {
555 crate::Error::Internal(format!(
556 "Total document count exceeds u32::MAX ({})",
557 u32::MAX
558 ))
559 })?;
560
561 self.validate_merge_capacities(segments)?;
562
563 let mut stats = MergeStats::default();
564 let files = SegmentFiles::new(new_segment_id.0);
565
566 let merge_start = std::time::Instant::now();
580
581 let postings_fut = async {
583 let mut postings_writer =
584 OffsetWriter::new(dir.streaming_writer_cold(&files.postings).await?);
585 let mut positions_writer =
586 OffsetWriter::new(dir.streaming_writer_cold(&files.positions).await?);
587 let mut term_dict_writer =
588 OffsetWriter::new(dir.streaming_writer_cold(&files.term_dict).await?);
589
590 let terms_processed = self
591 .merge_postings(
592 segments,
593 &mut term_dict_writer,
594 &mut postings_writer,
595 &mut positions_writer,
596 )
597 .await?;
598
599 let postings_bytes = postings_writer.offset() as usize;
600 let term_dict_bytes = term_dict_writer.offset() as usize;
601 let positions_bytes = positions_writer.offset();
602
603 postings_writer.finish()?;
604 term_dict_writer.finish()?;
605 if positions_bytes > 0 {
606 positions_writer.finish()?;
607 } else {
608 drop(positions_writer);
609 let _ = dir.delete(&files.positions).await;
610 }
611 log::info!(
612 "[merge] index={} postings done: {} terms, term_dict={}, postings={}, positions={}",
613 self.schema.index_label(),
614 terms_processed,
615 crate::format_bytes(term_dict_bytes as u64),
616 crate::format_bytes(postings_bytes as u64),
617 crate::format_bytes(positions_bytes),
618 );
619 Ok::<(usize, usize, usize), crate::Error>((
620 terms_processed,
621 term_dict_bytes,
622 postings_bytes,
623 ))
624 };
625
626 let store_fut = async {
627 let mut store_writer =
628 OffsetWriter::new(dir.streaming_writer_cold(&files.store).await?);
629 let store_num_docs = self.merge_store(segments, &mut store_writer).await?;
630 let bytes = store_writer.offset() as usize;
631 store_writer.finish()?;
632 Ok::<(usize, u32), crate::Error>((bytes, store_num_docs))
633 };
634
635 let fast_fut = async { self.merge_fast_fields(dir, segments, &files).await };
636
637 let (postings_result, store_result, fast_bytes) =
638 tokio::try_join!(postings_fut, store_fut, fast_fut)?;
639 self.ensure_not_cancelled()?;
640
641 log::info!(
642 "[merge] index={} stage 1 done in {:.1}s (postings + store + fast)",
643 self.schema.index_label(),
644 merge_start.elapsed().as_secs_f64()
645 );
646
647 let sparse_fut = async { self.merge_sparse_vectors(dir, segments, &files).await };
651
652 let dense_fut = async {
653 self.merge_dense_vectors(dir, segments, &files, trained, AnnWriteMode::Copy)
654 .await
655 };
656
657 let ((sparse_bytes, bp_converged), vectors_bytes) = if self.reorder_bmp {
661 let sparse = sparse_fut.await?;
662 let dense = dense_fut.await?;
663 (sparse, dense)
664 } else {
665 tokio::try_join!(sparse_fut, dense_fut)?
666 };
667 self.ensure_not_cancelled()?;
668 let (store_bytes, store_num_docs) = store_result;
669 stats.terms_processed = postings_result.0;
670 stats.term_dict_bytes = postings_result.1;
671 stats.postings_bytes = postings_result.2;
672 stats.store_bytes = store_bytes;
673 stats.vectors_bytes = vectors_bytes;
674 stats.sparse_bytes = sparse_bytes;
675 stats.bp_converged = bp_converged;
676 stats.fast_bytes = fast_bytes;
677 log::info!(
678 "[merge] index={} all phases done in {:.1}s: {}",
679 self.schema.index_label(),
680 merge_start.elapsed().as_secs_f64(),
681 stats
682 );
683
684 self.ensure_not_cancelled()?;
686 let mut merged_field_stats: FxHashMap<u32, FieldStats> = FxHashMap::default();
687 for segment in segments {
688 for (&field_id, field_stats) in &segment.meta().field_stats {
689 let entry = merged_field_stats.entry(field_id).or_default();
690 entry.total_tokens = entry
691 .total_tokens
692 .checked_add(field_stats.total_tokens)
693 .ok_or_else(|| {
694 crate::Error::Corruption(format!(
695 "field {} total-token count overflow while merging",
696 field_id
697 ))
698 })?;
699 entry.doc_count = entry
700 .doc_count
701 .checked_add(field_stats.doc_count)
702 .ok_or_else(|| {
703 crate::Error::Corruption(format!(
704 "field {} document count overflow while merging",
705 field_id
706 ))
707 })?;
708 }
709 }
710
711 if store_num_docs != total_docs {
715 log::error!(
716 "[merge] index={} STORE/META MISMATCH: store has {} docs but metadata expects {}. \
717 Per-segment: {:?}",
718 self.schema.index_label(),
719 store_num_docs,
720 total_docs,
721 segments
722 .iter()
723 .map(|s| (
724 format!("{:016x}", s.meta().id),
725 s.num_docs(),
726 s.store().num_docs()
727 ))
728 .collect::<Vec<_>>()
729 );
730 return Err(crate::Error::Io(std::io::Error::new(
731 std::io::ErrorKind::InvalidData,
732 format!(
733 "Store/meta doc count mismatch: store={}, meta={}",
734 store_num_docs, total_docs
735 ),
736 )));
737 }
738
739 let meta = SegmentMeta {
740 id: new_segment_id.0,
741 num_docs: total_docs,
742 field_stats: merged_field_stats,
743 };
744
745 dir.write_durable(&files.meta, &meta.serialize()?).await?;
749
750 log::info!(
755 "[merge] index={} complete: {} docs, {}",
756 self.schema.index_label(),
757 total_docs,
758 stats
759 );
760
761 Ok((meta, stats))
762 }
763}
764
765pub async fn delete_segment<D: Directory + DirectoryWriter>(
767 dir: &D,
768 segment_id: SegmentId,
769) -> Result<()> {
770 let files = SegmentFiles::new(segment_id.0);
771 let paths = files.lifecycle_paths();
772 let results = futures::future::join_all(paths.iter().map(|path| dir.delete(path))).await;
773
774 for result in results {
778 if let Err(error) = result
779 && error.kind() != std::io::ErrorKind::NotFound
780 {
781 return Err(crate::Error::Io(error));
782 }
783 }
784 Ok(())
785}
786
787#[cfg(test)]
788mod capacity_tests {
789 use super::{MergeCapacity, field_capacity_error};
790
791 #[test]
792 fn merge_capacity_accepts_the_exact_u32_boundary() {
793 let mut capacity = MergeCapacity::default();
794 assert_eq!(capacity.add(u64::from(u32::MAX) - 7), None);
795 assert_eq!(capacity.add(7), None);
796 }
797
798 #[test]
799 fn merge_capacity_rejects_the_first_value_beyond_u32() {
800 let mut capacity = MergeCapacity::default();
801 assert_eq!(capacity.add(u64::from(u32::MAX)), None);
802 assert_eq!(capacity.add(1), Some(u64::from(u32::MAX) + 1));
803 }
804
805 #[test]
806 fn merge_capacity_failure_is_not_source_corruption() {
807 let error = field_capacity_error(
808 7,
809 "body_embedding",
810 "dense vectors",
811 u64::from(u32::MAX) + 1,
812 );
813 assert!(matches!(error, crate::Error::Schema(_)));
814 assert!(error.to_string().contains("lower max_segment_docs"));
815 }
816}