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 granularity: crate::segment::reorder::BpGranularity,
298 bp_budget: crate::segment::BpBudget,
303 cancellation: Option<Arc<AtomicBool>>,
306 bp_memory_budget: usize,
308 reorder_permits: Option<Arc<ReorderConcurrencyGate>>,
311 reorder_priority: ReorderPriority,
314}
315
316impl SegmentMerger {
317 pub fn new(schema: Arc<Schema>) -> Self {
318 Self {
319 schema,
320 reorder_bmp: false,
321 background_pool: None,
322 granularity: crate::segment::reorder::BpGranularity::Auto,
323 bp_budget: crate::segment::BpBudget::full(),
324 cancellation: None,
325 bp_memory_budget: crate::segment::reorder::DEFAULT_MEMORY_BUDGET,
326 reorder_permits: None,
327 reorder_priority: ReorderPriority::AutomaticMerge,
328 }
329 }
330
331 pub fn with_bmp_reorder(mut self, reorder: bool) -> Self {
333 self.reorder_bmp = reorder;
334 self
335 }
336
337 pub fn with_background_pool(mut self, pool: Option<Arc<rayon::ThreadPool>>) -> Self {
339 self.background_pool = pool;
340 self
341 }
342
343 pub fn with_granularity(mut self, granularity: crate::segment::reorder::BpGranularity) -> Self {
345 self.granularity = granularity;
346 self
347 }
348
349 pub fn with_bp_budget(mut self, budget: crate::segment::BpBudget) -> Self {
351 self.bp_budget = budget;
352 self
353 }
354
355 pub(crate) fn with_cancellation(mut self, cancellation: Arc<AtomicBool>) -> Self {
356 self.cancellation = Some(cancellation);
357 self
358 }
359
360 pub fn with_bp_memory_budget(mut self, bytes: usize) -> Self {
362 self.bp_memory_budget = bytes;
363 self
364 }
365
366 pub fn with_reorder_permits(mut self, permits: Arc<ReorderConcurrencyGate>) -> Self {
368 self.reorder_permits = Some(permits);
369 self
370 }
371
372 pub(crate) fn with_reorder_priority(mut self, priority: ReorderPriority) -> Self {
373 self.reorder_priority = priority;
374 self
375 }
376
377 pub(super) fn ensure_not_cancelled(&self) -> Result<()> {
378 if self
379 .cancellation
380 .as_ref()
381 .is_some_and(|cancelled| cancelled.load(Ordering::Acquire))
382 {
383 Err(crate::Error::IndexClosed)
384 } else {
385 Ok(())
386 }
387 }
388
389 fn validate_merge_capacities(&self, segments: &[SegmentReader]) -> Result<()> {
393 let mut maxscore_skip_entries = MergeCapacity::default();
395
396 for (field, entry) in self.schema.fields() {
397 match entry.field_type {
398 FieldType::DenseVector | FieldType::BinaryDenseVector => {
399 let mut vectors = MergeCapacity::default();
400 for segment in segments {
401 let Some(flat) = segment.flat_vectors().get(&field.0) else {
402 continue;
403 };
404 if let Some(total) = vectors.add(flat.num_vectors as u64) {
405 let value_kind = if entry.field_type == FieldType::BinaryDenseVector {
406 "binary vectors"
407 } else {
408 "dense vectors"
409 };
410 return Err(field_capacity_error(
411 field.0,
412 &entry.name,
413 value_kind,
414 total,
415 ));
416 }
417 }
418 }
419 FieldType::SparseVector => {
420 let format = entry
421 .sparse_vector_config
422 .as_ref()
423 .map(|config| config.format)
424 .unwrap_or_default();
425 match format {
426 SparseFormat::Bmp => {
427 let mut vectors = MergeCapacity::default();
428 let mut blocks = MergeCapacity::default();
429 let mut real_slots = MergeCapacity::default();
430 let mut virtual_slots = MergeCapacity::default();
431
432 for segment in segments {
433 let Some(index) = segment.bmp_indexes().get(&field.0) else {
434 continue;
435 };
436 for (capacity, count, value_kind) in [
437 (&mut vectors, u64::from(index.total_vectors), "BMP vectors"),
438 (&mut blocks, u64::from(index.num_blocks), "BMP blocks"),
439 (
440 &mut real_slots,
441 u64::from(index.num_real_docs()),
442 "BMP real vector slots",
443 ),
444 (
445 &mut virtual_slots,
446 u64::from(index.num_virtual_docs),
447 "BMP padded virtual slots",
448 ),
449 ] {
450 if let Some(total) = capacity.add(count) {
451 return Err(field_capacity_error(
452 field.0,
453 &entry.name,
454 value_kind,
455 total,
456 ));
457 }
458 }
459 }
460 }
461 SparseFormat::MaxScore => {
462 let mut vectors = MergeCapacity::default();
463 let mut dimensions: FxHashMap<u32, (MergeCapacity, MergeCapacity)> =
464 FxHashMap::default();
465
466 for segment in segments {
467 let Some(index) = segment.sparse_indexes().get(&field.0) else {
468 continue;
469 };
470 if let Some(total) = vectors.add(u64::from(index.total_vectors)) {
471 return Err(field_capacity_error(
472 field.0,
473 &entry.name,
474 "MaxScore vectors",
475 total,
476 ));
477 }
478
479 for (dimension, doc_count, block_count) in index.dimension_counts()
480 {
481 let (docs, blocks) = dimensions.entry(dimension).or_default();
482 if let Some(total) = docs.add(u64::from(doc_count)) {
483 return Err(field_capacity_error(
484 field.0,
485 &entry.name,
486 &format!("MaxScore postings for dimension {dimension}"),
487 total,
488 ));
489 }
490 if let Some(total) = blocks.add(u64::from(block_count)) {
491 return Err(field_capacity_error(
492 field.0,
493 &entry.name,
494 &format!("MaxScore blocks for dimension {dimension}"),
495 total,
496 ));
497 }
498 if let Some(total) =
499 maxscore_skip_entries.add(u64::from(block_count))
500 {
501 return Err(crate::Error::Schema(format!(
502 "merge would produce {total} MaxScore skip entries \
503 across sparse fields, exceeding the segment format \
504 limit {}; lower max_segment_docs for multi-valued \
505 sparse fields",
506 u32::MAX,
507 )));
508 }
509 }
510 }
511 }
512 }
513 }
514 _ => {}
515 }
516 }
517 Ok(())
518 }
519
520 pub async fn merge<D: Directory + DirectoryWriter>(
530 &self,
531 dir: &D,
532 segments: &[SegmentReader],
533 new_segment_id: SegmentId,
534 trained: Option<&TrainedVectorStructures>,
535 ) -> Result<(SegmentMeta, MergeStats)> {
536 self.ensure_not_cancelled()?;
537 let total_docs: u32 = segments
541 .iter()
542 .try_fold(0u32, |acc, segment| acc.checked_add(segment.num_docs()))
543 .ok_or_else(|| {
544 crate::Error::Internal(format!(
545 "Total document count exceeds u32::MAX ({})",
546 u32::MAX
547 ))
548 })?;
549
550 self.validate_merge_capacities(segments)?;
551
552 let mut stats = MergeStats::default();
553 let files = SegmentFiles::new(new_segment_id.0);
554
555 let merge_start = std::time::Instant::now();
569
570 let postings_fut = async {
572 let mut postings_writer =
573 OffsetWriter::new(dir.streaming_writer_cold(&files.postings).await?);
574 let mut positions_writer =
575 OffsetWriter::new(dir.streaming_writer_cold(&files.positions).await?);
576 let mut term_dict_writer =
577 OffsetWriter::new(dir.streaming_writer_cold(&files.term_dict).await?);
578
579 let terms_processed = self
580 .merge_postings(
581 segments,
582 &mut term_dict_writer,
583 &mut postings_writer,
584 &mut positions_writer,
585 )
586 .await?;
587
588 let postings_bytes = postings_writer.offset() as usize;
589 let term_dict_bytes = term_dict_writer.offset() as usize;
590 let positions_bytes = positions_writer.offset();
591
592 postings_writer.finish()?;
593 term_dict_writer.finish()?;
594 if positions_bytes > 0 {
595 positions_writer.finish()?;
596 } else {
597 drop(positions_writer);
598 let _ = dir.delete(&files.positions).await;
599 }
600 log::info!(
601 "[merge] index={} postings done: {} terms, term_dict={}, postings={}, positions={}",
602 self.schema.index_label(),
603 terms_processed,
604 crate::format_bytes(term_dict_bytes as u64),
605 crate::format_bytes(postings_bytes as u64),
606 crate::format_bytes(positions_bytes),
607 );
608 Ok::<(usize, usize, usize), crate::Error>((
609 terms_processed,
610 term_dict_bytes,
611 postings_bytes,
612 ))
613 };
614
615 let store_fut = async {
616 let mut store_writer =
617 OffsetWriter::new(dir.streaming_writer_cold(&files.store).await?);
618 let store_num_docs = self.merge_store(segments, &mut store_writer).await?;
619 let bytes = store_writer.offset() as usize;
620 store_writer.finish()?;
621 Ok::<(usize, u32), crate::Error>((bytes, store_num_docs))
622 };
623
624 let fast_fut = async { self.merge_fast_fields(dir, segments, &files).await };
625
626 let (postings_result, store_result, fast_bytes) =
627 tokio::try_join!(postings_fut, store_fut, fast_fut)?;
628 self.ensure_not_cancelled()?;
629
630 log::info!(
631 "[merge] index={} stage 1 done in {:.1}s (postings + store + fast)",
632 self.schema.index_label(),
633 merge_start.elapsed().as_secs_f64()
634 );
635
636 let sparse_fut = async { self.merge_sparse_vectors(dir, segments, &files).await };
640
641 let dense_fut = async {
642 self.merge_dense_vectors(dir, segments, &files, trained, AnnWriteMode::Copy)
643 .await
644 };
645
646 let ((sparse_bytes, bp_converged), vectors_bytes) = if self.reorder_bmp {
650 let sparse = sparse_fut.await?;
651 let dense = dense_fut.await?;
652 (sparse, dense)
653 } else {
654 tokio::try_join!(sparse_fut, dense_fut)?
655 };
656 self.ensure_not_cancelled()?;
657 let (store_bytes, store_num_docs) = store_result;
658 stats.terms_processed = postings_result.0;
659 stats.term_dict_bytes = postings_result.1;
660 stats.postings_bytes = postings_result.2;
661 stats.store_bytes = store_bytes;
662 stats.vectors_bytes = vectors_bytes;
663 stats.sparse_bytes = sparse_bytes;
664 stats.bp_converged = bp_converged;
665 stats.fast_bytes = fast_bytes;
666 log::info!(
667 "[merge] index={} all phases done in {:.1}s: {}",
668 self.schema.index_label(),
669 merge_start.elapsed().as_secs_f64(),
670 stats
671 );
672
673 self.ensure_not_cancelled()?;
675 let mut merged_field_stats: FxHashMap<u32, FieldStats> = FxHashMap::default();
676 for segment in segments {
677 for (&field_id, field_stats) in &segment.meta().field_stats {
678 let entry = merged_field_stats.entry(field_id).or_default();
679 entry.total_tokens = entry
680 .total_tokens
681 .checked_add(field_stats.total_tokens)
682 .ok_or_else(|| {
683 crate::Error::Corruption(format!(
684 "field {} total-token count overflow while merging",
685 field_id
686 ))
687 })?;
688 entry.doc_count = entry
689 .doc_count
690 .checked_add(field_stats.doc_count)
691 .ok_or_else(|| {
692 crate::Error::Corruption(format!(
693 "field {} document count overflow while merging",
694 field_id
695 ))
696 })?;
697 }
698 }
699
700 if store_num_docs != total_docs {
704 log::error!(
705 "[merge] index={} STORE/META MISMATCH: store has {} docs but metadata expects {}. \
706 Per-segment: {:?}",
707 self.schema.index_label(),
708 store_num_docs,
709 total_docs,
710 segments
711 .iter()
712 .map(|s| (
713 format!("{:016x}", s.meta().id),
714 s.num_docs(),
715 s.store().num_docs()
716 ))
717 .collect::<Vec<_>>()
718 );
719 return Err(crate::Error::Io(std::io::Error::new(
720 std::io::ErrorKind::InvalidData,
721 format!(
722 "Store/meta doc count mismatch: store={}, meta={}",
723 store_num_docs, total_docs
724 ),
725 )));
726 }
727
728 let meta = SegmentMeta {
729 id: new_segment_id.0,
730 num_docs: total_docs,
731 field_stats: merged_field_stats,
732 };
733
734 dir.write_durable(&files.meta, &meta.serialize()?).await?;
738
739 log::info!(
744 "[merge] index={} complete: {} docs, {}",
745 self.schema.index_label(),
746 total_docs,
747 stats
748 );
749
750 Ok((meta, stats))
751 }
752}
753
754pub async fn delete_segment<D: Directory + DirectoryWriter>(
756 dir: &D,
757 segment_id: SegmentId,
758) -> Result<()> {
759 let files = SegmentFiles::new(segment_id.0);
760 let paths = files.lifecycle_paths();
761 let results = futures::future::join_all(paths.iter().map(|path| dir.delete(path))).await;
762
763 for result in results {
767 if let Err(error) = result
768 && error.kind() != std::io::ErrorKind::NotFound
769 {
770 return Err(crate::Error::Io(error));
771 }
772 }
773 Ok(())
774}
775
776#[cfg(test)]
777mod capacity_tests {
778 use super::{MergeCapacity, field_capacity_error};
779
780 #[test]
781 fn merge_capacity_accepts_the_exact_u32_boundary() {
782 let mut capacity = MergeCapacity::default();
783 assert_eq!(capacity.add(u64::from(u32::MAX) - 7), None);
784 assert_eq!(capacity.add(7), None);
785 }
786
787 #[test]
788 fn merge_capacity_rejects_the_first_value_beyond_u32() {
789 let mut capacity = MergeCapacity::default();
790 assert_eq!(capacity.add(u64::from(u32::MAX)), None);
791 assert_eq!(capacity.add(1), Some(u64::from(u32::MAX) + 1));
792 }
793
794 #[test]
795 fn merge_capacity_failure_is_not_source_corruption() {
796 let error = field_capacity_error(
797 7,
798 "body_embedding",
799 "dense vectors",
800 u64::from(u32::MAX) + 1,
801 );
802 assert!(matches!(error, crate::Error::Schema(_)));
803 assert!(error.to_string().contains("lower max_segment_docs"));
804 }
805}