Skip to main content

hermes_core/segment/merger/
mod.rs

1//! Segment merger for combining multiple segments
2
3mod dense;
4#[cfg(feature = "diagnostics")]
5mod diagnostics;
6mod fast_fields;
7mod postings;
8mod sparse;
9mod store;
10
11use std::sync::Arc;
12
13use rustc_hash::FxHashMap;
14
15use super::reader::SegmentReader;
16use super::types::{FieldStats, SegmentFiles, SegmentId, SegmentMeta};
17use super::{OffsetWriter, format_bytes};
18use crate::Result;
19use crate::directories::{Directory, DirectoryWriter};
20use crate::dsl::Schema;
21
22/// Compute per-segment doc ID offsets (each segment's docs start after the previous).
23///
24/// Returns an error if the total document count across segments exceeds `u32::MAX`.
25fn doc_offsets(segments: &[SegmentReader]) -> Result<Vec<u32>> {
26    let mut offsets = Vec::with_capacity(segments.len());
27    let mut acc = 0u32;
28    for seg in segments {
29        offsets.push(acc);
30        acc = acc.checked_add(seg.num_docs()).ok_or_else(|| {
31            crate::Error::Internal(format!(
32                "Total document count across segments exceeds u32::MAX ({})",
33                u32::MAX
34            ))
35        })?;
36    }
37    Ok(offsets)
38}
39
40/// Statistics for merge operations
41#[derive(Debug, Clone, Default)]
42pub struct MergeStats {
43    /// Number of terms processed
44    pub terms_processed: usize,
45    /// Term dictionary output size
46    pub term_dict_bytes: usize,
47    /// Postings output size
48    pub postings_bytes: usize,
49    /// Store output size
50    pub store_bytes: usize,
51    /// Vector index output size
52    pub vectors_bytes: usize,
53    /// Sparse vector index output size
54    pub sparse_bytes: usize,
55    /// Fast-field output size
56    pub fast_bytes: usize,
57}
58
59impl std::fmt::Display for MergeStats {
60    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
61        write!(
62            f,
63            "terms={}, term_dict={}, postings={}, store={}, vectors={}, sparse={}, fast={}",
64            self.terms_processed,
65            format_bytes(self.term_dict_bytes),
66            format_bytes(self.postings_bytes),
67            format_bytes(self.store_bytes),
68            format_bytes(self.vectors_bytes),
69            format_bytes(self.sparse_bytes),
70            format_bytes(self.fast_bytes),
71        )
72    }
73}
74
75// TrainedVectorStructures is defined in super::types (available on all platforms)
76pub use super::types::TrainedVectorStructures;
77
78/// Run a CPU/IO-heavy synchronous section, telling tokio to migrate this
79/// worker's task queue first (multi-thread runtimes only — `block_in_place`
80/// panics on current_thread, where we just run inline).
81pub(crate) fn block_in_place_if_multithread<R>(f: impl FnOnce() -> R) -> R {
82    if tokio::runtime::Handle::try_current()
83        .map(|h| h.runtime_flavor() == tokio::runtime::RuntimeFlavor::MultiThread)
84        .unwrap_or(false)
85    {
86        tokio::task::block_in_place(f)
87    } else {
88        f()
89    }
90}
91
92/// Segment merger - merges multiple segments into one
93pub struct SegmentMerger {
94    schema: Arc<Schema>,
95    /// Run BP reordering on BMP sparse fields while writing the merged blob
96    /// (instead of byte-level block stacking). The output segment is then
97    /// already ordered, so the standalone reorder pass is unnecessary.
98    reorder_bmp: bool,
99    /// Bounded rayon pool for merge-time BP. `None` = global pool (tests);
100    /// the SegmentManager always passes its background pool so BP cannot
101    /// starve query scoring.
102    background_pool: Option<Arc<rayon::ThreadPool>>,
103    /// Granularity for merge-time BP. `Auto` by default; the SegmentManager
104    /// forces `Records` when any merge source is an unconverged partial
105    /// reorder — the merged segment is marked `bp_converged`, so this merge
106    /// is the deepening pass those sources were owed.
107    granularity: crate::segment::reorder::BpGranularity,
108}
109
110impl SegmentMerger {
111    pub fn new(schema: Arc<Schema>) -> Self {
112        Self {
113            schema,
114            reorder_bmp: false,
115            background_pool: None,
116            granularity: crate::segment::reorder::BpGranularity::Auto,
117        }
118    }
119
120    /// Enable BP reordering of BMP fields during the merge (see `reorder_bmp`).
121    pub fn with_bmp_reorder(mut self, reorder: bool) -> Self {
122        self.reorder_bmp = reorder;
123        self
124    }
125
126    /// Run merge-time BP on this bounded pool instead of the global one.
127    pub fn with_background_pool(mut self, pool: Option<Arc<rayon::ThreadPool>>) -> Self {
128        self.background_pool = pool;
129        self
130    }
131
132    /// Set merge-time BP granularity (see `granularity`).
133    pub fn with_granularity(mut self, granularity: crate::segment::reorder::BpGranularity) -> Self {
134        self.granularity = granularity;
135        self
136    }
137
138    /// Merge segments into one, streaming postings/positions/store directly to files.
139    ///
140    /// If `trained` is provided, dense vectors use O(1) cluster merge when possible
141    /// (homogeneous IVF/ScaNN), otherwise rebuilds ANN from trained structures.
142    /// Without trained structures, only flat vectors are merged.
143    ///
144    /// Uses streaming writers so postings, positions, and store data flow directly
145    /// to files instead of buffering everything in memory. Only the term dictionary
146    /// (compact key+TermInfo entries) is buffered.
147    pub async fn merge<D: Directory + DirectoryWriter>(
148        &self,
149        dir: &D,
150        segments: &[SegmentReader],
151        new_segment_id: SegmentId,
152        trained: Option<&TrainedVectorStructures>,
153    ) -> Result<(SegmentMeta, MergeStats)> {
154        let mut stats = MergeStats::default();
155        let files = SegmentFiles::new(new_segment_id.0);
156
157        // === Two-stage merge to bound page cache pressure ===
158        //
159        // Stage 1: postings + store + fast_fields (concurrent)
160        //   Touches .term_dict, .postings, .positions, .store, .fast files.
161        //
162        // Stage 2: sparse + dense vectors (concurrent)
163        //   Touches .sparse, .vectors files.
164        //
165        // Running all phases concurrently caused OOM on large merges because
166        // mmap'd source files from all 16+ segments compete for page cache
167        // simultaneously (200+ GB of mmap'd data for BMP grids alone).
168        // Two stages halve the concurrent working set.
169        let merge_start = std::time::Instant::now();
170
171        // ── Stage 1: text + store + fast fields ─────────────────────────
172        let postings_fut = async {
173            let mut postings_writer =
174                OffsetWriter::new(dir.streaming_writer_cold(&files.postings).await?);
175            let mut positions_writer =
176                OffsetWriter::new(dir.streaming_writer_cold(&files.positions).await?);
177            let mut term_dict_writer =
178                OffsetWriter::new(dir.streaming_writer_cold(&files.term_dict).await?);
179
180            let terms_processed = self
181                .merge_postings(
182                    segments,
183                    &mut term_dict_writer,
184                    &mut postings_writer,
185                    &mut positions_writer,
186                )
187                .await?;
188
189            let postings_bytes = postings_writer.offset() as usize;
190            let term_dict_bytes = term_dict_writer.offset() as usize;
191            let positions_bytes = positions_writer.offset();
192
193            postings_writer.finish()?;
194            term_dict_writer.finish()?;
195            if positions_bytes > 0 {
196                positions_writer.finish()?;
197            } else {
198                drop(positions_writer);
199                let _ = dir.delete(&files.positions).await;
200            }
201            log::info!(
202                "[merge] postings done: {} terms, term_dict={}, postings={}, positions={}",
203                terms_processed,
204                format_bytes(term_dict_bytes),
205                format_bytes(postings_bytes),
206                format_bytes(positions_bytes as usize),
207            );
208            Ok::<(usize, usize, usize), crate::Error>((
209                terms_processed,
210                term_dict_bytes,
211                postings_bytes,
212            ))
213        };
214
215        let store_fut = async {
216            let mut store_writer =
217                OffsetWriter::new(dir.streaming_writer_cold(&files.store).await?);
218            let store_num_docs = self.merge_store(segments, &mut store_writer).await?;
219            let bytes = store_writer.offset() as usize;
220            store_writer.finish()?;
221            Ok::<(usize, u32), crate::Error>((bytes, store_num_docs))
222        };
223
224        let fast_fut = async { self.merge_fast_fields(dir, segments, &files).await };
225
226        let (postings_result, store_result, fast_bytes) =
227            tokio::try_join!(postings_fut, store_fut, fast_fut)?;
228
229        log::info!(
230            "[merge] stage 1 done in {:.1}s (postings + store + fast)",
231            merge_start.elapsed().as_secs_f64()
232        );
233
234        // ── Stage 2: sparse + dense vectors ─────────────────────────────
235        // Page cache from stage 1 files can now be evicted by the kernel
236        // as stage 2 accesses different mmap regions (.sparse, .vectors).
237        let sparse_fut = async { self.merge_sparse_vectors(dir, segments, &files).await };
238
239        let dense_fut = async {
240            self.merge_dense_vectors(dir, segments, &files, trained)
241                .await
242        };
243
244        let (sparse_bytes, vectors_bytes) = tokio::try_join!(sparse_fut, dense_fut)?;
245        let (store_bytes, store_num_docs) = store_result;
246        stats.terms_processed = postings_result.0;
247        stats.term_dict_bytes = postings_result.1;
248        stats.postings_bytes = postings_result.2;
249        stats.store_bytes = store_bytes;
250        stats.vectors_bytes = vectors_bytes;
251        stats.sparse_bytes = sparse_bytes;
252        stats.fast_bytes = fast_bytes;
253        log::info!(
254            "[merge] all phases done in {:.1}s: {}",
255            merge_start.elapsed().as_secs_f64(),
256            stats
257        );
258
259        // === Mandatory: merge field stats + write meta ===
260        let mut merged_field_stats: FxHashMap<u32, FieldStats> = FxHashMap::default();
261        for segment in segments {
262            for (&field_id, field_stats) in &segment.meta().field_stats {
263                let entry = merged_field_stats.entry(field_id).or_default();
264                entry.total_tokens += field_stats.total_tokens;
265                entry.doc_count += field_stats.doc_count;
266            }
267        }
268
269        let total_docs: u32 = segments
270            .iter()
271            .try_fold(0u32, |acc, s| acc.checked_add(s.num_docs()))
272            .ok_or_else(|| {
273                crate::Error::Internal(format!(
274                    "Total document count exceeds u32::MAX ({})",
275                    u32::MAX
276                ))
277            })?;
278
279        // Verify store doc count matches metadata — a mismatch here means
280        // some store blocks were lost (e.g., compression thread panic) or
281        // source segment metadata disagrees with its store.
282        if store_num_docs != total_docs {
283            log::error!(
284                "[merge] STORE/META MISMATCH: store has {} docs but metadata expects {}. \
285                 Per-segment: {:?}",
286                store_num_docs,
287                total_docs,
288                segments
289                    .iter()
290                    .map(|s| (
291                        format!("{:016x}", s.meta().id),
292                        s.num_docs(),
293                        s.store().num_docs()
294                    ))
295                    .collect::<Vec<_>>()
296            );
297            return Err(crate::Error::Io(std::io::Error::new(
298                std::io::ErrorKind::InvalidData,
299                format!(
300                    "Store/meta doc count mismatch: store={}, meta={}",
301                    store_num_docs, total_docs
302                ),
303            )));
304        }
305
306        let meta = SegmentMeta {
307            id: new_segment_id.0,
308            num_docs: total_docs,
309            field_stats: merged_field_stats,
310        };
311
312        dir.write(&files.meta, &meta.serialize()?).await?;
313
314        let label = if trained.is_some() {
315            "ANN merge"
316        } else {
317            "Merge"
318        };
319        log::info!("{} complete: {} docs, {}", label, total_docs, stats);
320
321        Ok((meta, stats))
322    }
323}
324
325/// Delete segment files from directory (all deletions run concurrently).
326pub async fn delete_segment<D: Directory + DirectoryWriter>(
327    dir: &D,
328    segment_id: SegmentId,
329) -> Result<()> {
330    let files = SegmentFiles::new(segment_id.0);
331    let _ = tokio::join!(
332        dir.delete(&files.term_dict),
333        dir.delete(&files.postings),
334        dir.delete(&files.store),
335        dir.delete(&files.meta),
336        dir.delete(&files.vectors),
337        dir.delete(&files.sparse),
338        dir.delete(&files.positions),
339        dir.delete(&files.fast),
340    );
341    Ok(())
342}