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    /// Compact binary ANN runs regardless of the fragmentation threshold.
295    /// The explicit reorder/optimize pass sets this: an optimize command
296    /// should hand back the freshly-built one-extent-per-cluster layout.
297    pub(crate) force_ann_compaction: bool,
298    /// Granularity for merge-time BP. `Auto` by default; the SegmentManager
299    /// forces `Records` when any merge source is an unconverged partial
300    /// reorder.
301    granularity: crate::segment::reorder::BpGranularity,
302    /// Budget for merge-time BP. Default unbudgeted; the SegmentManager
303    /// passes the index's `merge_bp_time_budget` so huge merges stop holding
304    /// a merge slot for the full BP depth — a truncated pass is marked
305    /// `bp_converged = false` and the background optimizer deepens it.
306    bp_budget: crate::segment::BpBudget,
307    /// Process-shutdown cancellation, kept separate from the public BP budget
308    /// so low-level callers retain the existing budget API.
309    cancellation: Option<Arc<AtomicBool>>,
310    /// Memory budget for the BP forward index during merge-time reorder.
311    bp_memory_budget: usize,
312    /// Shared whole-pass concurrency limit. Tests and low-level callers may
313    /// omit it; SegmentManager always supplies the application-wide gate.
314    reorder_permits: Option<Arc<ReorderConcurrencyGate>>,
315    /// Automatic merges are background work. An explicit force merge holds a
316    /// foreground guard and bypasses the background pause for its BP fields.
317    reorder_priority: ReorderPriority,
318}
319
320impl SegmentMerger {
321    pub fn new(schema: Arc<Schema>) -> Self {
322        Self {
323            schema,
324            reorder_bmp: false,
325            background_pool: None,
326            force_ann_compaction: false,
327            granularity: crate::segment::reorder::BpGranularity::Auto,
328            bp_budget: crate::segment::BpBudget::full(),
329            cancellation: None,
330            bp_memory_budget: crate::segment::reorder::DEFAULT_MEMORY_BUDGET,
331            reorder_permits: None,
332            reorder_priority: ReorderPriority::AutomaticMerge,
333        }
334    }
335
336    /// Enable BP reordering of BMP fields during the merge (see `reorder_bmp`).
337    pub fn with_bmp_reorder(mut self, reorder: bool) -> Self {
338        self.reorder_bmp = reorder;
339        self
340    }
341
342    /// Run merge-time BP on this bounded pool instead of the global one.
343    /// Compact binary ANN runs even below the merge fragmentation threshold.
344    pub fn with_forced_ann_compaction(mut self, force: bool) -> Self {
345        self.force_ann_compaction = force;
346        self
347    }
348
349    pub fn with_background_pool(mut self, pool: Option<Arc<rayon::ThreadPool>>) -> Self {
350        self.background_pool = pool;
351        self
352    }
353
354    /// Set merge-time BP granularity (see `granularity`).
355    pub fn with_granularity(mut self, granularity: crate::segment::reorder::BpGranularity) -> Self {
356        self.granularity = granularity;
357        self
358    }
359
360    /// Bound merge-time BP wall clock (see `bp_budget`).
361    pub fn with_bp_budget(mut self, budget: crate::segment::BpBudget) -> Self {
362        self.bp_budget = budget;
363        self
364    }
365
366    pub(crate) fn with_cancellation(mut self, cancellation: Arc<AtomicBool>) -> Self {
367        self.cancellation = Some(cancellation);
368        self
369    }
370
371    /// Memory budget for the BP forward index (see `bp_memory_budget`).
372    pub fn with_bp_memory_budget(mut self, bytes: usize) -> Self {
373        self.bp_memory_budget = bytes;
374        self
375    }
376
377    /// Share the application-wide whole-segment reorder gate.
378    pub fn with_reorder_permits(mut self, permits: Arc<ReorderConcurrencyGate>) -> Self {
379        self.reorder_permits = Some(permits);
380        self
381    }
382
383    pub(crate) fn with_reorder_priority(mut self, priority: ReorderPriority) -> Self {
384        self.reorder_priority = priority;
385        self
386    }
387
388    pub(super) fn ensure_not_cancelled(&self) -> Result<()> {
389        if self
390            .cancellation
391            .as_ref()
392            .is_some_and(|cancelled| cancelled.load(Ordering::Acquire))
393        {
394            Err(crate::Error::IndexClosed)
395        } else {
396            Ok(())
397        }
398    }
399
400    /// Reject additive per-field counts that the on-disk formats cannot
401    /// represent. All inputs are already-open metadata views; no vector,
402    /// posting, or document payload is read here.
403    fn validate_merge_capacities(&self, segments: &[SegmentReader]) -> Result<()> {
404        // MaxScore skip entries share one u32-addressed section across fields.
405        let mut maxscore_skip_entries = MergeCapacity::default();
406
407        for (field, entry) in self.schema.fields() {
408            match entry.field_type {
409                FieldType::DenseVector | FieldType::BinaryDenseVector => {
410                    let mut vectors = MergeCapacity::default();
411                    for segment in segments {
412                        let Some(flat) = segment.flat_vectors().get(&field.0) else {
413                            continue;
414                        };
415                        if let Some(total) = vectors.add(flat.num_vectors as u64) {
416                            let value_kind = if entry.field_type == FieldType::BinaryDenseVector {
417                                "binary vectors"
418                            } else {
419                                "dense vectors"
420                            };
421                            return Err(field_capacity_error(
422                                field.0,
423                                &entry.name,
424                                value_kind,
425                                total,
426                            ));
427                        }
428                    }
429                }
430                FieldType::SparseVector => {
431                    let format = entry
432                        .sparse_vector_config
433                        .as_ref()
434                        .map(|config| config.format)
435                        .unwrap_or_default();
436                    match format {
437                        SparseFormat::Bmp => {
438                            let mut vectors = MergeCapacity::default();
439                            let mut blocks = MergeCapacity::default();
440                            let mut real_slots = MergeCapacity::default();
441                            let mut virtual_slots = MergeCapacity::default();
442
443                            for segment in segments {
444                                let Some(index) = segment.bmp_indexes().get(&field.0) else {
445                                    continue;
446                                };
447                                for (capacity, count, value_kind) in [
448                                    (&mut vectors, u64::from(index.total_vectors), "BMP vectors"),
449                                    (&mut blocks, u64::from(index.num_blocks), "BMP blocks"),
450                                    (
451                                        &mut real_slots,
452                                        u64::from(index.num_real_docs()),
453                                        "BMP real vector slots",
454                                    ),
455                                    (
456                                        &mut virtual_slots,
457                                        u64::from(index.num_virtual_docs),
458                                        "BMP padded virtual slots",
459                                    ),
460                                ] {
461                                    if let Some(total) = capacity.add(count) {
462                                        return Err(field_capacity_error(
463                                            field.0,
464                                            &entry.name,
465                                            value_kind,
466                                            total,
467                                        ));
468                                    }
469                                }
470                            }
471                        }
472                        SparseFormat::MaxScore => {
473                            let mut vectors = MergeCapacity::default();
474                            let mut dimensions: FxHashMap<u32, (MergeCapacity, MergeCapacity)> =
475                                FxHashMap::default();
476
477                            for segment in segments {
478                                let Some(index) = segment.sparse_indexes().get(&field.0) else {
479                                    continue;
480                                };
481                                if let Some(total) = vectors.add(u64::from(index.total_vectors)) {
482                                    return Err(field_capacity_error(
483                                        field.0,
484                                        &entry.name,
485                                        "MaxScore vectors",
486                                        total,
487                                    ));
488                                }
489
490                                for (dimension, doc_count, block_count) in index.dimension_counts()
491                                {
492                                    let (docs, blocks) = dimensions.entry(dimension).or_default();
493                                    if let Some(total) = docs.add(u64::from(doc_count)) {
494                                        return Err(field_capacity_error(
495                                            field.0,
496                                            &entry.name,
497                                            &format!("MaxScore postings for dimension {dimension}"),
498                                            total,
499                                        ));
500                                    }
501                                    if let Some(total) = blocks.add(u64::from(block_count)) {
502                                        return Err(field_capacity_error(
503                                            field.0,
504                                            &entry.name,
505                                            &format!("MaxScore blocks for dimension {dimension}"),
506                                            total,
507                                        ));
508                                    }
509                                    if let Some(total) =
510                                        maxscore_skip_entries.add(u64::from(block_count))
511                                    {
512                                        return Err(crate::Error::Schema(format!(
513                                            "merge would produce {total} MaxScore skip entries \
514                                             across sparse fields, exceeding the segment format \
515                                             limit {}; lower max_segment_docs for multi-valued \
516                                             sparse fields",
517                                            u32::MAX,
518                                        )));
519                                    }
520                                }
521                            }
522                        }
523                    }
524                }
525                _ => {}
526            }
527        }
528        Ok(())
529    }
530
531    /// Merge segments into one, streaming postings/positions/store directly to files.
532    ///
533    /// If `trained` is provided, dense vectors use O(1) cluster merge when possible
534    /// (compatible IVF-PQ), otherwise rebuilds ANN from global artifacts.
535    /// Without trained structures, only flat vectors are merged.
536    ///
537    /// Uses streaming writers so postings, positions, and store data flow directly
538    /// to files instead of buffering everything in memory. Only the term dictionary
539    /// (compact key+TermInfo entries) is buffered.
540    pub async fn merge<D: Directory + DirectoryWriter>(
541        &self,
542        dir: &D,
543        segments: &[SegmentReader],
544        new_segment_id: SegmentId,
545        trained: Option<&TrainedVectorStructures>,
546    ) -> Result<(SegmentMeta, MergeStats)> {
547        self.ensure_not_cancelled()?;
548        // Reject an unrepresentable merge before creating any output files.
549        // The previous late check left a complete orphan output behind after
550        // doing all expensive phases.
551        let total_docs: u32 = segments
552            .iter()
553            .try_fold(0u32, |acc, segment| acc.checked_add(segment.num_docs()))
554            .ok_or_else(|| {
555                crate::Error::Internal(format!(
556                    "Total document count exceeds u32::MAX ({})",
557                    u32::MAX
558                ))
559            })?;
560
561        self.validate_merge_capacities(segments)?;
562
563        let mut stats = MergeStats::default();
564        let files = SegmentFiles::new(new_segment_id.0);
565
566        // === Two-stage merge to bound page cache pressure ===
567        //
568        // Stage 1: postings + store + fast_fields (concurrent)
569        //   Touches .term_dict, .postings, .positions, .store, .fast files.
570        //
571        // Stage 2: sparse + dense vectors. Block-copy sparse work runs with
572        // dense vectors; BP sparse work runs first to bound peak memory.
573        //   Touches .sparse, .vectors files.
574        //
575        // Running all phases concurrently caused OOM on large merges because
576        // mmap'd source files from all 16+ segments compete for page cache
577        // simultaneously (200+ GB of mmap'd data for BMP grids alone).
578        // Two stages halve the concurrent working set.
579        let merge_start = std::time::Instant::now();
580
581        // ── Stage 1: text + store + fast fields ─────────────────────────
582        let postings_fut = async {
583            let mut postings_writer =
584                OffsetWriter::new(dir.streaming_writer_cold(&files.postings).await?);
585            let mut positions_writer =
586                OffsetWriter::new(dir.streaming_writer_cold(&files.positions).await?);
587            let mut term_dict_writer =
588                OffsetWriter::new(dir.streaming_writer_cold(&files.term_dict).await?);
589
590            let terms_processed = self
591                .merge_postings(
592                    segments,
593                    &mut term_dict_writer,
594                    &mut postings_writer,
595                    &mut positions_writer,
596                )
597                .await?;
598
599            let postings_bytes = postings_writer.offset() as usize;
600            let term_dict_bytes = term_dict_writer.offset() as usize;
601            let positions_bytes = positions_writer.offset();
602
603            postings_writer.finish()?;
604            term_dict_writer.finish()?;
605            if positions_bytes > 0 {
606                positions_writer.finish()?;
607            } else {
608                drop(positions_writer);
609                let _ = dir.delete(&files.positions).await;
610            }
611            log::info!(
612                "[merge] index={} postings done: {} terms, term_dict={}, postings={}, positions={}",
613                self.schema.index_label(),
614                terms_processed,
615                crate::format_bytes(term_dict_bytes as u64),
616                crate::format_bytes(postings_bytes as u64),
617                crate::format_bytes(positions_bytes),
618            );
619            Ok::<(usize, usize, usize), crate::Error>((
620                terms_processed,
621                term_dict_bytes,
622                postings_bytes,
623            ))
624        };
625
626        let store_fut = async {
627            let mut store_writer =
628                OffsetWriter::new(dir.streaming_writer_cold(&files.store).await?);
629            let store_num_docs = self.merge_store(segments, &mut store_writer).await?;
630            let bytes = store_writer.offset() as usize;
631            store_writer.finish()?;
632            Ok::<(usize, u32), crate::Error>((bytes, store_num_docs))
633        };
634
635        let fast_fut = async { self.merge_fast_fields(dir, segments, &files).await };
636
637        let (postings_result, store_result, fast_bytes) =
638            tokio::try_join!(postings_fut, store_fut, fast_fut)?;
639        self.ensure_not_cancelled()?;
640
641        log::info!(
642            "[merge] index={} stage 1 done in {:.1}s (postings + store + fast)",
643            self.schema.index_label(),
644            merge_start.elapsed().as_secs_f64()
645        );
646
647        // ── Stage 2: sparse + dense vectors ─────────────────────────────
648        // Page cache from stage 1 files can now be evicted by the kernel
649        // as stage 2 accesses different mmap regions (.sparse, .vectors).
650        let sparse_fut = async { self.merge_sparse_vectors(dir, segments, &files).await };
651
652        let dense_fut = async {
653            self.merge_dense_vectors(dir, segments, &files, trained, AnnWriteMode::Copy)
654                .await
655        };
656
657        // Merge-time BP constructs a potentially budget-sized forward index.
658        // Do not overlap that allocation and its heavy source-file scan with
659        // an ANN rebuild. Block-copy sparse merges remain concurrent with ANN.
660        let ((sparse_bytes, bp_converged), vectors_bytes) = if self.reorder_bmp {
661            let sparse = sparse_fut.await?;
662            let dense = dense_fut.await?;
663            (sparse, dense)
664        } else {
665            tokio::try_join!(sparse_fut, dense_fut)?
666        };
667        self.ensure_not_cancelled()?;
668        let (store_bytes, store_num_docs) = store_result;
669        stats.terms_processed = postings_result.0;
670        stats.term_dict_bytes = postings_result.1;
671        stats.postings_bytes = postings_result.2;
672        stats.store_bytes = store_bytes;
673        stats.vectors_bytes = vectors_bytes;
674        stats.sparse_bytes = sparse_bytes;
675        stats.bp_converged = bp_converged;
676        stats.fast_bytes = fast_bytes;
677        log::info!(
678            "[merge] index={} all phases done in {:.1}s: {}",
679            self.schema.index_label(),
680            merge_start.elapsed().as_secs_f64(),
681            stats
682        );
683
684        // === Mandatory: merge field stats + write meta ===
685        self.ensure_not_cancelled()?;
686        let mut merged_field_stats: FxHashMap<u32, FieldStats> = FxHashMap::default();
687        for segment in segments {
688            for (&field_id, field_stats) in &segment.meta().field_stats {
689                let entry = merged_field_stats.entry(field_id).or_default();
690                entry.total_tokens = entry
691                    .total_tokens
692                    .checked_add(field_stats.total_tokens)
693                    .ok_or_else(|| {
694                        crate::Error::Corruption(format!(
695                            "field {} total-token count overflow while merging",
696                            field_id
697                        ))
698                    })?;
699                entry.doc_count = entry
700                    .doc_count
701                    .checked_add(field_stats.doc_count)
702                    .ok_or_else(|| {
703                        crate::Error::Corruption(format!(
704                            "field {} document count overflow while merging",
705                            field_id
706                        ))
707                    })?;
708            }
709        }
710
711        // Verify store doc count matches metadata — a mismatch here means
712        // some store blocks were lost (e.g., compression thread panic) or
713        // source segment metadata disagrees with its store.
714        if store_num_docs != total_docs {
715            log::error!(
716                "[merge] index={} STORE/META MISMATCH: store has {} docs but metadata expects {}. \
717                 Per-segment: {:?}",
718                self.schema.index_label(),
719                store_num_docs,
720                total_docs,
721                segments
722                    .iter()
723                    .map(|s| (
724                        format!("{:016x}", s.meta().id),
725                        s.num_docs(),
726                        s.store().num_docs()
727                    ))
728                    .collect::<Vec<_>>()
729            );
730            return Err(crate::Error::Io(std::io::Error::new(
731                std::io::ErrorKind::InvalidData,
732                format!(
733                    "Store/meta doc count mismatch: store={}, meta={}",
734                    store_num_docs, total_docs
735                ),
736            )));
737        }
738
739        let meta = SegmentMeta {
740            id: new_segment_id.0,
741            num_docs: total_docs,
742            field_stats: merged_field_stats,
743        };
744
745        // Durable: replace_segments deletes the fsynced source segments right
746        // after publishing this output, so a non-durable .meta could be the
747        // only copy of the merged documents across a power failure.
748        dir.write_durable(&files.meta, &meta.serialize()?).await?;
749
750        // Dense ANN payloads are byte-copied during an ordinary merge; the
751        // wall-clock timer also includes postings, store, sparse BP and any BP
752        // scheduler wait. Calling this an "ANN merge" made long sparse waits
753        // look like ANN construction in production logs.
754        log::info!(
755            "[merge] index={} complete: {} docs, {}",
756            self.schema.index_label(),
757            total_docs,
758            stats
759        );
760
761        Ok((meta, stats))
762    }
763}
764
765/// Delete segment files from directory (all deletions run concurrently).
766pub async fn delete_segment<D: Directory + DirectoryWriter>(
767    dir: &D,
768    segment_id: SegmentId,
769) -> Result<()> {
770    let files = SegmentFiles::new(segment_id.0);
771    let paths = files.lifecycle_paths();
772    let results = futures::future::join_all(paths.iter().map(|path| dir.delete(path))).await;
773
774    // Missing files are expected for optional components and idempotent
775    // retries. Any other failure must be surfaced so cleanup is not falsely
776    // reported as successful; a later orphan sweep can retry remaining files.
777    for result in results {
778        if let Err(error) = result
779            && error.kind() != std::io::ErrorKind::NotFound
780        {
781            return Err(crate::Error::Io(error));
782        }
783    }
784    Ok(())
785}
786
787#[cfg(test)]
788mod capacity_tests {
789    use super::{MergeCapacity, field_capacity_error};
790
791    #[test]
792    fn merge_capacity_accepts_the_exact_u32_boundary() {
793        let mut capacity = MergeCapacity::default();
794        assert_eq!(capacity.add(u64::from(u32::MAX) - 7), None);
795        assert_eq!(capacity.add(7), None);
796    }
797
798    #[test]
799    fn merge_capacity_rejects_the_first_value_beyond_u32() {
800        let mut capacity = MergeCapacity::default();
801        assert_eq!(capacity.add(u64::from(u32::MAX)), None);
802        assert_eq!(capacity.add(1), Some(u64::from(u32::MAX) + 1));
803    }
804
805    #[test]
806    fn merge_capacity_failure_is_not_source_corruption() {
807        let error = field_capacity_error(
808            7,
809            "body_embedding",
810            "dense vectors",
811            u64::from(u32::MAX) + 1,
812        );
813        assert!(matches!(error, crate::Error::Schema(_)));
814        assert!(error.to_string().contains("lower max_segment_docs"));
815    }
816}