Skip to main content

hermes_core/merge/
segment_manager.rs

1//! Segment manager — coordinates segment commit, background merging, and trained structures.
2//!
3//! Architecture:
4//! - **Single mutation queue**: All metadata mutations serialize through `tokio::sync::Mutex<ManagerState>`.
5//! - **Concurrent merges**: Multiple non-overlapping merges can run in parallel.
6//!   Each merge registers its segment IDs in `MergeInventory` via RAII `MergeGuard`.
7//!   New merges are rejected only if they share segments with an active merge.
8//! - **Auto-trigger**: Each completed merge re-evaluates the merge policy and spawns
9//!   new merges if eligible (cascading merges for higher tiers).
10//! - **ArcSwap for trained**: Lock-free reads of trained vector structures.
11//!
12//! # Locking model (deadlock-free by construction)
13//!
14//! ```text
15//! Lock ordering (acquire in this order):
16//!   1. state               — tokio::sync::Mutex, held for mutations + disk I/O
17//!   2. merge_inventory     — parking_lot::Mutex (sync), sub-μs hold, RAII via MergeGuard
18//!   3. tracker.inner       — parking_lot::Mutex (sync), sub-μs hold
19//!
20//! Lock-free state:
21//!   trained                — arc_swap::ArcSwapOption, no ordering constraint
22//!   merge_handles          — tokio::sync::Mutex, never held with state
23//! ```
24//!
25//! **Rule:** Never hold a sync lock while `.await`-ing.
26
27use std::collections::HashSet;
28use std::sync::Arc;
29
30use arc_swap::ArcSwapOption;
31use tokio::sync::Mutex as AsyncMutex;
32use tokio::task::JoinHandle;
33
34use crate::directories::DirectoryWriter;
35use crate::error::{Error, Result};
36use crate::index::IndexMetadata;
37use crate::segment::{SegmentId, SegmentSnapshot, SegmentTracker, TrainedVectorStructures};
38#[cfg(feature = "native")]
39use crate::segment::{SegmentMerger, SegmentReader};
40
41use super::{MergePolicy, SegmentInfo};
42
43// ============================================================================
44// RAII merge tracking
45// ============================================================================
46
47/// Tracks which segments are involved in active merges.
48///
49/// Supports multiple concurrent merges. Each merge registers its segment IDs;
50/// a new merge is rejected only if any of its segments overlap with an active merge.
51/// Uses RAII via `MergeGuard`: when a merge task ends, the guard drops and
52/// its segment IDs are automatically unregistered.
53struct MergeInventory {
54    inner: parking_lot::Mutex<HashSet<String>>,
55}
56
57impl MergeInventory {
58    fn new() -> Self {
59        Self {
60            inner: parking_lot::Mutex::new(HashSet::new()),
61        }
62    }
63
64    /// Try to register a merge. Returns `MergeGuard` on success, `None` if
65    /// any of the requested segments are already in an active merge.
66    fn try_register(self: &Arc<Self>, segment_ids: Vec<String>) -> Option<MergeGuard> {
67        let mut inner = self.inner.lock();
68        // Check for overlap with any active merge
69        for id in &segment_ids {
70            if inner.contains(id) {
71                log::debug!(
72                    "[merge_inventory] rejected: {} overlaps with active merge ({} active IDs)",
73                    id,
74                    inner.len()
75                );
76                return None;
77            }
78        }
79        log::debug!(
80            "[merge_inventory] registered {} IDs (total active: {})",
81            segment_ids.len(),
82            inner.len() + segment_ids.len()
83        );
84        for id in &segment_ids {
85            inner.insert(id.clone());
86        }
87        Some(MergeGuard {
88            inventory: Arc::clone(self),
89            segment_ids,
90        })
91    }
92
93    /// Snapshot of all in-merge segment IDs (for cleanup_orphan_segments)
94    fn snapshot(&self) -> HashSet<String> {
95        self.inner.lock().clone()
96    }
97
98    /// Check if a specific segment is currently involved in a merge.
99    fn contains(&self, segment_id: &str) -> bool {
100        self.inner.lock().contains(segment_id)
101    }
102}
103
104/// RAII guard for a merge operation.
105/// Dropped when the merge task completes (success, failure, or panic) —
106/// automatically unregisters this merge's segment IDs from the inventory.
107struct MergeGuard {
108    inventory: Arc<MergeInventory>,
109    segment_ids: Vec<String>,
110}
111
112impl Drop for MergeGuard {
113    fn drop(&mut self) {
114        let mut inner = self.inventory.inner.lock();
115        for id in &self.segment_ids {
116            inner.remove(id);
117        }
118    }
119}
120
121/// All mutable state behind the single async Mutex.
122struct ManagerState {
123    metadata: IndexMetadata,
124    merge_policy: Box<dyn MergePolicy>,
125}
126
127/// Segment manager — coordinates segment commit, background merging, and trained structures.
128///
129/// SOLE owner of `metadata.json`. All metadata mutations go through `state` Mutex.
130pub struct SegmentManager<D: DirectoryWriter + 'static> {
131    /// Serializes ALL metadata mutations.
132    state: AsyncMutex<ManagerState>,
133
134    /// RAII merge tracking: segments are registered on merge start, automatically
135    /// unregistered when the merge task ends (via MergeGuard drop).
136    merge_inventory: Arc<MergeInventory>,
137
138    /// In-flight merge JoinHandles — supports multiple concurrent merges.
139    merge_handles: AsyncMutex<Vec<JoinHandle<()>>>,
140
141    /// Trained vector structures — lock-free reads via ArcSwap.
142    trained: ArcSwapOption<TrainedVectorStructures>,
143
144    /// Reference counting for safe segment deletion (sync Mutex for Drop).
145    tracker: Arc<SegmentTracker>,
146
147    /// Cached deletion callback for snapshots (avoids allocation per acquire_snapshot).
148    delete_fn: Arc<dyn Fn(Vec<SegmentId>) + Send + Sync>,
149
150    /// Directory for segment I/O
151    directory: Arc<D>,
152    /// Schema for segment operations
153    schema: Arc<crate::dsl::Schema>,
154    /// Term cache blocks for segment readers during merge
155    term_cache_blocks: usize,
156    /// Maximum number of concurrent background merges
157    max_concurrent_merges: usize,
158    /// Run BP reordering of `reorder`-attributed BMP fields inside merges.
159    /// Persisted index configuration (schema-level `reorder_on_merge: true`
160    /// in SDL); merged segments are marked `reordered` and skipped by the
161    /// standalone optimizer pass.
162    reorder_on_merge: bool,
163    /// Bounded rayon pool for background CPU work (merge-time BP, manual
164    /// reorder). Built lazily at ~cores/4 so background passes cannot
165    /// saturate the global rayon pool that query scoring runs on.
166    bg_cpu_pool: std::sync::OnceLock<Arc<rayon::ThreadPool>>,
167}
168
169impl<D: DirectoryWriter + 'static> SegmentManager<D> {
170    /// Create a new segment manager with existing metadata
171    pub fn new(
172        directory: Arc<D>,
173        schema: Arc<crate::dsl::Schema>,
174        metadata: IndexMetadata,
175        merge_policy: Box<dyn MergePolicy>,
176        term_cache_blocks: usize,
177        max_concurrent_merges: usize,
178    ) -> Self {
179        // Persisted index option: set via `reorder_on_merge: true` in the SDL
180        // at index creation. Absent = disabled (merges block-copy).
181        let reorder_on_merge = schema.reorder_on_merge();
182        if reorder_on_merge {
183            log::info!("[merge] reorder-on-merge enabled by index schema");
184        }
185
186        let tracker = Arc::new(SegmentTracker::new());
187        for seg_id in metadata.segment_metas.keys() {
188            tracker.register(seg_id);
189        }
190
191        let delete_fn: Arc<dyn Fn(Vec<SegmentId>) + Send + Sync> = {
192            let dir = Arc::clone(&directory);
193            Arc::new(move |segment_ids| {
194                // Guard: if the tokio runtime is gone (program exit), skip async
195                // deletion. Segment files become orphans cleaned up on next startup.
196                let Ok(handle) = tokio::runtime::Handle::try_current() else {
197                    return;
198                };
199                let dir = Arc::clone(&dir);
200                handle.spawn(async move {
201                    for segment_id in segment_ids {
202                        log::info!(
203                            "[segment_cleanup] deleting deferred segment {}",
204                            segment_id.0
205                        );
206                        let _ = crate::segment::delete_segment(dir.as_ref(), segment_id).await;
207                    }
208                });
209            })
210        };
211
212        Self {
213            state: AsyncMutex::new(ManagerState {
214                metadata,
215                merge_policy,
216            }),
217            merge_inventory: Arc::new(MergeInventory::new()),
218            merge_handles: AsyncMutex::new(Vec::new()),
219            trained: ArcSwapOption::new(None),
220            tracker,
221            delete_fn,
222            directory,
223            schema,
224            term_cache_blocks,
225            max_concurrent_merges: max_concurrent_merges.max(1),
226            reorder_on_merge,
227            bg_cpu_pool: std::sync::OnceLock::new(),
228        }
229    }
230
231    /// Bounded rayon pool for background CPU (merge-time BP, manual reorder).
232    /// Query scoring uses the global rayon pool; keeping background BP off it
233    /// prevents a large merge from queueing every search behind gain passes.
234    pub fn background_cpu_pool(&self) -> Arc<rayon::ThreadPool> {
235        Arc::clone(self.bg_cpu_pool.get_or_init(|| {
236            let threads = (num_cpus::get() / 4).max(1);
237            log::info!("[merge] background CPU pool: {} thread(s)", threads);
238            Arc::new(
239                rayon::ThreadPoolBuilder::new()
240                    .num_threads(threads)
241                    .thread_name(|i| format!("hermes-bg-cpu-{}", i))
242                    .build()
243                    .expect("failed to build background CPU pool"),
244            )
245        }))
246    }
247
248    // ========================================================================
249    // Read path (brief lock or lock-free)
250    // ========================================================================
251
252    /// Get the current segment IDs
253    pub async fn get_segment_ids(&self) -> Vec<String> {
254        self.state.lock().await.metadata.segment_ids()
255    }
256
257    /// Get trained vector structures (lock-free via ArcSwap)
258    pub fn trained(&self) -> Option<Arc<TrainedVectorStructures>> {
259        self.trained.load_full()
260    }
261
262    /// Load trained structures from disk and publish to ArcSwap.
263    /// Copies metadata under lock, releases lock, then does disk I/O.
264    pub async fn load_and_publish_trained(&self) {
265        // Copy vector_fields under lock (cheap clone of HashMap<u32, FieldMeta>)
266        let vector_fields = {
267            let st = self.state.lock().await;
268            st.metadata.vector_fields.clone()
269        };
270        // Disk I/O happens WITHOUT holding the state lock
271        let trained =
272            IndexMetadata::load_trained_from_fields(&vector_fields, self.directory.as_ref()).await;
273        if let Some(t) = trained {
274            self.trained.store(Some(Arc::new(t)));
275        }
276    }
277
278    /// Clear trained structures (sets ArcSwap to None)
279    pub(crate) fn clear_trained(&self) {
280        self.trained.store(None);
281    }
282
283    /// Read metadata with a closure (no persist)
284    pub(crate) async fn read_metadata<F, R>(&self, f: F) -> R
285    where
286        F: FnOnce(&IndexMetadata) -> R,
287    {
288        let st = self.state.lock().await;
289        f(&st.metadata)
290    }
291
292    /// Update metadata with a closure and persist atomically
293    pub(crate) async fn update_metadata<F>(&self, f: F) -> Result<()>
294    where
295        F: FnOnce(&mut IndexMetadata),
296    {
297        let mut st = self.state.lock().await;
298        f(&mut st.metadata);
299        st.metadata.save(self.directory.as_ref()).await
300    }
301
302    /// Acquire a snapshot of current segments for reading.
303    /// The snapshot holds references — segments won't be deleted while snapshot exists.
304    pub async fn acquire_snapshot(&self) -> SegmentSnapshot {
305        let acquired = {
306            let st = self.state.lock().await;
307            let segment_ids = st.metadata.segment_ids();
308            self.tracker.acquire(&segment_ids)
309        };
310
311        SegmentSnapshot::with_delete_fn(
312            Arc::clone(&self.tracker),
313            acquired,
314            Arc::clone(&self.delete_fn),
315        )
316    }
317
318    /// Get the segment tracker
319    pub fn tracker(&self) -> Arc<SegmentTracker> {
320        Arc::clone(&self.tracker)
321    }
322
323    /// Get the directory
324    pub fn directory(&self) -> Arc<D> {
325        Arc::clone(&self.directory)
326    }
327}
328
329// ============================================================================
330// Native-only: commit, merging, force_merge
331// ============================================================================
332
333#[cfg(feature = "native")]
334impl<D: DirectoryWriter + 'static> SegmentManager<D> {
335    /// Atomic commit: register new segments + persist metadata.
336    pub async fn commit(&self, new_segments: Vec<(String, u32)>) -> Result<()> {
337        let mut st = self.state.lock().await;
338        for (segment_id, num_docs) in new_segments {
339            if !st.metadata.has_segment(&segment_id) {
340                st.metadata.add_segment(segment_id.clone(), num_docs);
341                self.tracker.register(&segment_id);
342            }
343        }
344        st.metadata.save(self.directory.as_ref()).await
345    }
346
347    /// Evaluate merge policy and spawn background merges for all eligible candidates.
348    ///
349    /// **Atomicity**: The entire filter → find_merges → spawn_merge sequence runs
350    /// under the `state` lock to prevent a TOCTOU race where concurrent callers
351    /// both see segments as eligible before either registers them in the inventory.
352    /// `spawn_merge` is non-blocking (just `try_register` + `tokio::spawn`), so
353    /// holding the state lock through it is safe and sub-microsecond.
354    ///
355    /// Note: `max_concurrent_merges` is a soft limit — concurrent auto-triggers
356    /// may briefly exceed it by one or two due to TOCTOU between slot counting
357    /// and handle registration.
358    pub async fn maybe_merge(self: &Arc<Self>) {
359        // Drain completed handles and check how many slots are available
360        let slots_available = {
361            let mut handles = self.merge_handles.lock().await;
362            handles.retain(|h| !h.is_finished());
363            self.max_concurrent_merges.saturating_sub(handles.len())
364        };
365
366        if slots_available == 0 {
367            log::debug!("[maybe_merge] at max concurrent merges, skipping");
368            return;
369        }
370
371        // Hold state lock through spawn_merge to make filter + register atomic.
372        // This closes the TOCTOU window where concurrent maybe_merge calls could
373        // both see the same segments as eligible before either registers them.
374        let new_handles = {
375            let st = self.state.lock().await;
376
377            // Exclude segments that are pending deletion OR already in an active merge.
378            let segments: Vec<SegmentInfo> = st
379                .metadata
380                .segment_metas
381                .iter()
382                .filter(|(id, _)| {
383                    !self.tracker.is_pending_deletion(id) && !self.merge_inventory.contains(id)
384                })
385                .map(|(id, info)| SegmentInfo {
386                    id: id.clone(),
387                    num_docs: info.num_docs,
388                })
389                .collect();
390
391            log::debug!("[maybe_merge] {} eligible segments", segments.len());
392
393            let candidates = st.merge_policy.find_merges(&segments);
394
395            if candidates.is_empty() {
396                return;
397            }
398
399            log::debug!(
400                "[maybe_merge] {} merge candidates, {} slots available",
401                candidates.len(),
402                slots_available
403            );
404
405            let mut handles = Vec::new();
406            for c in candidates {
407                if handles.len() >= slots_available {
408                    break;
409                }
410                if let Some(h) = self.spawn_merge(c.segment_ids) {
411                    handles.push(h);
412                }
413            }
414            handles
415            // state lock released here — after spawn_merge registered IDs in inventory
416        };
417
418        if !new_handles.is_empty() {
419            self.merge_handles.lock().await.extend(new_handles);
420        }
421    }
422
423    /// Spawn a background merge task with RAII tracking.
424    ///
425    /// Pre-generates the output segment ID. `MergeGuard` registers all segment IDs
426    /// (old + output) in `merge_inventory`. When the task ends (success, failure, or
427    /// panic), the guard drops and segments are automatically unregistered.
428    ///
429    /// On completion, the task auto-triggers `maybe_merge` to evaluate cascading merges.
430    /// Returns the JoinHandle if the merge was spawned, None if it was skipped.
431    fn spawn_merge(self: &Arc<Self>, segment_ids_to_merge: Vec<String>) -> Option<JoinHandle<()>> {
432        let output_id = SegmentId::new();
433        let output_hex = output_id.to_hex();
434
435        let mut all_ids = segment_ids_to_merge.clone();
436        all_ids.push(output_hex);
437
438        let guard = match self.merge_inventory.try_register(all_ids) {
439            Some(g) => g,
440            None => {
441                log::debug!("[spawn_merge] skipped: segments overlap with active merge");
442                return None;
443            }
444        };
445
446        let sm = Arc::clone(self);
447        let ids = segment_ids_to_merge;
448
449        Some(tokio::spawn(async move {
450            let _guard = guard;
451
452            let trained_snap = sm.trained();
453            let granularity = sm.merge_granularity(&ids).await;
454            let result = Self::do_merge(
455                sm.directory.as_ref(),
456                &sm.schema,
457                &ids,
458                output_id,
459                sm.term_cache_blocks,
460                trained_snap.as_deref(),
461                sm.reorder_on_merge,
462                granularity,
463                Some(sm.background_cpu_pool()),
464            )
465            .await;
466
467            match result {
468                Ok((new_id, doc_count)) => {
469                    if let Err(e) = sm
470                        .replace_segments(&ids, new_id, doc_count, sm.reorder_on_merge, true)
471                        .await
472                    {
473                        log::error!("[merge] Failed to replace segments after merge: {:?}", e);
474                    }
475                }
476                Err(e) => {
477                    log::error!(
478                        "[merge] Background merge failed for segments {:?}: {:?}",
479                        ids,
480                        e
481                    );
482                }
483            }
484            // _guard drops here → segment IDs unregistered from inventory
485
486            // Auto-trigger: re-evaluate merge policy after this merge completes.
487            // The merged output may now be eligible for a higher-tier merge.
488            sm.maybe_merge().await;
489        }))
490    }
491
492    /// Atomically replace old segments with a new merged segment.
493    /// Computes merge generation as max(parent gens) + 1 and records ancestors.
494    /// `reordered` marks whether the new segment was BP-reordered.
495    async fn replace_segments(
496        &self,
497        old_ids: &[String],
498        new_id: String,
499        doc_count: u32,
500        reordered: bool,
501        bp_converged: bool,
502    ) -> Result<()> {
503        self.tracker.register(&new_id);
504
505        {
506            let mut st = self.state.lock().await;
507            // Compute generation from parents before removing them
508            let parent_gen = old_ids
509                .iter()
510                .filter_map(|id| st.metadata.segment_metas.get(id))
511                .map(|info| info.generation)
512                .max()
513                .unwrap_or(0);
514            let ancestors: Vec<String> = old_ids.to_vec();
515
516            for id in old_ids {
517                st.metadata.remove_segment(id);
518            }
519            st.metadata.add_merged_segment(
520                new_id,
521                doc_count,
522                ancestors,
523                parent_gen + 1,
524                reordered,
525                bp_converged,
526            );
527            // Mutation + persist must be atomic — keep under lock
528            st.metadata.save(self.directory.as_ref()).await?;
529        }
530
531        let ready_to_delete = self.tracker.mark_for_deletion(old_ids);
532        for segment_id in ready_to_delete {
533            let _ = crate::segment::delete_segment(self.directory.as_ref(), segment_id).await;
534        }
535        Ok(())
536    }
537
538    /// Perform the actual merge operation (pure function — no shared state access).
539    /// `output_segment_id` is pre-generated by the caller so it can be tracked in `merge_inventory`.
540    /// Returns (new_segment_id_hex, total_doc_count).
541    #[allow(clippy::too_many_arguments)]
542    #[allow(clippy::too_many_arguments)]
543    pub(crate) async fn do_merge(
544        directory: &D,
545        schema: &Arc<crate::dsl::Schema>,
546        segment_ids_to_merge: &[String],
547        output_segment_id: SegmentId,
548        term_cache_blocks: usize,
549        trained: Option<&TrainedVectorStructures>,
550        reorder_bmp: bool,
551        granularity: crate::segment::reorder::BpGranularity,
552        bg_cpu_pool: Option<Arc<rayon::ThreadPool>>,
553    ) -> Result<(String, u32)> {
554        let output_hex = output_segment_id.to_hex();
555        let load_start = std::time::Instant::now();
556
557        let segment_ids: Vec<SegmentId> = segment_ids_to_merge
558            .iter()
559            .map(|id_str| {
560                SegmentId::from_hex(id_str)
561                    .ok_or_else(|| Error::Corruption(format!("Invalid segment ID: {}", id_str)))
562            })
563            .collect::<Result<Vec<_>>>()?;
564
565        let schema_arc = Arc::clone(schema);
566        let futures: Vec<_> = segment_ids
567            .iter()
568            .map(|&sid| {
569                let sch = Arc::clone(&schema_arc);
570                async move { SegmentReader::open(directory, sid, sch, term_cache_blocks).await }
571            })
572            .collect();
573
574        let results = futures::future::join_all(futures).await;
575        let mut readers = Vec::with_capacity(results.len());
576        let mut total_docs = 0u64;
577        for (i, result) in results.into_iter().enumerate() {
578            match result {
579                Ok(r) => {
580                    total_docs += r.meta().num_docs as u64;
581                    readers.push(r);
582                }
583                Err(e) => {
584                    log::error!(
585                        "[merge] Failed to open segment {}: {:?}",
586                        segment_ids_to_merge[i],
587                        e
588                    );
589                    return Err(e);
590                }
591            }
592        }
593
594        // Pre-merge validation: verify each source segment's store doc count
595        // matches its metadata. Catching mismatches early avoids building a
596        // corrupted merged segment and leaving orphan files on disk.
597        for (i, reader) in readers.iter().enumerate() {
598            let meta_docs = reader.meta().num_docs;
599            let store_docs = reader.store().num_docs();
600            if store_docs != meta_docs {
601                return Err(Error::Corruption(format!(
602                    "pre-merge validation: segment {} store has {} docs but meta says {}",
603                    segment_ids_to_merge[i], store_docs, meta_docs
604                )));
605            }
606        }
607
608        log::info!(
609            "[merge] loaded {} segment readers in {:.1}s",
610            readers.len(),
611            load_start.elapsed().as_secs_f64()
612        );
613
614        let merger = SegmentMerger::new(Arc::clone(schema))
615            .with_bmp_reorder(reorder_bmp)
616            .with_granularity(granularity)
617            .with_background_pool(bg_cpu_pool);
618
619        log::info!(
620            "[merge] {} segments -> {} (trained={})",
621            segment_ids_to_merge.len(),
622            output_hex,
623            trained.map_or(0, |t| t.centroids.len()),
624        );
625
626        merger
627            .merge(directory, &readers, output_segment_id, trained)
628            .await?;
629
630        log::info!(
631            "[merge] total wall-clock: {:.1}s ({} segments, {} docs)",
632            load_start.elapsed().as_secs_f64(),
633            readers.len(),
634            total_docs,
635        );
636
637        if total_docs > u32::MAX as u64 {
638            return Err(Error::Internal(format!(
639                "Merged segment doc count ({}) exceeds u32::MAX",
640                total_docs
641            )));
642        }
643        Ok((output_hex, total_docs as u32))
644    }
645
646    /// Abort all in-flight merge tasks without waiting for completion.
647    /// Used during index deletion to stop background work immediately.
648    pub async fn abort_merges(&self) {
649        let handles: Vec<JoinHandle<()>> =
650            { std::mem::take(&mut *self.merge_handles.lock().await) };
651        for h in handles {
652            h.abort();
653        }
654    }
655
656    /// Wait for all current in-flight merges to complete.
657    pub async fn wait_for_merging_thread(self: &Arc<Self>) {
658        let handles: Vec<JoinHandle<()>> =
659            { std::mem::take(&mut *self.merge_handles.lock().await) };
660        for h in handles {
661            let _ = h.await;
662        }
663    }
664
665    /// Wait for all eligible merges to complete, including cascading merges.
666    ///
667    /// Drains current handles, then loops. Each completed merge auto-triggers
668    /// `maybe_merge` (which pushes new handles) before its JoinHandle resolves,
669    /// so by the time `h.await` returns all cascading handles are registered.
670    pub async fn wait_for_all_merges(self: &Arc<Self>) {
671        loop {
672            let handles: Vec<JoinHandle<()>> =
673                { std::mem::take(&mut *self.merge_handles.lock().await) };
674            if handles.is_empty() {
675                break;
676            }
677            for h in handles {
678                let _ = h.await;
679            }
680        }
681    }
682
683    /// Force merge segments into the fewest possible segments, respecting
684    /// `max_segment_docs` from the merge policy.
685    ///
686    /// If the policy defines a max segment size, segments are merged in batches
687    /// that stay within that limit. Otherwise, all segments are merged into one.
688    ///
689    /// Each batch is registered in `merge_inventory` via `MergeGuard` to prevent
690    /// `maybe_merge` from spawning a conflicting background merge.
691    pub async fn force_merge(self: &Arc<Self>) -> Result<()> {
692        const FORCE_MERGE_BATCH: usize = 64;
693
694        let max_segment_docs = {
695            let st = self.state.lock().await;
696            st.merge_policy.max_segment_docs()
697        };
698
699        // Wait for all in-flight background merges (including cascading)
700        // before starting forced merges to avoid try_register conflicts.
701        self.wait_for_all_merges().await;
702
703        loop {
704            // Get segment IDs with their doc counts, sorted ascending by size
705            let mut segments: Vec<(String, u32)> = {
706                let st = self.state.lock().await;
707                st.metadata
708                    .segment_metas
709                    .iter()
710                    .map(|(id, info)| (id.clone(), info.num_docs))
711                    .collect()
712            };
713
714            if segments.len() < 2 {
715                return Ok(());
716            }
717
718            segments.sort_by_key(|(_, docs)| *docs);
719
720            // Build a batch respecting max_segment_docs
721            let max_docs = max_segment_docs.map(|m| m as u64).unwrap_or(u64::MAX);
722            let mut batch = Vec::new();
723            let mut batch_docs = 0u64;
724
725            for (id, docs) in &segments {
726                if batch.len() >= FORCE_MERGE_BATCH {
727                    break;
728                }
729                let next_total = batch_docs + *docs as u64;
730                if next_total > max_docs && !batch.is_empty() {
731                    break;
732                }
733                batch.push(id.clone());
734                batch_docs += *docs as u64;
735            }
736
737            if batch.len() < 2 {
738                return Ok(());
739            }
740
741            log::info!(
742                "[force_merge] merging batch of {} segments ({} docs)",
743                batch.len(),
744                batch_docs
745            );
746
747            let output_id = SegmentId::new();
748            let output_hex = output_id.to_hex();
749
750            // Register batch + output in inventory so maybe_merge skips them.
751            let mut all_ids = batch.clone();
752            all_ids.push(output_hex);
753            let _guard = match self.merge_inventory.try_register(all_ids) {
754                Some(g) => g,
755                None => {
756                    // A background merge slipped in — wait for it, then retry the loop
757                    self.wait_for_merging_thread().await;
758                    continue;
759                }
760            };
761
762            let trained_snap = self.trained();
763            let granularity = self.merge_granularity(&batch).await;
764            let (new_segment_id, total_docs) = Self::do_merge(
765                self.directory.as_ref(),
766                &self.schema,
767                &batch,
768                output_id,
769                self.term_cache_blocks,
770                trained_snap.as_deref(),
771                self.reorder_on_merge,
772                granularity,
773                Some(self.background_cpu_pool()),
774            )
775            .await?;
776
777            self.replace_segments(
778                &batch,
779                new_segment_id,
780                total_docs,
781                self.reorder_on_merge,
782                true,
783            )
784            .await?;
785
786            // _guard drops here → segments unregistered from inventory
787        }
788    }
789
790    /// Reorder all segments via Recursive Graph Bisection (BP) for better BMP pruning.
791    ///
792    /// Each segment is individually rebuilt with reordered BMP blocks.
793    /// Non-BMP fields are copied unchanged via streaming file copy.
794    ///
795    /// Uses `MergeInventory` to prevent concurrent operations on the same segment.
796    pub async fn reorder_segments(self: &Arc<Self>) -> Result<()> {
797        self.wait_for_all_merges().await;
798        let segment_ids = self.get_segment_ids().await;
799
800        if segment_ids.is_empty() {
801            log::info!("[reorder] no segments to reorder");
802            return Ok(());
803        }
804
805        log::info!("[reorder] reordering {} segments", segment_ids.len());
806
807        for seg_id in segment_ids {
808            match self
809                .reorder_single_segment(&seg_id, None, crate::segment::BpBudget::full())
810                .await
811            {
812                Ok(true) => {}
813                Ok(false) => log::warn!("[reorder] segment {} skipped (in merge)", seg_id),
814                Err(e) => return Err(e),
815            }
816        }
817
818        log::info!("[reorder] all segments reordered");
819        Ok(())
820    }
821
822    /// Get segment IDs that have not been reordered yet.
823    ///
824    /// Excludes segments currently involved in a merge or reorder operation
825    /// to avoid wasted work (the optimizer would skip them anyway).
826    pub async fn unreordered_segment_ids(&self) -> Vec<String> {
827        let st = self.state.lock().await;
828        let in_merge = self.merge_inventory.snapshot();
829        st.metadata
830            .segment_metas
831            .iter()
832            .filter(|(id, info)| !info.reordered && !in_merge.contains(*id))
833            .map(|(id, _)| id.clone())
834            .collect()
835    }
836
837    /// Segments never reordered, with doc counts — for the optimizer to pick
838    /// a size-appropriate BP budget.
839    pub async fn unreordered_segments(&self) -> Vec<(String, u32)> {
840        let st = self.state.lock().await;
841        let in_merge = self.merge_inventory.snapshot();
842        st.metadata
843            .segment_metas
844            .iter()
845            .filter(|(id, info)| !info.reordered && !in_merge.contains(*id))
846            .map(|(id, info)| (id.clone(), info.num_docs))
847            .collect()
848    }
849
850    /// Segments whose last BP pass hit its wall-clock budget before finishing
851    /// (`bp_converged == false`). A warm-started follow-up pass deepens the
852    /// ordering; the optimizer revisits these at low priority.
853    pub async fn unconverged_segments(&self) -> Vec<(String, u32)> {
854        let st = self.state.lock().await;
855        let in_merge = self.merge_inventory.snapshot();
856        st.metadata
857            .segment_metas
858            .iter()
859            .filter(|(id, info)| info.reordered && !info.bp_converged && !in_merge.contains(*id))
860            .map(|(id, info)| (id.clone(), info.num_docs))
861            .collect()
862    }
863
864    /// Granularity for a BP pass whose sources are `ids`: `Records` when any
865    /// source is an unconverged partial reorder, `Auto` otherwise.
866    ///
867    /// Alignment with the depth budget (docs/block-level-reorder.md): an
868    /// unconverged segment is owed a deepening pass, and the output of this
869    /// pass will be marked `bp_converged`. `Auto` would measure the partial
870    /// pass's residual coherence, potentially take the blockwise path — which
871    /// cannot deepen record clustering — and end the cascade at partial
872    /// quality. Only record-level BP discharges the debt.
873    async fn merge_granularity(&self, ids: &[String]) -> crate::segment::reorder::BpGranularity {
874        let st = self.state.lock().await;
875        let deepening = ids.iter().any(|id| {
876            st.metadata
877                .segment_metas
878                .get(id)
879                .is_some_and(|info| info.reordered && !info.bp_converged)
880        });
881        drop(st);
882        if deepening {
883            log::info!(
884                "[reorder] source segment(s) unconverged — forcing record-level BP (deepening pass)",
885            );
886            crate::segment::reorder::BpGranularity::Records
887        } else {
888            crate::segment::reorder::BpGranularity::Auto
889        }
890    }
891
892    /// Reorder a single segment via BP. Returns Ok(true) if reordered, Ok(false) if skipped.
893    ///
894    /// Non-blocking: uses merge inventory to prevent conflicts with background merges.
895    /// Copies unchanged files and rebuilds only the sparse file with reordered BMP data.
896    pub async fn reorder_single_segment(
897        self: &Arc<Self>,
898        seg_id: &str,
899        rayon_pool: Option<Arc<rayon::ThreadPool>>,
900        bp_budget: crate::segment::BpBudget,
901    ) -> Result<bool> {
902        let source_id = SegmentId::from_hex(seg_id)
903            .ok_or_else(|| Error::Corruption(format!("Invalid segment ID: {}", seg_id)))?;
904        let output_id = SegmentId::new();
905        let output_hex = output_id.to_hex();
906        let source_ids = [seg_id.to_string()];
907        let granularity = self.merge_granularity(&source_ids).await;
908
909        let all_ids = vec![seg_id.to_string(), output_hex];
910        let _guard = match self.merge_inventory.try_register(all_ids) {
911            Some(g) => g,
912            None => {
913                log::debug!("[optimizer] segment {} in active merge, skipping", seg_id);
914                return Ok(false);
915            }
916        };
917
918        let (new_id, total_docs, bp_converged) = crate::segment::reorder::reorder_segment(
919            self.directory.as_ref(),
920            &self.schema,
921            source_id,
922            output_id,
923            self.term_cache_blocks,
924            crate::segment::reorder::DEFAULT_MEMORY_BUDGET,
925            bp_budget,
926            granularity,
927            rayon_pool,
928        )
929        .await?;
930
931        self.replace_segments(
932            &[seg_id.to_string()],
933            new_id,
934            total_docs,
935            true,
936            bp_converged,
937        )
938        .await?;
939
940        Ok(true)
941    }
942
943    /// Clean up orphan segment files not registered in metadata.
944    ///
945    /// Non-blocking: reads both metadata and `merge_inventory` to determine which
946    /// segments are legitimate. In-flight merge outputs are protected by the inventory.
947    pub async fn cleanup_orphan_segments(&self) -> Result<usize> {
948        // Read BOTH sets under the same state lock to prevent TOCTOU:
949        // without this, a merge completing between the two reads could make
950        // its output segment invisible to both sets → wrongly deleted.
951        let (registered_set, in_merge_set) = {
952            let st = self.state.lock().await;
953            let registered = st
954                .metadata
955                .segment_metas
956                .keys()
957                .cloned()
958                .collect::<HashSet<String>>();
959            let in_merge = self.merge_inventory.snapshot();
960            (registered, in_merge)
961        };
962
963        let mut orphan_ids: HashSet<String> = HashSet::new();
964
965        if let Ok(entries) = self.directory.list_files(std::path::Path::new("")).await {
966            for entry in entries {
967                let filename = entry.to_string_lossy();
968                if filename.starts_with("seg_") && filename.len() > 37 {
969                    let hex_part = &filename[4..36];
970                    if !registered_set.contains(hex_part) && !in_merge_set.contains(hex_part) {
971                        orphan_ids.insert(hex_part.to_string());
972                    }
973                }
974            }
975        }
976
977        let mut deleted = 0;
978        for hex_id in &orphan_ids {
979            if let Some(segment_id) = SegmentId::from_hex(hex_id)
980                && crate::segment::delete_segment(self.directory.as_ref(), segment_id)
981                    .await
982                    .is_ok()
983            {
984                deleted += 1;
985            }
986        }
987
988        Ok(deleted)
989    }
990}
991
992#[cfg(test)]
993mod tests {
994    use super::*;
995
996    #[test]
997    fn test_inventory_guard_drop_unregisters() {
998        let inv = Arc::new(MergeInventory::new());
999        {
1000            let _guard = inv.try_register(vec!["a".into(), "b".into()]).unwrap();
1001            let snap = inv.snapshot();
1002            assert!(snap.contains("a"));
1003            assert!(snap.contains("b"));
1004        }
1005        // Guard dropped → segments unregistered
1006        assert!(inv.snapshot().is_empty());
1007    }
1008
1009    #[test]
1010    fn test_inventory_concurrent_non_overlapping_merges() {
1011        let inv = Arc::new(MergeInventory::new());
1012        let _g1 = inv.try_register(vec!["a".into(), "b".into()]).unwrap();
1013        // Non-overlapping merge succeeds concurrently
1014        let _g2 = inv.try_register(vec!["c".into(), "d".into()]).unwrap();
1015        let snap = inv.snapshot();
1016        assert_eq!(snap.len(), 4);
1017
1018        // Drop first guard — only its segments are removed
1019        drop(_g1);
1020        let snap = inv.snapshot();
1021        assert_eq!(snap.len(), 2);
1022        assert!(snap.contains("c"));
1023        assert!(snap.contains("d"));
1024    }
1025
1026    #[test]
1027    fn test_inventory_overlapping_merge_rejected() {
1028        let inv = Arc::new(MergeInventory::new());
1029        let _g1 = inv.try_register(vec!["a".into(), "b".into()]).unwrap();
1030        // Overlapping merge rejected (shares "b")
1031        assert!(inv.try_register(vec!["b".into(), "c".into()]).is_none());
1032        // After drop, the overlapping merge succeeds
1033        drop(_g1);
1034        assert!(inv.try_register(vec!["b".into(), "c".into()]).is_some());
1035    }
1036
1037    #[test]
1038    fn test_inventory_snapshot() {
1039        let inv = Arc::new(MergeInventory::new());
1040        let _g = inv.try_register(vec!["x".into(), "y".into()]).unwrap();
1041        let snap = inv.snapshot();
1042        assert!(snap.contains("x"));
1043        assert!(snap.contains("y"));
1044        assert!(!snap.contains("z"));
1045    }
1046}