Skip to main content

hermes_core/index/
mod.rs

1//! Index - multi-segment async search index
2//!
3//! The `Index` is the central concept that provides:
4//! - `Index::create()` / `Index::open()` - create or open an index
5//! - `index.writer()` - get an IndexWriter for adding documents
6//! - `index.reader()` - get an IndexReader for searching (with reload policy)
7//!
8//! The Index owns the SegmentManager which handles segment lifecycle and tracking.
9
10#[cfg(feature = "native")]
11use crate::dsl::Schema;
12#[cfg(feature = "native")]
13use crate::error::Result;
14#[cfg(feature = "sync")]
15use std::collections::HashMap;
16#[cfg(feature = "native")]
17use std::sync::Arc;
18#[cfg(feature = "native")]
19use std::sync::{OnceLock, Weak};
20
21mod searcher;
22pub use searcher::Searcher;
23
24#[cfg(feature = "native")]
25mod primary_key;
26#[cfg(feature = "native")]
27mod reader;
28#[cfg(feature = "native")]
29mod vector_builder;
30#[cfg(all(feature = "wasm", not(feature = "native")))]
31mod wasm_writer;
32#[cfg(feature = "native")]
33mod writer;
34#[cfg(feature = "native")]
35pub use primary_key::PrimaryKeyIndex;
36#[cfg(feature = "native")]
37pub use reader::IndexReader;
38#[cfg(feature = "native")]
39pub use vector_builder::{AlterVectorIndexOutcome, AlterVectorIndexState};
40#[cfg(all(feature = "wasm", not(feature = "native")))]
41pub use wasm_writer::IndexWriter as WasmIndexWriter;
42#[cfg(feature = "native")]
43pub use writer::{IndexWriter, PreparedCommit, WRITER_LOCK_FILENAME};
44
45mod metadata;
46pub use metadata::{
47    FieldVectorMeta, INDEX_META_FILENAME, IndexMetadata, SegmentMetaInfo, VectorIndexState,
48};
49
50#[cfg(feature = "native")]
51mod helpers;
52#[cfg(feature = "native")]
53pub use helpers::{
54    IndexingStats, SchemaConfig, SchemaFieldConfig, create_index_at_path, create_index_from_sdl,
55    index_documents_from_reader, index_json_document, parse_schema,
56};
57
58/// Default file name for the slice cache
59pub const SLICE_CACHE_FILENAME: &str = "index.slicecache";
60
61/// A BP pass can consume every background CPU worker and the complete
62/// per-pass memory allowance. More than two simultaneous passes only
63/// oversubscribe the same pool and multiply memory-bandwidth pressure.
64#[cfg(feature = "native")]
65pub const MAX_CONCURRENT_REORDER_PASSES: usize = 2;
66
67#[cfg(feature = "native")]
68#[derive(Clone, Copy, Debug, Eq, PartialEq)]
69pub(crate) enum ReorderPriority {
70    /// Periodic optimizer and explicit standalone reorder work. This class
71    /// must retain capacity while automatic merges continuously arrive,
72    /// otherwise fresh segments stay unordered for minutes behind giant BP
73    /// passes and query pruning recovers slowly after ingestion.
74    Optimizer,
75    /// BP performed while producing an automatic merge output.
76    AutomaticMerge,
77    Foreground,
78}
79
80/// Application-wide gate shared by optimizer, merge-time, and manual BP.
81///
82/// Besides enforcing the hard two-pass ceiling, the gate lets an explicit
83/// force merge reserve all but one slot. Background passes already running
84/// finish normally; new ones wait until the force merge releases its guard.
85#[cfg(feature = "native")]
86#[derive(Debug)]
87pub struct ReorderConcurrencyGate {
88    permits: Arc<tokio::sync::Semaphore>,
89    /// Standalone optimizer passes rewrite complete sparse blobs and can each
90    /// consume the full per-pass memory budget. Serializing this class avoids
91    /// multiplying disk traffic and retained source/output pages when a scan
92    /// discovers candidates in multiple indexes at once.
93    optimizer_permits: Arc<tokio::sync::Semaphore>,
94    /// Automatic merges may consume all but one whole-pass slot. The reserved
95    /// slot lets short optimizer passes continuously retire fresh segments.
96    /// With a one-pass configuration both classes share the only slot.
97    automatic_merge_permits: Arc<tokio::sync::Semaphore>,
98    limit: usize,
99    foreground_lock: Arc<tokio::sync::Mutex<()>>,
100    foreground_active: std::sync::atomic::AtomicBool,
101    foreground_finished: tokio::sync::Notify,
102}
103
104/// Process-wide cap on simultaneously active BMP segment scorers.
105///
106/// Each scorer performs random mmap reads. Letting every segment of every
107/// concurrent query run at once multiplies page faults without increasing
108/// useful NVMe throughput, so this gate is independent from the CPU pool.
109#[cfg(feature = "native")]
110#[derive(Debug)]
111pub(crate) struct BmpIoGate {
112    limit: usize,
113    active: parking_lot::Mutex<usize>,
114    available: parking_lot::Condvar,
115    async_available: tokio::sync::Notify,
116}
117
118#[cfg(feature = "native")]
119impl BmpIoGate {
120    fn new(limit: usize) -> Self {
121        Self {
122            limit,
123            active: parking_lot::Mutex::new(0),
124            available: parking_lot::Condvar::new(),
125            async_available: tokio::sync::Notify::new(),
126        }
127    }
128
129    #[cfg(feature = "sync")]
130    fn acquire(&self) -> BmpIoPermit<'_> {
131        let mut active = self.active.lock();
132        while *active >= self.limit {
133            self.available.wait(&mut active);
134        }
135        *active += 1;
136        BmpIoPermit { gate: self }
137    }
138
139    async fn acquire_async(&self) -> BmpIoPermit<'_> {
140        loop {
141            // Register before checking the counter, so a release between the
142            // check and await cannot be lost.
143            let notified = self.async_available.notified();
144            {
145                let mut active = self.active.lock();
146                if *active < self.limit {
147                    *active += 1;
148                    return BmpIoPermit { gate: self };
149                }
150            }
151            notified.await;
152        }
153    }
154}
155
156#[cfg(feature = "native")]
157struct BmpIoPermit<'a> {
158    gate: &'a BmpIoGate,
159}
160
161#[cfg(feature = "native")]
162impl Drop for BmpIoPermit<'_> {
163    fn drop(&mut self) {
164        let mut active = self.gate.active.lock();
165        *active -= 1;
166        self.gate.available.notify_one();
167        self.gate.async_available.notify_one();
168    }
169}
170
171#[cfg(feature = "native")]
172impl ReorderConcurrencyGate {
173    pub fn new(requested_limit: usize) -> Self {
174        let limit = requested_limit.clamp(1, MAX_CONCURRENT_REORDER_PASSES);
175        let automatic_merge_limit = limit.saturating_sub(1).max(1);
176        Self {
177            permits: Arc::new(tokio::sync::Semaphore::new(limit)),
178            optimizer_permits: Arc::new(tokio::sync::Semaphore::new(1)),
179            automatic_merge_permits: Arc::new(tokio::sync::Semaphore::new(automatic_merge_limit)),
180            limit,
181            foreground_lock: Arc::new(tokio::sync::Mutex::new(())),
182            foreground_active: std::sync::atomic::AtomicBool::new(false),
183            foreground_finished: tokio::sync::Notify::new(),
184        }
185    }
186
187    pub fn limit(&self) -> usize {
188        self.limit
189    }
190
191    pub(crate) async fn acquire(
192        self: &Arc<Self>,
193        priority: ReorderPriority,
194    ) -> std::result::Result<ReorderPermit, tokio::sync::AcquireError> {
195        match priority {
196            ReorderPriority::Optimizer => {
197                let optimizer_permit = Arc::clone(&self.optimizer_permits).acquire_owned().await?;
198                self.acquire_background(Some(optimizer_permit), None).await
199            }
200            ReorderPriority::AutomaticMerge => {
201                let merge_permit = Arc::clone(&self.automatic_merge_permits)
202                    .acquire_owned()
203                    .await?;
204                self.acquire_background(None, Some(merge_permit)).await
205            }
206            ReorderPriority::Foreground => self.acquire_foreground().await,
207        }
208    }
209
210    /// Acquire capacity for periodic optimizer or automatic merge work.
211    async fn acquire_background(
212        self: &Arc<Self>,
213        optimizer: Option<tokio::sync::OwnedSemaphorePermit>,
214        automatic_merge: Option<tokio::sync::OwnedSemaphorePermit>,
215    ) -> std::result::Result<ReorderPermit, tokio::sync::AcquireError> {
216        loop {
217            if self
218                .foreground_active
219                .load(std::sync::atomic::Ordering::Acquire)
220            {
221                let notified = self.foreground_finished.notified();
222                if self
223                    .foreground_active
224                    .load(std::sync::atomic::Ordering::Acquire)
225                {
226                    notified.await;
227                    continue;
228                }
229            }
230
231            let permit = Arc::clone(&self.permits).acquire_owned().await?;
232            if !self
233                .foreground_active
234                .load(std::sync::atomic::Ordering::Acquire)
235            {
236                return Ok(ReorderPermit {
237                    _permit: permit,
238                    _optimizer: optimizer,
239                    _automatic_merge: automatic_merge,
240                });
241            }
242            // A foreground operation started between the check and permit
243            // acquisition. Yield the slot instead of extending its queue.
244            drop(permit);
245        }
246    }
247
248    /// Acquire the one BP slot left available to a foreground force merge.
249    async fn acquire_foreground(
250        self: &Arc<Self>,
251    ) -> std::result::Result<ReorderPermit, tokio::sync::AcquireError> {
252        let permit = Arc::clone(&self.permits).acquire_owned().await?;
253        Ok(ReorderPermit {
254            _permit: permit,
255            _optimizer: None,
256            _automatic_merge: None,
257        })
258    }
259
260    /// Prioritize one explicit force merge across all indexes using this gate.
261    ///
262    /// Foreground operations are serialized to avoid two force merges each
263    /// reserving one slot and then waiting for the other. The guard is
264    /// cancellation-safe and releases reservations on drop.
265    pub(crate) async fn begin_foreground(
266        self: &Arc<Self>,
267    ) -> std::result::Result<ForegroundReorderGuard, tokio::sync::AcquireError> {
268        let exclusive = Arc::clone(&self.foreground_lock).lock_owned().await;
269        self.foreground_active
270            .store(true, std::sync::atomic::Ordering::Release);
271
272        // Construct the guard before awaiting capacity. If this future is
273        // cancelled while existing background work drains, Drop clears the
274        // active flag and releases the foreground mutex.
275        let mut guard = ForegroundReorderGuard {
276            gate: Arc::clone(self),
277            reserved: None,
278            _exclusive: exclusive,
279        };
280        if self.limit > 1 {
281            guard.reserved = Some(
282                Arc::clone(&self.permits)
283                    .acquire_many_owned((self.limit - 1) as u32)
284                    .await?,
285            );
286        }
287        Ok(guard)
288    }
289}
290
291#[cfg(feature = "native")]
292pub(crate) struct ReorderPermit {
293    _permit: tokio::sync::OwnedSemaphorePermit,
294    _optimizer: Option<tokio::sync::OwnedSemaphorePermit>,
295    _automatic_merge: Option<tokio::sync::OwnedSemaphorePermit>,
296}
297
298#[cfg(feature = "native")]
299pub(crate) struct ForegroundReorderGuard {
300    gate: Arc<ReorderConcurrencyGate>,
301    reserved: Option<tokio::sync::OwnedSemaphorePermit>,
302    _exclusive: tokio::sync::OwnedMutexGuard<()>,
303}
304
305#[cfg(feature = "native")]
306impl Drop for ForegroundReorderGuard {
307    fn drop(&mut self) {
308        // Make capacity visible before waking background waiters.
309        drop(self.reserved.take());
310        self.gate
311            .foreground_active
312            .store(false, std::sync::atomic::Ordering::Release);
313        self.gate.foreground_finished.notify_waiters();
314    }
315}
316
317/// Index configuration
318#[derive(Debug, Clone)]
319pub struct IndexConfig {
320    /// Number of threads shared by CPU-intensive search work.
321    ///
322    /// Indexes in the same process that request the same width reuse one Rayon
323    /// pool. A value of zero is invalid and is rejected by `Index::create` and
324    /// `Index::open`.
325    pub num_threads: usize,
326    /// Maximum BMP segment scorers issuing random mmap reads concurrently
327    /// across the process. CPU parallelism remains controlled by
328    /// `num_threads`; this separate cap protects the page cache and storage
329    /// queue from segment/query fan-out.
330    pub bmp_io_concurrency: usize,
331    /// Number of parallel segment builders (documents distributed round-robin)
332    pub num_indexing_threads: usize,
333    /// Width of the document-store compression pool. Concurrent segment
334    /// builders requesting the same width share one process-wide pool, so
335    /// indexing-worker fan-out does not multiply this thread count.
336    pub num_compression_threads: usize,
337    /// Block cache size for term dictionary per segment
338    pub term_cache_blocks: usize,
339    /// Process-wide byte budget for decompressed document-store blocks.
340    ///
341    /// Indexes opened with the same budget share one read-concurrent,
342    /// byte-bounded cache. This is a byte limit rather than a block count
343    /// because a stored document can legitimately make one decompressed block
344    /// tens of MiB.
345    pub store_cache_budget_bytes: usize,
346    /// Max memory (bytes) across all builders before auto-commit (global limit)
347    pub max_indexing_memory_bytes: usize,
348    /// Maximum vectors retained for one field's global ANN training sample.
349    /// The byte budget below is applied at the same time; the smaller bound
350    /// wins. Fields are sampled and trained serially.
351    pub vector_training_max_samples: usize,
352    /// Maximum raw vector bytes retained for one field's ANN training sample.
353    pub vector_training_memory_bytes: usize,
354    /// Merge policy for background segment merging
355    pub merge_policy: Box<dyn crate::merge::MergePolicy>,
356    /// Index optimization mode (adaptive, size-optimized, performance-optimized).
357    /// Selects the term-dictionary compression level and, unless
358    /// `posting_codec` overrides it, the posting block codec
359    /// (`docs/posting-codecs.md`).
360    pub optimization: crate::structures::IndexOptimization,
361    /// Explicit posting block codec; `None` derives it from `optimization`
362    /// (`size` → `Pfor`, everything else → `Rounded`).
363    pub posting_codec: Option<crate::structures::PostingCodec>,
364    /// Reload interval in milliseconds for IndexReader (how often to check for new segments)
365    pub reload_interval_ms: u64,
366    /// Maximum number of concurrent background merges per index (default: 4)
367    pub max_concurrent_merges: usize,
368    /// Application-wide background merge gate shared by clones of this
369    /// config. The per-index limit alone multiplied large merge working sets
370    /// by the number of active indexes.
371    #[cfg(feature = "native")]
372    pub background_merge_permits: Arc<tokio::sync::Semaphore>,
373    /// Wall-clock budget for merge-time BP reorder per field (only applies
374    /// when the index has `reorder_on_merge`). A truncated pass still writes
375    /// a valid, better-ordered segment; it is marked `bp_converged = false`
376    /// and the background optimizer deepens it later (warm-started).
377    /// `None` = unbudgeted (BP runs to full depth inside the merge, which can
378    /// hold a merge slot for 10-30+ minutes on 10M+ doc outputs).
379    pub merge_bp_time_budget: Option<std::time::Duration>,
380    /// Memory budget (bytes) for the BP forward index during reorder passes
381    /// (merge-time and background). When a large segment's forward index
382    /// would exceed this, the highest-df dims are dropped from BP's input
383    /// (logged loudly) — clustering quality degrades gracefully. Production
384    /// evidence: 18M-doc merges exceeded the former 2 GB default and dropped
385    /// ~10% of eligible dims; hosts with less headroom may lower this.
386    pub bp_memory_budget_bytes: usize,
387    /// Hard limit on simultaneous whole-segment BP rewrites. This is shared
388    /// by all indexes opened from clones of this config and applies to
389    /// optimizer, merge-time, and manual reorder passes. It is deliberately
390    /// separate from the Rayon pool width: one pass can already use every
391    /// background CPU thread and consume the full BP memory budget.
392    #[cfg(feature = "native")]
393    pub background_reorder_permits: Arc<ReorderConcurrencyGate>,
394    /// Optional process/application-owned Rayon pool for BP work. Supplying
395    /// one lets every index and the optimizer share the same worker threads;
396    /// `None` lazily uses one process-wide cores/2 fallback pool.
397    #[cfg(feature = "native")]
398    pub background_reorder_pool: Option<Arc<rayon::ThreadPool>>,
399}
400
401/// Search pools are shared process-wide by width. This avoids multiplying OS
402/// threads by the number of open indexes while still allowing applications to
403/// deliberately isolate indexes that need different CPU budgets.
404#[cfg(feature = "sync")]
405static SEARCH_CPU_POOLS: OnceLock<parking_lot::Mutex<HashMap<usize, Weak<rayon::ThreadPool>>>> =
406    OnceLock::new();
407
408/// Store caches are shared process-wide by configured byte budget, just like
409/// search CPU pools are shared by width. `IndexRegistry` clones one config for
410/// every index, but standalone callers with the same policy also converge on
411/// the same bounded cache.
412#[cfg(feature = "native")]
413static STORE_CACHE_POOLS: OnceLock<
414    parking_lot::Mutex<std::collections::HashMap<usize, Weak<crate::segment::SharedStoreCache>>>,
415> = OnceLock::new();
416
417#[cfg(feature = "native")]
418static BMP_IO_GATES: OnceLock<
419    parking_lot::Mutex<std::collections::HashMap<usize, Weak<BmpIoGate>>>,
420> = OnceLock::new();
421
422#[cfg(feature = "native")]
423pub(crate) fn shared_bmp_io_gate(limit: usize) -> Arc<BmpIoGate> {
424    let mut gates = BMP_IO_GATES
425        .get_or_init(|| parking_lot::Mutex::new(std::collections::HashMap::new()))
426        .lock();
427    if let Some(gate) = gates.get(&limit).and_then(Weak::upgrade) {
428        return gate;
429    }
430    let gate = Arc::new(BmpIoGate::new(limit));
431    gates.retain(|_, gate| gate.strong_count() > 0);
432    gates.insert(limit, Arc::downgrade(&gate));
433    log::info!("[bmp] process-wide random-I/O concurrency={limit}");
434    gate
435}
436
437#[cfg(feature = "native")]
438pub(crate) fn shared_store_cache(budget_bytes: usize) -> Arc<crate::segment::SharedStoreCache> {
439    let mut caches = STORE_CACHE_POOLS
440        .get_or_init(|| parking_lot::Mutex::new(std::collections::HashMap::new()))
441        .lock();
442    if let Some(cache) = caches.get(&budget_bytes).and_then(Weak::upgrade) {
443        return cache;
444    }
445    let cache = Arc::new(crate::segment::SharedStoreCache::new(budget_bytes));
446    caches.retain(|_, cache| cache.strong_count() > 0);
447    caches.insert(budget_bytes, Arc::downgrade(&cache));
448    log::info!(
449        "[store_cache] process-wide budget={}",
450        crate::format_bytes(budget_bytes as u64)
451    );
452    cache
453}
454
455#[cfg(feature = "sync")]
456fn shared_search_pool(num_threads: usize) -> Result<Arc<rayon::ThreadPool>> {
457    if num_threads == 0 {
458        return Err(crate::Error::Internal(
459            "IndexConfig.num_threads must be greater than zero".into(),
460        ));
461    }
462
463    let mut pools = SEARCH_CPU_POOLS
464        .get_or_init(|| parking_lot::Mutex::new(HashMap::new()))
465        .lock();
466    if let Some(pool) = pools.get(&num_threads).and_then(Weak::upgrade) {
467        return Ok(pool);
468    }
469
470    // Build while holding the registry lock. Index construction is cold-path
471    // work, and serialization here prevents two concurrent opens from creating
472    // duplicate pools for the same width.
473    let pool = Arc::new(
474        rayon::ThreadPoolBuilder::new()
475            .num_threads(num_threads)
476            .thread_name(move |idx| format!("hermes-search-{}-{}", num_threads, idx))
477            .build()
478            .map_err(|error| {
479                crate::Error::Internal(format!(
480                    "failed to create {num_threads}-thread search pool: {error}"
481                ))
482            })?,
483    );
484    pools.retain(|_, pool| pool.strong_count() > 0);
485    pools.insert(num_threads, Arc::downgrade(&pool));
486    log::info!("[search] process-wide CPU pool: {} thread(s)", num_threads);
487    Ok(pool)
488}
489
490impl Default for IndexConfig {
491    fn default() -> Self {
492        #[cfg(feature = "native")]
493        let compression_threads = crate::default_compression_threads();
494        #[cfg(not(feature = "native"))]
495        let compression_threads = 1;
496
497        #[cfg(feature = "native")]
498        let search_threads = crate::default_search_threads();
499        #[cfg(not(feature = "native"))]
500        let search_threads = 1;
501
502        Self {
503            num_threads: search_threads,
504            bmp_io_concurrency: 4,
505            num_indexing_threads: 1, // Increase to 2+ for production to avoid stalls during segment build
506            num_compression_threads: compression_threads,
507            term_cache_blocks: 256,
508            // Stored bodies can be much larger than the writer's nominal
509            // 16-KiB block target. Keep this process-wide and byte bounded so
510            // segment fan-out cannot multiply it into tens of GiB.
511            #[cfg(target_pointer_width = "64")]
512            store_cache_budget_bytes: 2 * 1024 * 1024 * 1024,
513            #[cfg(not(target_pointer_width = "64"))]
514            store_cache_budget_bytes: 32 * 1024 * 1024,
515            max_indexing_memory_bytes: 256 * 1024 * 1024, // 256 MB default
516            vector_training_max_samples: 10_000_000,
517            #[cfg(target_pointer_width = "64")]
518            vector_training_memory_bytes: 4 * 1024 * 1024 * 1024,
519            #[cfg(not(target_pointer_width = "64"))]
520            vector_training_memory_bytes: usize::MAX,
521            // large_scale: wide fan-in + budget/scored selection. Safe for
522            // small indexes too (tier floors only shape *when* segments
523            // merge); merge-time BP is wall-clock budgeted, so giant merges
524            // cannot hold slots indefinitely.
525            merge_policy: Box::new(crate::merge::TieredMergePolicy::large_scale()),
526            optimization: crate::structures::IndexOptimization::default(),
527            posting_codec: None,
528            reload_interval_ms: 1000, // 1 second default
529            max_concurrent_merges: 4,
530            #[cfg(feature = "native")]
531            background_merge_permits: Arc::new(tokio::sync::Semaphore::new(4)),
532            merge_bp_time_budget: Some(std::time::Duration::from_secs(600)),
533            // 24 GB — mirrors segment::reorder::DEFAULT_MEMORY_BUDGET (that
534            // module is native-only; IndexConfig also compiles for wasm).
535            // A cap, not an allocation: usage is proportional to the segment
536            // being reordered (~4 B/posting + ~32 B/doc). Sized from prod
537            // evidence: a 58M-doc/5B-posting pass estimated 20.1 GB, which
538            // 8/16 GB budgets trimmed by dropping highest-df dims.
539            // 24 GB overflows 32-bit usize (wasm32) — reorder never runs
540            // there, so any large value works; use usize::MAX.
541            #[cfg(target_pointer_width = "64")]
542            bp_memory_budget_bytes: 24 * 1024 * 1024 * 1024,
543            #[cfg(not(target_pointer_width = "64"))]
544            bp_memory_budget_bytes: usize::MAX,
545            #[cfg(feature = "native")]
546            background_reorder_permits: Arc::new(ReorderConcurrencyGate::new(2)),
547            #[cfg(feature = "native")]
548            background_reorder_pool: None,
549        }
550    }
551}
552
553impl IndexConfig {
554    /// Posting block codec new segments and merges are written with.
555    pub fn effective_posting_codec(&self) -> crate::structures::PostingCodec {
556        self.posting_codec
557            .unwrap_or_else(|| self.optimization.default_posting_codec())
558    }
559}
560
561/// Build the segment-lifecycle owner from the corresponding index policy.
562///
563/// `Index` and `IndexWriter` both support create/open entry points. Routing
564/// their shared configuration through this helper prevents a new
565/// `SegmentManager` option from being wired into only some constructors.
566#[cfg(feature = "native")]
567fn segment_manager_from_config<D: crate::directories::DirectoryWriter + 'static>(
568    directory: &Arc<D>,
569    schema: &Arc<Schema>,
570    metadata: IndexMetadata,
571    config: &IndexConfig,
572) -> Arc<crate::merge::SegmentManager<D>> {
573    Arc::new(
574        crate::merge::SegmentManager::new(
575            Arc::clone(directory),
576            Arc::clone(schema),
577            metadata,
578            config.merge_policy.clone_box(),
579            config.term_cache_blocks,
580            config.max_concurrent_merges,
581            Arc::clone(&config.background_merge_permits),
582            config.merge_bp_time_budget,
583            config.bp_memory_budget_bytes,
584            Arc::clone(&config.background_reorder_permits),
585            config.background_reorder_pool.clone(),
586        )
587        .with_posting_config(config.optimization, config.effective_posting_codec()),
588    )
589}
590
591/// Multi-segment async Index
592///
593/// The central concept for search. Owns segment lifecycle and provides:
594/// - `Index::create()` / `Index::open()` - create or open an index
595/// - `index.writer()` - get an IndexWriter for adding documents
596/// - `index.reader()` - get an IndexReader for searching with reload policy
597///
598/// All segment management is delegated to SegmentManager.
599#[cfg(feature = "native")]
600pub struct Index<D: crate::directories::DirectoryWriter + 'static> {
601    directory: Arc<D>,
602    config: IndexConfig,
603    /// Cache and CPU policy used by every searcher reload.
604    search_resources: searcher::SearcherResources,
605    /// Segment manager - owns segments, tracker, metadata, and trained structures
606    segment_manager: Arc<crate::merge::SegmentManager<D>>,
607    /// Cached reader (created lazily, reused across calls)
608    cached_reader: tokio::sync::OnceCell<IndexReader<D>>,
609}
610
611#[cfg(feature = "native")]
612impl<D: crate::directories::DirectoryWriter + 'static> Index<D> {
613    /// Create a new index in the directory
614    pub async fn create(directory: D, schema: Schema, config: IndexConfig) -> Result<Self> {
615        let search_resources = searcher::SearcherResources::new(
616            config.term_cache_blocks,
617            config.store_cache_budget_bytes,
618            config.num_threads,
619            config.bmp_io_concurrency,
620        )?;
621        let directory = Arc::new(directory);
622        let schema = Arc::new(schema);
623        // Directory-layer metrics (cold writes, lazy reads) carry the index label
624        directory.set_index_label(schema.index_label());
625
626        // Refuse to clobber an existing index: persisting a fresh empty
627        // metadata.json would orphan every committed segment, and the next
628        // writer open's orphan sweep would permanently delete them.
629        if directory
630            .exists(std::path::Path::new(INDEX_META_FILENAME))
631            .await?
632        {
633            return Err(crate::Error::Internal(format!(
634                "refusing to create index: {} already exists in this directory; \
635                 use Index::open to open the existing index, or delete the \
636                 directory first if you really want to start over",
637                INDEX_META_FILENAME
638            )));
639        }
640
641        let metadata = IndexMetadata::new((*schema).clone());
642
643        let segment_manager = segment_manager_from_config(&directory, &schema, metadata, &config);
644
645        // Save initial metadata
646        segment_manager.update_metadata(|_| {}).await?;
647
648        Ok(Self {
649            directory,
650            config,
651            search_resources,
652            segment_manager,
653            cached_reader: tokio::sync::OnceCell::new(),
654        })
655    }
656
657    /// Open an existing index from a directory
658    pub async fn open(directory: D, config: IndexConfig) -> Result<Self> {
659        let search_resources = searcher::SearcherResources::new(
660            config.term_cache_blocks,
661            config.store_cache_budget_bytes,
662            config.num_threads,
663            config.bmp_io_concurrency,
664        )?;
665        let directory = Arc::new(directory);
666
667        // Load metadata (includes schema)
668        let metadata = IndexMetadata::load(directory.as_ref()).await?;
669        let schema = Arc::new(metadata.schema.clone());
670        // Directory-layer metrics (cold writes, lazy reads) carry the index label
671        directory.set_index_label(schema.index_label());
672
673        let segment_manager = segment_manager_from_config(&directory, &schema, metadata, &config);
674
675        // Load trained structures into SegmentManager's ArcSwap
676        segment_manager.try_load_and_publish_trained().await?;
677
678        Ok(Self {
679            directory,
680            config,
681            search_resources,
682            segment_manager,
683            cached_reader: tokio::sync::OnceCell::new(),
684        })
685    }
686
687    /// Get the schema
688    pub fn schema(&self) -> Arc<Schema> {
689        self.schema_arc()
690    }
691
692    /// Clone the schema handle from the currently published generation.
693    pub fn schema_arc(&self) -> Arc<Schema> {
694        self.segment_manager.published_generation().schema.clone()
695    }
696
697    /// Get a reference to the underlying directory
698    pub fn directory(&self) -> &D {
699        &self.directory
700    }
701
702    /// Get the segment manager
703    pub fn segment_manager(&self) -> &Arc<crate::merge::SegmentManager<D>> {
704        &self.segment_manager
705    }
706
707    /// Get an IndexReader for searching (with reload policy)
708    ///
709    /// The reader is cached and reused across calls. The reader's internal
710    /// searcher will reload segments based on its reload interval (configurable via IndexConfig).
711    pub async fn reader(&self) -> Result<&IndexReader<D>> {
712        self.cached_reader
713            .get_or_try_init(|| async {
714                IndexReader::from_segment_manager_with_resources(
715                    self.schema_arc(),
716                    Arc::clone(&self.segment_manager),
717                    self.config.reload_interval_ms,
718                    self.search_resources.clone(),
719                )
720                .await
721            })
722            .await
723    }
724
725    /// Get the config
726    pub fn config(&self) -> &IndexConfig {
727        &self.config
728    }
729
730    /// Get segment readers for query execution (convenience method)
731    pub async fn segment_readers(&self) -> Result<Vec<Arc<crate::segment::SegmentReader>>> {
732        let reader = self.reader().await?;
733        let searcher = reader.searcher().await?;
734        Ok(searcher.segment_readers().to_vec())
735    }
736
737    /// Total number of documents across all segments
738    pub async fn num_docs(&self) -> Result<u32> {
739        let reader = self.reader().await?;
740        let searcher = reader.searcher().await?;
741        Ok(searcher.num_docs())
742    }
743
744    /// Get default fields for search
745    pub fn default_fields(&self) -> Vec<crate::Field> {
746        let schema = self.schema_arc();
747        if !schema.default_fields().is_empty() {
748            schema.default_fields().to_vec()
749        } else {
750            schema
751                .fields()
752                .filter(|(_, entry)| {
753                    entry.indexed && entry.field_type == crate::dsl::FieldType::Text
754                })
755                .map(|(field, _)| field)
756                .collect()
757        }
758    }
759
760    /// Get tokenizer registry
761    pub fn tokenizers(&self) -> Arc<crate::tokenizer::TokenizerRegistry> {
762        Arc::new(crate::tokenizer::TokenizerRegistry::default())
763    }
764
765    /// Create a query parser for this index
766    pub fn query_parser(&self) -> crate::dsl::QueryLanguageParser {
767        let default_fields = self.default_fields();
768        let tokenizers = self.tokenizers();
769        let schema = self.schema_arc();
770
771        let query_routers = schema.query_routers();
772        if !query_routers.is_empty()
773            && let Ok(router) = crate::dsl::QueryFieldRouter::from_rules(query_routers)
774        {
775            return crate::dsl::QueryLanguageParser::with_router(
776                Arc::clone(&schema),
777                default_fields,
778                tokenizers,
779                router,
780            );
781        }
782
783        crate::dsl::QueryLanguageParser::new(schema, default_fields, tokenizers)
784    }
785
786    /// Parse and search using a query string
787    pub async fn query(
788        &self,
789        query_str: &str,
790        limit: usize,
791    ) -> Result<crate::query::SearchResponse> {
792        self.query_offset(query_str, limit, 0).await
793    }
794
795    /// Query with offset for pagination
796    pub async fn query_offset(
797        &self,
798        query_str: &str,
799        limit: usize,
800        offset: usize,
801    ) -> Result<crate::query::SearchResponse> {
802        let parser = self.query_parser();
803        let query = parser
804            .parse(query_str)
805            .map_err(crate::error::Error::Query)?;
806        self.search_offset(query.as_ref(), limit, offset).await
807    }
808
809    /// Search and return results
810    pub async fn search(
811        &self,
812        query: &dyn crate::query::Query,
813        limit: usize,
814    ) -> Result<crate::query::SearchResponse> {
815        self.search_offset(query, limit, 0).await
816    }
817
818    /// Search with offset for pagination
819    pub async fn search_offset(
820        &self,
821        query: &dyn crate::query::Query,
822        limit: usize,
823        offset: usize,
824    ) -> Result<crate::query::SearchResponse> {
825        let reader = self.reader().await?;
826        let searcher = reader.searcher().await?;
827
828        #[cfg(feature = "sync")]
829        let (results, total_seen) = {
830            // Sync search: rayon handles segment parallelism internally.
831            // On multi-threaded tokio, use block_in_place to yield the worker;
832            // on single-threaded (tests), call directly.
833            let runtime_flavor = tokio::runtime::Handle::current().runtime_flavor();
834            if runtime_flavor == tokio::runtime::RuntimeFlavor::MultiThread {
835                tokio::task::block_in_place(|| {
836                    searcher.search_with_offset_and_count_sync(query, limit, offset)
837                })?
838            } else {
839                searcher.search_with_offset_and_count_sync(query, limit, offset)?
840            }
841        };
842
843        #[cfg(not(feature = "sync"))]
844        let (results, total_seen) = {
845            searcher
846                .search_with_offset_and_count(query, limit, offset)
847                .await?
848        };
849
850        let total_hits = total_seen;
851        let hits: Vec<crate::query::SearchHit> = results
852            .into_iter()
853            .map(|result| crate::query::SearchHit {
854                address: crate::query::DocAddress::new(result.segment_id, result.doc_id),
855                score: result.score,
856                matched_fields: result.extract_ordinals(),
857            })
858            .collect();
859
860        Ok(crate::query::SearchResponse { hits, total_hits })
861    }
862
863    /// Get a document by its unique address
864    pub async fn get_document(
865        &self,
866        address: &crate::query::DocAddress,
867    ) -> Result<Option<crate::dsl::Document>> {
868        let reader = self.reader().await?;
869        let searcher = reader.searcher().await?;
870        searcher.get_document(address).await
871    }
872
873    /// Get posting lists for a term across all segments
874    pub async fn get_postings(
875        &self,
876        field: crate::Field,
877        term: &[u8],
878    ) -> Result<
879        Vec<(
880            Arc<crate::segment::SegmentReader>,
881            crate::structures::BlockPostingList,
882        )>,
883    > {
884        let segments = self.segment_readers().await?;
885        let mut results = Vec::new();
886
887        for segment in segments {
888            if let Some(postings) = segment.get_postings(field, term).await? {
889                results.push((segment, postings));
890            }
891        }
892
893        Ok(results)
894    }
895}
896
897/// Native-only methods for Index
898#[cfg(feature = "native")]
899impl<D: crate::directories::DirectoryWriter + 'static> Index<D> {
900    /// Get an IndexWriter for adding documents
901    pub fn writer(&self) -> writer::IndexWriter<D> {
902        writer::IndexWriter::from_index(self)
903    }
904}
905
906#[cfg(test)]
907mod tests;
908
909// (tests moved to index/tests/ module)