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