Skip to main content

hermes_core/segment/merger/
mod.rs

1//! Segment merger for combining multiple segments
2
3mod 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
26/// Compute per-segment doc ID offsets (each segment's docs start after the previous).
27///
28/// Returns an error if the total document count across segments exceeds `u32::MAX`.
29fn 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/// Additive count stored in a `u32` field of the merged segment format.
45///
46/// Source segments are individually valid, so exceeding the limit is a
47/// property of this merge plan rather than source corruption.
48#[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/// Statistics for merge operations
74#[derive(Debug, Clone, Default)]
75pub struct MergeStats {
76    /// Number of terms processed
77    pub terms_processed: usize,
78    /// Term dictionary output size
79    pub term_dict_bytes: usize,
80    /// Postings output size
81    pub postings_bytes: usize,
82    /// Store output size
83    pub store_bytes: usize,
84    /// Vector index output size
85    pub vectors_bytes: usize,
86    /// Sparse vector index output size
87    pub sparse_bytes: usize,
88    /// Whether merge-time BP reorder ran to full depth on every BMP field
89    /// (false = a pass hit its wall-clock budget; the segment is valid and
90    /// better-ordered, and the background optimizer deepens it later).
91    /// True when no BP ran (block-copy merges have nothing to deepen... they
92    /// are simply not reordered and tracked by the `reordered` flag instead).
93    pub bp_converged: bool,
94    /// Fast-field output size
95    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
114// TrainedVectorStructures is defined in super::types (available on all platforms)
115pub use super::types::TrainedVectorStructures;
116
117/// Run a CPU/IO-heavy synchronous section, telling tokio to migrate this
118/// worker's task queue first (multi-thread runtimes only — `block_in_place`
119/// panics on current_thread, where we just run inline).
120pub(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
131/// Attempt a byte-identical local range copy through the streaming writer's
132/// kernel-assisted path. Returns `Ok(false)` without emitting bytes when the
133/// backend/filesystem does not support it.
134fn 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
186/// Copy a byte-identical range, falling back to already-mapped bytes for
187/// abstract directories and filesystems without range-copy support.
188pub(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
225/// Append an exact-length temporary directory file to a segment output and
226/// remove it. Used by sparse skip tables so neither merge nor BP rewrite
227/// buffers a corpus-sized metadata section on heap.
228pub(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        // The section is already complete in the output. This output-scoped
271        // scratch file is safe for the startup orphan sweep and must not
272        // invalidate an otherwise successful multi-hour merge.
273        log::warn!(
274            "[merge] index={} failed to remove temporary sparse section {:?}: {}",
275            index_label,
276            path,
277            error,
278        );
279    }
280    Ok(())
281}
282
283/// Segment merger - merges multiple segments into one
284pub struct SegmentMerger {
285    schema: Arc<Schema>,
286    /// Run BP reordering on BMP sparse fields while writing the merged blob
287    /// (instead of byte-level block stacking). The output segment is then
288    /// already ordered, so the standalone reorder pass is unnecessary.
289    reorder_bmp: bool,
290    /// Bounded rayon pool for merge-time BP. `None` = global pool (tests);
291    /// the SegmentManager always passes its background pool so BP cannot
292    /// starve query scoring.
293    background_pool: Option<Arc<rayon::ThreadPool>>,
294    /// Granularity for merge-time BP. `Auto` by default; the SegmentManager
295    /// forces `Records` when any merge source is an unconverged partial
296    /// reorder.
297    granularity: crate::segment::reorder::BpGranularity,
298    /// Budget for merge-time BP. Default unbudgeted; the SegmentManager
299    /// passes the index's `merge_bp_time_budget` so huge merges stop holding
300    /// a merge slot for the full BP depth — a truncated pass is marked
301    /// `bp_converged = false` and the background optimizer deepens it.
302    bp_budget: crate::segment::BpBudget,
303    /// Process-shutdown cancellation, kept separate from the public BP budget
304    /// so low-level callers retain the existing budget API.
305    cancellation: Option<Arc<AtomicBool>>,
306    /// Memory budget for the BP forward index during merge-time reorder.
307    bp_memory_budget: usize,
308    /// Shared whole-pass concurrency limit. Tests and low-level callers may
309    /// omit it; SegmentManager always supplies the application-wide gate.
310    reorder_permits: Option<Arc<ReorderConcurrencyGate>>,
311    /// Automatic merges are background work. An explicit force merge holds a
312    /// foreground guard and bypasses the background pause for its BP fields.
313    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    /// Enable BP reordering of BMP fields during the merge (see `reorder_bmp`).
332    pub fn with_bmp_reorder(mut self, reorder: bool) -> Self {
333        self.reorder_bmp = reorder;
334        self
335    }
336
337    /// Run merge-time BP on this bounded pool instead of the global one.
338    pub fn with_background_pool(mut self, pool: Option<Arc<rayon::ThreadPool>>) -> Self {
339        self.background_pool = pool;
340        self
341    }
342
343    /// Set merge-time BP granularity (see `granularity`).
344    pub fn with_granularity(mut self, granularity: crate::segment::reorder::BpGranularity) -> Self {
345        self.granularity = granularity;
346        self
347    }
348
349    /// Bound merge-time BP wall clock (see `bp_budget`).
350    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    /// Memory budget for the BP forward index (see `bp_memory_budget`).
361    pub fn with_bp_memory_budget(mut self, bytes: usize) -> Self {
362        self.bp_memory_budget = bytes;
363        self
364    }
365
366    /// Share the application-wide whole-segment reorder gate.
367    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    /// Reject additive per-field counts that the on-disk formats cannot
390    /// represent. All inputs are already-open metadata views; no vector,
391    /// posting, or document payload is read here.
392    fn validate_merge_capacities(&self, segments: &[SegmentReader]) -> Result<()> {
393        // MaxScore skip entries share one u32-addressed section across fields.
394        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    /// Merge segments into one, streaming postings/positions/store directly to files.
521    ///
522    /// If `trained` is provided, dense vectors use O(1) cluster merge when possible
523    /// (compatible IVF-PQ), otherwise rebuilds ANN from global artifacts.
524    /// Without trained structures, only flat vectors are merged.
525    ///
526    /// Uses streaming writers so postings, positions, and store data flow directly
527    /// to files instead of buffering everything in memory. Only the term dictionary
528    /// (compact key+TermInfo entries) is buffered.
529    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        // Reject an unrepresentable merge before creating any output files.
538        // The previous late check left a complete orphan output behind after
539        // doing all expensive phases.
540        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        // === Two-stage merge to bound page cache pressure ===
556        //
557        // Stage 1: postings + store + fast_fields (concurrent)
558        //   Touches .term_dict, .postings, .positions, .store, .fast files.
559        //
560        // Stage 2: sparse + dense vectors. Block-copy sparse work runs with
561        // dense vectors; BP sparse work runs first to bound peak memory.
562        //   Touches .sparse, .vectors files.
563        //
564        // Running all phases concurrently caused OOM on large merges because
565        // mmap'd source files from all 16+ segments compete for page cache
566        // simultaneously (200+ GB of mmap'd data for BMP grids alone).
567        // Two stages halve the concurrent working set.
568        let merge_start = std::time::Instant::now();
569
570        // ── Stage 1: text + store + fast fields ─────────────────────────
571        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        // ── Stage 2: sparse + dense vectors ─────────────────────────────
637        // Page cache from stage 1 files can now be evicted by the kernel
638        // as stage 2 accesses different mmap regions (.sparse, .vectors).
639        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        // Merge-time BP constructs a potentially budget-sized forward index.
647        // Do not overlap that allocation and its heavy source-file scan with
648        // an ANN rebuild. Block-copy sparse merges remain concurrent with ANN.
649        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        // === Mandatory: merge field stats + write meta ===
674        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        // Verify store doc count matches metadata — a mismatch here means
701        // some store blocks were lost (e.g., compression thread panic) or
702        // source segment metadata disagrees with its store.
703        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        // Durable: replace_segments deletes the fsynced source segments right
735        // after publishing this output, so a non-durable .meta could be the
736        // only copy of the merged documents across a power failure.
737        dir.write_durable(&files.meta, &meta.serialize()?).await?;
738
739        // Dense ANN payloads are byte-copied during an ordinary merge; the
740        // wall-clock timer also includes postings, store, sparse BP and any BP
741        // scheduler wait. Calling this an "ANN merge" made long sparse waits
742        // look like ANN construction in production logs.
743        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
754/// Delete segment files from directory (all deletions run concurrently).
755pub 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    // Missing files are expected for optional components and idempotent
764    // retries. Any other failure must be surfaced so cleanup is not falsely
765    // reported as successful; a later orphan sweep can retry remaining files.
766    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}