Skip to main content

hermes_core/segment/merger/
mod.rs

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