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