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