Skip to main content

summa_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(any(feature = "native", feature = "wasm"))]
25mod content_hash;
26#[cfg(any(feature = "native", feature = "wasm"))]
27mod primary_key;
28#[cfg(feature = "native")]
29mod reader;
30#[cfg(any(feature = "native", feature = "wasm"))]
31pub(crate) mod staged_row;
32#[cfg(feature = "native")]
33mod vector_builder;
34#[cfg(all(feature = "wasm", not(feature = "native")))]
35mod wasm_writer;
36#[cfg(feature = "native")]
37mod writer;
38#[cfg(any(feature = "native", feature = "wasm"))]
39pub use primary_key::PrimaryKeyIndex;
40#[cfg(feature = "native")]
41pub use reader::IndexReader;
42#[cfg(feature = "native")]
43pub use vector_builder::{AlterVectorIndexOutcome, AlterVectorIndexState};
44#[cfg(all(feature = "wasm", not(feature = "native")))]
45pub use wasm_writer::IndexWriter as WasmIndexWriter;
46#[cfg(feature = "native")]
47pub use writer::{IndexWriter, PreparedCommit, WRITER_LOCK_FILENAME};
48
49mod metadata;
50pub use metadata::{
51    FieldVectorMeta, INDEX_META_FILENAME, IndexMetadata, SegmentMetaInfo, VectorIndexState,
52};
53
54#[cfg(feature = "native")]
55mod helpers;
56#[cfg(feature = "native")]
57pub use helpers::{
58    IndexingStats, SchemaConfig, SchemaFieldConfig, create_index_at_path, create_index_from_sdl,
59    index_documents_from_reader, index_json_document, parse_schema,
60};
61
62/// Default file name for the slice cache
63pub const SLICE_CACHE_FILENAME: &str = "index.slicecache";
64
65/// A BP pass can consume every background CPU worker and the complete
66/// per-pass memory allowance. More than two simultaneous passes only
67/// oversubscribe the same pool and multiply memory-bandwidth pressure.
68#[cfg(feature = "native")]
69pub const MAX_CONCURRENT_REORDER_PASSES: usize = 2;
70
71#[cfg(feature = "native")]
72#[derive(Clone, Copy, Debug, Eq, PartialEq)]
73pub(crate) enum ReorderPriority {
74    /// Periodic optimizer and explicit standalone reorder work. This class
75    /// must retain capacity while automatic merges continuously arrive,
76    /// otherwise fresh segments stay unordered for minutes behind giant BP
77    /// passes and query pruning recovers slowly after ingestion.
78    Optimizer,
79    /// BP performed while producing an automatic merge output.
80    AutomaticMerge,
81    Foreground,
82}
83
84/// Application-wide gate shared by optimizer, merge-time, and manual BP.
85///
86/// Besides enforcing the hard two-pass ceiling, the gate lets an explicit
87/// force merge reserve all but one slot. Background passes already running
88/// finish normally; new ones wait until the force merge releases its guard.
89#[cfg(feature = "native")]
90#[derive(Debug)]
91pub struct ReorderConcurrencyGate {
92    permits: Arc<tokio::sync::Semaphore>,
93    /// Standalone optimizer passes rewrite complete sparse blobs and can each
94    /// consume the full per-pass memory budget. Serializing this class avoids
95    /// multiplying disk traffic and retained source/output pages when a scan
96    /// discovers candidates in multiple indexes at once.
97    optimizer_permits: Arc<tokio::sync::Semaphore>,
98    /// Automatic merges may consume all but one whole-pass slot. The reserved
99    /// slot lets short optimizer passes continuously retire fresh segments.
100    /// With a one-pass configuration both classes share the only slot.
101    automatic_merge_permits: Arc<tokio::sync::Semaphore>,
102    limit: usize,
103    foreground_lock: Arc<tokio::sync::Mutex<()>>,
104    foreground_active: std::sync::atomic::AtomicBool,
105    foreground_finished: tokio::sync::Notify,
106}
107
108/// Process-wide cap on simultaneously active sparse segment scorers.
109///
110/// Each scorer performs random mmap reads. Letting every segment of every
111/// concurrent query run at once multiplies page faults without increasing
112/// useful NVMe throughput, so this gate is independent from the CPU pool.
113#[cfg(feature = "native")]
114#[derive(Debug)]
115pub(crate) struct SparseIoGate {
116    limit: usize,
117    active: parking_lot::Mutex<usize>,
118    available: parking_lot::Condvar,
119    async_available: tokio::sync::Notify,
120}
121
122#[cfg(feature = "native")]
123impl SparseIoGate {
124    fn new(limit: usize) -> Self {
125        Self {
126            limit,
127            active: parking_lot::Mutex::new(0),
128            available: parking_lot::Condvar::new(),
129            async_available: tokio::sync::Notify::new(),
130        }
131    }
132
133    #[cfg(feature = "sync")]
134    fn acquire(&self) -> SparseIoPermit<'_> {
135        let mut active = self.active.lock();
136        while *active >= self.limit {
137            self.available.wait(&mut active);
138        }
139        *active += 1;
140        SparseIoPermit { gate: self }
141    }
142
143    async fn acquire_async(&self) -> SparseIoPermit<'_> {
144        loop {
145            // Register before checking the counter, so a release between the
146            // check and await cannot be lost.
147            let notified = self.async_available.notified();
148            {
149                let mut active = self.active.lock();
150                if *active < self.limit {
151                    *active += 1;
152                    return SparseIoPermit { gate: self };
153                }
154            }
155            notified.await;
156        }
157    }
158}
159
160#[cfg(feature = "native")]
161struct SparseIoPermit<'a> {
162    gate: &'a SparseIoGate,
163}
164
165#[cfg(feature = "native")]
166impl Drop for SparseIoPermit<'_> {
167    fn drop(&mut self) {
168        let mut active = self.gate.active.lock();
169        *active -= 1;
170        self.gate.available.notify_one();
171        self.gate.async_available.notify_one();
172    }
173}
174
175#[cfg(feature = "native")]
176impl ReorderConcurrencyGate {
177    pub fn new(requested_limit: usize) -> Self {
178        let limit = requested_limit.clamp(1, MAX_CONCURRENT_REORDER_PASSES);
179        let automatic_merge_limit = limit.saturating_sub(1).max(1);
180        Self {
181            permits: Arc::new(tokio::sync::Semaphore::new(limit)),
182            optimizer_permits: Arc::new(tokio::sync::Semaphore::new(1)),
183            automatic_merge_permits: Arc::new(tokio::sync::Semaphore::new(automatic_merge_limit)),
184            limit,
185            foreground_lock: Arc::new(tokio::sync::Mutex::new(())),
186            foreground_active: std::sync::atomic::AtomicBool::new(false),
187            foreground_finished: tokio::sync::Notify::new(),
188        }
189    }
190
191    pub fn limit(&self) -> usize {
192        self.limit
193    }
194
195    /// Periodic maintenance must not occupy a queued task while foreground
196    /// work or another full sparse rewrite owns this shared capacity.
197    pub(crate) fn try_acquire_optimizer(
198        self: &Arc<Self>,
199    ) -> std::result::Result<ReorderPermit, tokio::sync::TryAcquireError> {
200        use std::sync::atomic::Ordering;
201        use tokio::sync::TryAcquireError;
202        if self.foreground_active.load(Ordering::Acquire) {
203            return Err(TryAcquireError::NoPermits);
204        }
205        let optimizer = Arc::clone(&self.optimizer_permits).try_acquire_owned()?;
206        let permit = Arc::clone(&self.permits).try_acquire_owned()?;
207        if self.foreground_active.load(Ordering::Acquire) {
208            return Err(TryAcquireError::NoPermits);
209        }
210        Ok(ReorderPermit {
211            _permit: permit,
212            _optimizer: Some(optimizer),
213            _automatic_merge: None,
214        })
215    }
216
217    pub(crate) async fn acquire(
218        self: &Arc<Self>,
219        priority: ReorderPriority,
220    ) -> std::result::Result<ReorderPermit, tokio::sync::AcquireError> {
221        match priority {
222            ReorderPriority::Optimizer => {
223                let optimizer_permit = Arc::clone(&self.optimizer_permits).acquire_owned().await?;
224                self.acquire_background(Some(optimizer_permit), None).await
225            }
226            ReorderPriority::AutomaticMerge => {
227                let merge_permit = Arc::clone(&self.automatic_merge_permits)
228                    .acquire_owned()
229                    .await?;
230                self.acquire_background(None, Some(merge_permit)).await
231            }
232            ReorderPriority::Foreground => self.acquire_foreground().await,
233        }
234    }
235
236    /// Acquire capacity for periodic optimizer or automatic merge work.
237    async fn acquire_background(
238        self: &Arc<Self>,
239        optimizer: Option<tokio::sync::OwnedSemaphorePermit>,
240        automatic_merge: Option<tokio::sync::OwnedSemaphorePermit>,
241    ) -> std::result::Result<ReorderPermit, tokio::sync::AcquireError> {
242        loop {
243            if self
244                .foreground_active
245                .load(std::sync::atomic::Ordering::Acquire)
246            {
247                let notified = self.foreground_finished.notified();
248                if self
249                    .foreground_active
250                    .load(std::sync::atomic::Ordering::Acquire)
251                {
252                    notified.await;
253                    continue;
254                }
255            }
256
257            let permit = Arc::clone(&self.permits).acquire_owned().await?;
258            if !self
259                .foreground_active
260                .load(std::sync::atomic::Ordering::Acquire)
261            {
262                return Ok(ReorderPermit {
263                    _permit: permit,
264                    _optimizer: optimizer,
265                    _automatic_merge: automatic_merge,
266                });
267            }
268            // A foreground operation started between the check and permit
269            // acquisition. Yield the slot instead of extending its queue.
270            drop(permit);
271        }
272    }
273
274    /// Acquire the one BP slot left available to a foreground force merge.
275    async fn acquire_foreground(
276        self: &Arc<Self>,
277    ) -> std::result::Result<ReorderPermit, tokio::sync::AcquireError> {
278        let permit = Arc::clone(&self.permits).acquire_owned().await?;
279        Ok(ReorderPermit {
280            _permit: permit,
281            _optimizer: None,
282            _automatic_merge: None,
283        })
284    }
285
286    /// Prioritize one explicit force merge across all indexes using this gate.
287    ///
288    /// Foreground operations are serialized to avoid two force merges each
289    /// reserving one slot and then waiting for the other. The guard is
290    /// cancellation-safe and releases reservations on drop.
291    pub(crate) async fn begin_foreground(
292        self: &Arc<Self>,
293    ) -> std::result::Result<ForegroundReorderGuard, tokio::sync::AcquireError> {
294        let exclusive = Arc::clone(&self.foreground_lock).lock_owned().await;
295        self.foreground_active
296            .store(true, std::sync::atomic::Ordering::Release);
297
298        // Construct the guard before awaiting capacity. If this future is
299        // cancelled while existing background work drains, Drop clears the
300        // active flag and releases the foreground mutex.
301        let mut guard = ForegroundReorderGuard {
302            gate: Arc::clone(self),
303            reserved: None,
304            _exclusive: exclusive,
305        };
306        if self.limit > 1 {
307            guard.reserved = Some(
308                Arc::clone(&self.permits)
309                    .acquire_many_owned((self.limit - 1) as u32)
310                    .await?,
311            );
312        }
313        Ok(guard)
314    }
315}
316
317#[cfg(feature = "native")]
318pub(crate) struct ReorderPermit {
319    _permit: tokio::sync::OwnedSemaphorePermit,
320    _optimizer: Option<tokio::sync::OwnedSemaphorePermit>,
321    _automatic_merge: Option<tokio::sync::OwnedSemaphorePermit>,
322}
323
324#[cfg(feature = "native")]
325pub(crate) struct ForegroundReorderGuard {
326    gate: Arc<ReorderConcurrencyGate>,
327    reserved: Option<tokio::sync::OwnedSemaphorePermit>,
328    _exclusive: tokio::sync::OwnedMutexGuard<()>,
329}
330
331#[cfg(feature = "native")]
332impl Drop for ForegroundReorderGuard {
333    fn drop(&mut self) {
334        // Make capacity visible before waking background waiters.
335        drop(self.reserved.take());
336        self.gate
337            .foreground_active
338            .store(false, std::sync::atomic::Ordering::Release);
339        self.gate.foreground_finished.notify_waiters();
340    }
341}
342
343/// Index configuration
344#[derive(Debug, Clone)]
345pub struct IndexConfig {
346    /// Number of threads shared by CPU-intensive search work.
347    ///
348    /// Indexes in the same process that request the same width reuse one Rayon
349    /// pool. A value of zero is invalid and is rejected by `Index::create` and
350    /// `Index::open`.
351    pub num_threads: usize,
352    /// Maximum sparse segment scorers issuing random mmap reads concurrently
353    /// across the process. CPU parallelism remains controlled by
354    /// `num_threads`; this separate cap protects the page cache and storage
355    /// queue from segment/query fan-out.
356    pub sparse_io_concurrency: usize,
357    /// Number of parallel segment builders (documents distributed round-robin)
358    pub num_indexing_threads: usize,
359    /// Width of the document-store compression pool. Concurrent segment
360    /// builders requesting the same width share one process-wide pool, so
361    /// indexing-worker fan-out does not multiply this thread count.
362    pub num_compression_threads: usize,
363    /// Block cache size for term dictionary per segment
364    pub term_cache_blocks: usize,
365    /// Optional per-segment cap on retained decompressed dictionary-block bytes.
366    /// None preserves the block-count policy; zero disables retention.
367    pub term_cache_budget_bytes: Option<usize>,
368    /// Flush target for newly written term dictionaries; default 16 KiB.
369    pub term_dict_block_size: crate::structures::SSTableBlockSize,
370    /// Process-wide byte budget for decompressed document-store blocks.
371    ///
372    /// Indexes opened with the same budget share one read-concurrent,
373    /// byte-bounded cache. This is a byte limit rather than a block count
374    /// because a stored document can legitimately make one decompressed block
375    /// tens of MiB.
376    pub store_cache_budget_bytes: usize,
377    /// Max memory (bytes) across all builders before auto-commit (global limit)
378    pub max_indexing_memory_bytes: usize,
379    /// Maximum vectors retained for one field's global ANN training sample.
380    /// The byte budget below is applied at the same time; the smaller bound
381    /// wins. Fields are sampled and trained serially.
382    pub vector_training_max_samples: usize,
383    /// Maximum raw vector bytes retained for one field's ANN training sample.
384    pub vector_training_memory_bytes: usize,
385    /// Merge policy for background segment merging
386    pub merge_policy: Box<dyn crate::merge::MergePolicy>,
387    /// Index optimization mode (adaptive, size-optimized, performance-optimized).
388    /// Selects the term-dictionary compression level and, unless
389    /// `posting_codec` overrides it, the posting block codec
390    /// (`docs/posting-codecs.md`).
391    pub optimization: crate::structures::IndexOptimization,
392    /// Explicit posting block codec; `None` derives it from `optimization`
393    /// (`size` → `Pfor`, everything else → `Rounded`).
394    pub posting_codec: Option<crate::structures::PostingCodec>,
395    /// New plain-text columns use versioned byte4 norms. Existing segments retain their scores.
396    pub quantized_norms: bool,
397    /// New position streams use a compact directory separate from payload pages.
398    pub compact_text: bool,
399    /// Opt in to compact, score-independent length/TF block bounds.
400    ///
401    /// Applies to new segments only. Merges, compaction, and reorder copy or
402    /// re-encode each list in the representation its sources already have:
403    /// existing blocks keep their layout and are never upgraded, not even by
404    /// `force_merge`. Rebuild (re-index) to add bounds to old data. Opening
405    /// an index whose segments lack the enabled bounds logs this once.
406    pub posting_ratio_bounds: bool,
407    /// Opt in to bounded competitive frequency/length envelopes. Implies ratio
408    /// bounds (`effective_posting_bounds`). Same new-segments-only policy as
409    /// `posting_ratio_bounds`.
410    pub posting_impact_bounds: bool,
411    /// Reload interval in milliseconds for IndexReader (how often to check for new segments)
412    pub reload_interval_ms: u64,
413    /// Maximum number of concurrent background merges per index (default: 4)
414    pub max_concurrent_merges: usize,
415    /// Application-wide background merge gate shared by clones of this
416    /// config. The per-index limit alone multiplied large merge working sets
417    /// by the number of active indexes.
418    #[cfg(feature = "native")]
419    pub background_merge_permits: Arc<tokio::sync::Semaphore>,
420    /// Wall-clock budget for merge-time BP reorder per field (only applies
421    /// when the index has `reorder_on_merge`). A truncated pass still writes
422    /// a valid, better-ordered segment; it is marked `bp_converged = false`
423    /// and the background optimizer deepens it later (warm-started).
424    /// `None` = unbudgeted (BP runs to full depth inside the merge, which can
425    /// hold a merge slot for 10-30+ minutes on 10M+ doc outputs).
426    pub merge_bp_time_budget: Option<std::time::Duration>,
427    /// Memory budget (bytes) for the BP forward index during reorder passes
428    /// (merge-time and background). When a large segment's forward index
429    /// would exceed this, the highest-df dims are dropped from BP's input
430    /// (logged loudly) — clustering quality degrades gracefully. Production
431    /// evidence: 18M-doc merges exceeded the former 2 GB default and dropped
432    /// ~10% of eligible dims; hosts with less headroom may lower this.
433    pub bp_memory_budget_bytes: usize,
434    /// Scratch limit for explicit or background physical row compaction.
435    pub compaction_memory_budget_bytes: usize,
436    /// Hard limit on simultaneous whole-segment BP rewrites. This is shared
437    /// by all indexes opened from clones of this config and applies to
438    /// optimizer, merge-time, and manual reorder passes. It is deliberately
439    /// separate from the Rayon pool width: one pass can already use every
440    /// background CPU thread and consume the full BP memory budget.
441    #[cfg(feature = "native")]
442    pub background_reorder_permits: Arc<ReorderConcurrencyGate>,
443    /// Optional process/application-owned Rayon pool for BP work. Supplying
444    /// one lets every index and the optimizer share the same worker threads;
445    /// `None` lazily uses one process-wide cores/2 fallback pool.
446    #[cfg(feature = "native")]
447    pub background_reorder_pool: Option<Arc<rayon::ThreadPool>>,
448}
449
450/// Search pools are shared process-wide by width. This avoids multiplying OS
451/// threads by the number of open indexes while still allowing applications to
452/// deliberately isolate indexes that need different CPU budgets.
453#[cfg(feature = "sync")]
454static SEARCH_CPU_POOLS: OnceLock<parking_lot::Mutex<HashMap<usize, Weak<rayon::ThreadPool>>>> =
455    OnceLock::new();
456
457/// Store caches are shared process-wide by configured byte budget, just like
458/// search CPU pools are shared by width. `IndexRegistry` clones one config for
459/// every index, but standalone callers with the same policy also converge on
460/// the same bounded cache.
461#[cfg(feature = "native")]
462static STORE_CACHE_POOLS: OnceLock<
463    parking_lot::Mutex<std::collections::HashMap<usize, Weak<crate::segment::SharedStoreCache>>>,
464> = OnceLock::new();
465
466#[cfg(feature = "native")]
467static SPARSE_IO_GATES: OnceLock<
468    parking_lot::Mutex<std::collections::HashMap<usize, Weak<SparseIoGate>>>,
469> = OnceLock::new();
470
471/// Announce each resource kind once per process. Weak registry entries can
472/// expire between index opens; subsequent creations (including different
473/// settings) remain visible at DEBUG without retaining resources or an
474/// unbounded history of configurations just for logging.
475#[cfg(feature = "native")]
476fn shared_resource_log_level(announced: &OnceLock<()>) -> log::Level {
477    if announced.set(()).is_ok() {
478        log::Level::Info
479    } else {
480        log::Level::Debug
481    }
482}
483
484#[cfg(feature = "native")]
485pub(crate) fn shared_sparse_io_gate(limit: usize) -> Arc<SparseIoGate> {
486    let mut gates = SPARSE_IO_GATES
487        .get_or_init(|| parking_lot::Mutex::new(std::collections::HashMap::new()))
488        .lock();
489    if let Some(gate) = gates.get(&limit).and_then(Weak::upgrade) {
490        return gate;
491    }
492    let gate = Arc::new(SparseIoGate::new(limit));
493    gates.retain(|_, gate| gate.strong_count() > 0);
494    gates.insert(limit, Arc::downgrade(&gate));
495    static ANNOUNCED: OnceLock<()> = OnceLock::new();
496    log::log!(
497        shared_resource_log_level(&ANNOUNCED),
498        "[sparse] process-wide random-I/O concurrency={limit}"
499    );
500    gate
501}
502
503#[cfg(feature = "native")]
504pub(crate) fn shared_store_cache(budget_bytes: usize) -> Arc<crate::segment::SharedStoreCache> {
505    let mut caches = STORE_CACHE_POOLS
506        .get_or_init(|| parking_lot::Mutex::new(std::collections::HashMap::new()))
507        .lock();
508    if let Some(cache) = caches.get(&budget_bytes).and_then(Weak::upgrade) {
509        return cache;
510    }
511    let cache = Arc::new(crate::segment::SharedStoreCache::new(budget_bytes));
512    caches.retain(|_, cache| cache.strong_count() > 0);
513    caches.insert(budget_bytes, Arc::downgrade(&cache));
514    static ANNOUNCED: OnceLock<()> = OnceLock::new();
515    log::log!(
516        shared_resource_log_level(&ANNOUNCED),
517        "[store_cache] process-wide budget={}",
518        crate::format_bytes(budget_bytes as u64)
519    );
520    cache
521}
522
523#[cfg(feature = "sync")]
524fn shared_search_pool(num_threads: usize) -> Result<Arc<rayon::ThreadPool>> {
525    if num_threads == 0 {
526        return Err(crate::Error::Internal(
527            "IndexConfig.num_threads must be greater than zero".into(),
528        ));
529    }
530
531    let mut pools = SEARCH_CPU_POOLS
532        .get_or_init(|| parking_lot::Mutex::new(HashMap::new()))
533        .lock();
534    if let Some(pool) = pools.get(&num_threads).and_then(Weak::upgrade) {
535        return Ok(pool);
536    }
537
538    // Build while holding the registry lock. Index construction is cold-path
539    // work, and serialization here prevents two concurrent opens from creating
540    // duplicate pools for the same width.
541    let pool = Arc::new(
542        rayon::ThreadPoolBuilder::new()
543            .num_threads(num_threads)
544            .thread_name(move |idx| format!("summa-search-{}-{}", num_threads, idx))
545            .build()
546            .map_err(|error| {
547                crate::Error::Internal(format!(
548                    "failed to create {num_threads}-thread search pool: {error}"
549                ))
550            })?,
551    );
552    pools.retain(|_, pool| pool.strong_count() > 0);
553    pools.insert(num_threads, Arc::downgrade(&pool));
554    static ANNOUNCED: OnceLock<()> = OnceLock::new();
555    log::log!(
556        shared_resource_log_level(&ANNOUNCED),
557        "[search] process-wide CPU pool: {} thread(s)",
558        num_threads
559    );
560    Ok(pool)
561}
562
563impl Default for IndexConfig {
564    fn default() -> Self {
565        #[cfg(feature = "native")]
566        let compression_threads = crate::default_compression_threads();
567        #[cfg(not(feature = "native"))]
568        let compression_threads = 1;
569
570        #[cfg(feature = "native")]
571        let search_threads = crate::default_search_threads();
572        #[cfg(not(feature = "native"))]
573        let search_threads = 1;
574
575        Self {
576            num_threads: search_threads,
577            sparse_io_concurrency: 4,
578            num_indexing_threads: 1, // Increase to 2+ for production to avoid stalls during segment build
579            num_compression_threads: compression_threads,
580            term_cache_blocks: 256,
581            term_cache_budget_bytes: None,
582            term_dict_block_size: crate::structures::SSTableBlockSize::default(),
583            // Stored bodies can be much larger than the writer's nominal
584            // 16-KiB block target. Keep this process-wide and byte bounded so
585            // segment fan-out cannot multiply it into tens of GiB.
586            #[cfg(target_pointer_width = "64")]
587            store_cache_budget_bytes: 2 * 1024 * 1024 * 1024,
588            #[cfg(not(target_pointer_width = "64"))]
589            store_cache_budget_bytes: 32 * 1024 * 1024,
590            max_indexing_memory_bytes: 256 * 1024 * 1024, // 256 MB default
591            vector_training_max_samples: 10_000_000,
592            #[cfg(target_pointer_width = "64")]
593            vector_training_memory_bytes: 4 * 1024 * 1024 * 1024,
594            #[cfg(not(target_pointer_width = "64"))]
595            vector_training_memory_bytes: usize::MAX,
596            // large_scale: wide fan-in + budget/scored selection. Safe for
597            // small indexes too (tier floors only shape *when* segments
598            // merge); merge-time BP is wall-clock budgeted, so giant merges
599            // cannot hold slots indefinitely.
600            merge_policy: Box::new(crate::merge::TieredMergePolicy::large_scale()),
601            optimization: crate::structures::IndexOptimization::default(),
602            posting_codec: None,
603            quantized_norms: false,
604            compact_text: false,
605            posting_ratio_bounds: false,
606            posting_impact_bounds: false,
607            reload_interval_ms: 1000, // 1 second default
608            max_concurrent_merges: 4,
609            #[cfg(feature = "native")]
610            background_merge_permits: Arc::new(tokio::sync::Semaphore::new(4)),
611            merge_bp_time_budget: Some(std::time::Duration::from_secs(600)),
612            // 24 GB — mirrors segment::reorder::DEFAULT_MEMORY_BUDGET (that
613            // module is native-only; IndexConfig also compiles for wasm).
614            // A cap, not an allocation: usage is proportional to the segment
615            // being reordered (~4 B/posting + ~32 B/doc). Sized from prod
616            // evidence: a 58M-doc/5B-posting pass estimated 20.1 GB, which
617            // 8/16 GB budgets trimmed by dropping highest-df dims.
618            // 24 GB overflows 32-bit usize (wasm32) — reorder never runs
619            // there, so any large value works; use usize::MAX.
620            #[cfg(target_pointer_width = "64")]
621            bp_memory_budget_bytes: 24 * 1024 * 1024 * 1024,
622            #[cfg(not(target_pointer_width = "64"))]
623            bp_memory_budget_bytes: usize::MAX,
624            compaction_memory_budget_bytes: 256 * 1024 * 1024,
625            #[cfg(feature = "native")]
626            background_reorder_permits: Arc::new(ReorderConcurrencyGate::new(2)),
627            #[cfg(feature = "native")]
628            background_reorder_pool: None,
629        }
630    }
631}
632
633/// Largest `IndexConfig::term_cache_blocks`; the per-segment dictionary block
634/// cache is sized by count and this keeps a typo from pinning a whole
635/// dictionary per segment.
636pub const MAX_TERM_CACHE_BLOCKS: usize = 65_536;
637
638/// Reject an out-of-range dictionary block cap before any segment is opened.
639#[cfg(feature = "native")]
640pub(crate) fn validate_term_cache_blocks(blocks: usize) -> crate::Result<()> {
641    if blocks > MAX_TERM_CACHE_BLOCKS {
642        return Err(crate::Error::Internal(format!(
643            "IndexConfig.term_cache_blocks must be at most {MAX_TERM_CACHE_BLOCKS} (got {blocks})"
644        )));
645    }
646    Ok(())
647}
648
649/// Block-bound metadata layout new posting lists are written with.
650#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
651pub struct PostingBounds {
652    /// Score-independent length/TF ratio minima per block and L1 group.
653    pub ratio: bool,
654    /// Competitive frequency/length envelopes (always together with ratios).
655    pub impact: bool,
656}
657
658impl PostingBounds {
659    pub(crate) fn new(ratio: bool, impact: bool) -> Self {
660        Self {
661            ratio: ratio || impact,
662            impact,
663        }
664    }
665}
666
667impl IndexConfig {
668    /// Posting block codec new segments and merges are written with.
669    pub fn effective_posting_codec(&self) -> crate::structures::PostingCodec {
670        self.posting_codec
671            .unwrap_or_else(|| self.optimization.default_posting_codec())
672    }
673
674    /// Block-bound metadata new segments are written with. Impact bounds
675    /// imply ratio bounds; this is the single place that rule is applied.
676    pub fn effective_posting_bounds(&self) -> PostingBounds {
677        PostingBounds::new(self.posting_ratio_bounds, self.posting_impact_bounds)
678    }
679}
680
681/// Segments probed by [`segments_missing_posting_bounds`] and dictionary
682/// entries scanned per segment before the probe gives up as inconclusive.
683#[cfg(feature = "native")]
684const POSTING_BOUNDS_PROBE_SEGMENTS: usize = 32;
685#[cfg(feature = "native")]
686const POSTING_BOUNDS_PROBE_TERMS: usize = 4096;
687
688/// Existing segments whose posting lists lack the block-bound metadata
689/// `config` enables (`posting_ratio_bounds` / `posting_impact_bounds`).
690///
691/// Bounds apply to new segments only; nothing upgrades old blocks. This
692/// probe reads one term dictionary prefix and one external posting list per
693/// segment (bounded by the constants above) so an operator learns at open
694/// time that the option is not retroactive. Returns `(missing, probed)`.
695/// Impact envelopes exist only on multi-block lists, so the probe checks
696/// ratio metadata, which both options write.
697#[cfg(feature = "native")]
698pub(crate) async fn segments_missing_posting_bounds<D: crate::directories::Directory>(
699    directory: &D,
700    metadata: &IndexMetadata,
701    config: &IndexConfig,
702) -> Result<(Vec<String>, usize)> {
703    use crate::segment::{SegmentFiles, SegmentId};
704    use crate::structures::{AsyncSSTableReader, BlockPostingList, TermInfo};
705
706    let mut missing = Vec::new();
707    let mut probed = 0usize;
708    if !config.effective_posting_bounds().ratio {
709        return Ok((missing, probed));
710    }
711    for id in metadata
712        .segment_ids()
713        .into_iter()
714        .take(POSTING_BOUNDS_PROBE_SEGMENTS)
715    {
716        let Some(segment_id) = SegmentId::from_hex(&id) else {
717            continue;
718        };
719        let files = SegmentFiles::new(segment_id.0);
720        if !directory.exists(&files.term_dict).await? {
721            continue;
722        }
723        let term_dict = AsyncSSTableReader::<TermInfo>::open_with_cache_budget(
724            directory.open_lazy(&files.term_dict).await?,
725            1,
726            None,
727        )
728        .await?;
729        let mut terms = term_dict.iter();
730        let mut external = None;
731        for _ in 0..POSTING_BOUNDS_PROBE_TERMS {
732            match terms.next().await? {
733                Some((_, info)) => {
734                    if let Some(range) = info.external_info() {
735                        external = Some(range);
736                        break;
737                    }
738                }
739                None => break,
740            }
741        }
742        let Some((offset, len)) = external else {
743            continue;
744        };
745        let postings = directory.open_lazy(&files.postings).await?;
746        let end = offset.checked_add(len).ok_or_else(|| {
747            crate::Error::Corruption("posting range overflow while probing bounds".into())
748        })?;
749        let list =
750            BlockPostingList::deserialize_zero_copy(postings.read_bytes_range(offset..end).await?)?;
751        probed += 1;
752        if !list.has_ratio_bounds() {
753            missing.push(id);
754        }
755    }
756    Ok((missing, probed))
757}
758
759/// Log once per open when enabled posting bounds do not cover existing
760/// segments. Probe failures are logged, never fatal: a corrupt segment fails
761/// loudly when it is actually opened.
762#[cfg(feature = "native")]
763async fn log_posting_bounds_policy<D: crate::directories::Directory>(
764    directory: &D,
765    metadata: &IndexMetadata,
766    config: &IndexConfig,
767) {
768    match segments_missing_posting_bounds(directory, metadata, config).await {
769        Ok((missing, probed)) if !missing.is_empty() => log::info!(
770            "[index] {}: posting_ratio_bounds/posting_impact_bounds are enabled but {} of {} \
771             probed existing segments carry no block-bound metadata (e.g. {}). Bounds apply \
772             to new segments only; merges and compaction keep existing block layouts. \
773             Re-index to add bounds to old data.",
774            metadata.schema.index_label(),
775            missing.len(),
776            probed,
777            missing[0]
778        ),
779        Ok(_) => {}
780        Err(error) => log::warn!(
781            "[index] {}: could not probe existing segments for posting bounds: {}",
782            metadata.schema.index_label(),
783            error
784        ),
785    }
786}
787
788/// Build the segment-lifecycle owner from the corresponding index policy.
789///
790/// `Index` and `IndexWriter` both support create/open entry points. Routing
791/// their shared configuration through this helper prevents a new
792/// `SegmentManager` option from being wired into only some constructors.
793#[cfg(feature = "native")]
794fn segment_manager_from_config<D: crate::directories::DirectoryWriter + 'static>(
795    directory: &Arc<D>,
796    schema: &Arc<Schema>,
797    metadata: IndexMetadata,
798    config: &IndexConfig,
799) -> Result<Arc<crate::merge::SegmentManager<D>>> {
800    // Writer-only opens never build `SearcherResources`; lifecycle readers
801    // still size their dictionary caches from this value.
802    validate_term_cache_blocks(config.term_cache_blocks)?;
803    Ok(Arc::new(
804        crate::merge::SegmentManager::new(
805            Arc::clone(directory),
806            Arc::clone(schema),
807            metadata,
808            config.merge_policy.clone_box(),
809            config.term_cache_blocks,
810            config.max_concurrent_merges,
811            Arc::clone(&config.background_merge_permits),
812            config.merge_bp_time_budget,
813            config.bp_memory_budget_bytes,
814            Arc::clone(&config.background_reorder_permits),
815            config.background_reorder_pool.clone(),
816        )
817        .with_posting_config(config.optimization, config.effective_posting_codec())
818        .with_term_dict_block_size(config.term_dict_block_size)
819        .with_term_cache_budget(config.term_cache_budget_bytes),
820    ))
821}
822
823/// Multi-segment async Index
824///
825/// The central concept for search. Owns segment lifecycle and provides:
826/// - `Index::create()` / `Index::open()` - create or open an index
827/// - `index.writer()` - get an IndexWriter for adding documents
828/// - `index.reader()` - get an IndexReader for searching with reload policy
829///
830/// All segment management is delegated to SegmentManager.
831#[cfg(feature = "native")]
832pub struct Index<D: crate::directories::DirectoryWriter + 'static> {
833    directory: Arc<D>,
834    config: IndexConfig,
835    /// Cache and CPU policy used by every searcher reload.
836    search_resources: searcher::SearcherResources,
837    /// Segment manager - owns segments, tracker, metadata, and trained structures
838    segment_manager: Arc<crate::merge::SegmentManager<D>>,
839    /// Cached reader (created lazily, reused across calls)
840    cached_reader: tokio::sync::OnceCell<IndexReader<D>>,
841}
842
843#[cfg(feature = "native")]
844impl<D: crate::directories::DirectoryWriter + 'static> Index<D> {
845    /// Create a new index in the directory
846    pub async fn create(directory: D, schema: Schema, config: IndexConfig) -> Result<Self> {
847        schema.validate()?;
848        let search_resources = searcher::SearcherResources::from_config(&config)?;
849        let directory = Arc::new(directory);
850        let schema = Arc::new(schema);
851        // Directory-layer metrics (cold writes, lazy reads) carry the index label
852        directory.set_index_label(schema.index_label());
853
854        // Refuse to clobber an existing index: persisting a fresh empty
855        // metadata.json would orphan every committed segment, and the next
856        // writer open's orphan sweep would permanently delete them.
857        if directory
858            .exists(std::path::Path::new(INDEX_META_FILENAME))
859            .await?
860        {
861            return Err(crate::Error::Internal(format!(
862                "refusing to create index: {} already exists in this directory; \
863                 use Index::open to open the existing index, or delete the \
864                 directory first if you really want to start over",
865                INDEX_META_FILENAME
866            )));
867        }
868
869        let metadata = IndexMetadata::new((*schema).clone());
870
871        let segment_manager = segment_manager_from_config(&directory, &schema, metadata, &config)?;
872
873        // Save initial metadata
874        segment_manager.update_metadata(|_| {}).await?;
875
876        Ok(Self {
877            directory,
878            config,
879            search_resources,
880            segment_manager,
881            cached_reader: tokio::sync::OnceCell::new(),
882        })
883    }
884
885    /// Open an existing index from a directory
886    pub async fn open(directory: D, config: IndexConfig) -> Result<Self> {
887        let search_resources = searcher::SearcherResources::from_config(&config)?;
888        let directory = Arc::new(directory);
889
890        // Load metadata (includes schema)
891        let metadata = IndexMetadata::load(directory.as_ref()).await?;
892        let schema = Arc::new(metadata.schema.clone());
893        // Directory-layer metrics (cold writes, lazy reads) carry the index label
894        directory.set_index_label(schema.index_label());
895        log_posting_bounds_policy(directory.as_ref(), &metadata, &config).await;
896
897        let segment_manager = segment_manager_from_config(&directory, &schema, metadata, &config)?;
898
899        // Load trained structures into SegmentManager's ArcSwap
900        segment_manager.try_load_and_publish_trained().await?;
901
902        Ok(Self {
903            directory,
904            config,
905            search_resources,
906            segment_manager,
907            cached_reader: tokio::sync::OnceCell::new(),
908        })
909    }
910
911    /// Open a search index and its sole writer under one lifecycle owner.
912    ///
913    /// The writer lock precedes metadata loading and crash cleanup. Use this
914    /// when opening for mutation; `open` remains a read-only operation.
915    pub async fn open_with_writer(
916        directory: D,
917        config: IndexConfig,
918    ) -> Result<(Self, IndexWriter<D>)> {
919        let search_resources = searcher::SearcherResources::from_config(&config)?;
920        let writer = IndexWriter::open(directory, config.clone()).await?;
921        let index = Self {
922            directory: Arc::clone(&writer.directory),
923            config,
924            search_resources,
925            segment_manager: Arc::clone(writer.segment_manager()),
926            cached_reader: tokio::sync::OnceCell::new(),
927        };
928        Ok((index, writer))
929    }
930
931    /// Get the schema
932    pub fn schema(&self) -> Arc<Schema> {
933        self.schema_arc()
934    }
935
936    /// Clone the schema handle from the currently published generation.
937    pub fn schema_arc(&self) -> Arc<Schema> {
938        self.segment_manager.published_generation().schema.clone()
939    }
940
941    /// Get a reference to the underlying directory
942    pub fn directory(&self) -> &D {
943        &self.directory
944    }
945
946    /// Get the segment manager
947    pub fn segment_manager(&self) -> &Arc<crate::merge::SegmentManager<D>> {
948        &self.segment_manager
949    }
950
951    /// Get an IndexReader for searching (with reload policy)
952    ///
953    /// The reader is cached and reused across calls. The reader's internal
954    /// searcher will reload segments based on its reload interval (configurable via IndexConfig).
955    pub async fn reader(&self) -> Result<&IndexReader<D>> {
956        self.cached_reader
957            .get_or_try_init(|| async {
958                IndexReader::from_segment_manager_with_resources(
959                    self.schema_arc(),
960                    Arc::clone(&self.segment_manager),
961                    self.config.reload_interval_ms,
962                    self.search_resources.clone(),
963                )
964                .await
965            })
966            .await
967    }
968
969    /// Get the config
970    pub fn config(&self) -> &IndexConfig {
971        &self.config
972    }
973
974    /// Get segment readers for query execution (convenience method)
975    pub async fn segment_readers(&self) -> Result<Vec<Arc<crate::segment::SegmentReader>>> {
976        let reader = self.reader().await?;
977        let searcher = reader.searcher().await?;
978        Ok(searcher.segment_readers().to_vec())
979    }
980
981    /// Total number of documents across all segments
982    pub async fn num_docs(&self) -> Result<u32> {
983        let reader = self.reader().await?;
984        let searcher = reader.searcher().await?;
985        Ok(searcher.num_docs())
986    }
987
988    /// Get default fields for search
989    pub fn default_fields(&self) -> Vec<crate::Field> {
990        let schema = self.schema_arc();
991        if !schema.default_fields().is_empty() {
992            schema.default_fields().to_vec()
993        } else {
994            schema
995                .fields()
996                .filter(|(_, entry)| {
997                    entry.indexed && entry.field_type == crate::dsl::FieldType::Text
998                })
999                .map(|(field, _)| field)
1000                .collect()
1001        }
1002    }
1003
1004    /// Get tokenizer registry
1005    pub fn tokenizers(&self) -> Arc<crate::tokenizer::TokenizerRegistry> {
1006        Arc::new(crate::tokenizer::TokenizerRegistry::default())
1007    }
1008
1009    /// Create a query parser for this index
1010    pub fn query_parser(&self) -> crate::dsl::QueryLanguageParser {
1011        let default_fields = self.default_fields();
1012        let tokenizers = self.tokenizers();
1013        let schema = self.schema_arc();
1014
1015        let query_routers = schema.query_routers();
1016        if !query_routers.is_empty()
1017            && let Ok(router) = crate::dsl::QueryFieldRouter::from_rules(query_routers)
1018        {
1019            return crate::dsl::QueryLanguageParser::with_router(
1020                Arc::clone(&schema),
1021                default_fields,
1022                tokenizers,
1023                router,
1024            );
1025        }
1026
1027        crate::dsl::QueryLanguageParser::new(schema, default_fields, tokenizers)
1028    }
1029
1030    /// Parse and search using a query string
1031    pub async fn query(
1032        &self,
1033        query_str: &str,
1034        limit: usize,
1035    ) -> Result<crate::query::SearchResponse> {
1036        self.query_offset(query_str, limit, 0).await
1037    }
1038
1039    /// Query with offset for pagination
1040    pub async fn query_offset(
1041        &self,
1042        query_str: &str,
1043        limit: usize,
1044        offset: usize,
1045    ) -> Result<crate::query::SearchResponse> {
1046        let parser = self.query_parser();
1047        let query = parser
1048            .parse(query_str)
1049            .map_err(crate::error::Error::Query)?;
1050        self.search_offset(query.as_ref(), limit, offset).await
1051    }
1052
1053    /// Search and return results
1054    pub async fn search(
1055        &self,
1056        query: &dyn crate::query::Query,
1057        limit: usize,
1058    ) -> Result<crate::query::SearchResponse> {
1059        self.search_offset(query, limit, 0).await
1060    }
1061
1062    /// Search with offset for pagination
1063    pub async fn search_offset(
1064        &self,
1065        query: &dyn crate::query::Query,
1066        limit: usize,
1067        offset: usize,
1068    ) -> Result<crate::query::SearchResponse> {
1069        let reader = self.reader().await?;
1070        let searcher = reader.searcher().await?;
1071
1072        #[cfg(feature = "sync")]
1073        let (results, total_seen) = {
1074            // Sync search: rayon handles segment parallelism internally.
1075            // On multi-threaded tokio, use block_in_place to yield the worker;
1076            // on single-threaded (tests), call directly.
1077            let runtime_flavor = tokio::runtime::Handle::current().runtime_flavor();
1078            if runtime_flavor == tokio::runtime::RuntimeFlavor::MultiThread {
1079                tokio::task::block_in_place(|| {
1080                    searcher.search_with_offset_and_count_sync(query, limit, offset)
1081                })?
1082            } else {
1083                searcher.search_with_offset_and_count_sync(query, limit, offset)?
1084            }
1085        };
1086
1087        #[cfg(not(feature = "sync"))]
1088        let (results, total_seen) = {
1089            searcher
1090                .search_with_offset_and_count(query, limit, offset)
1091                .await?
1092        };
1093
1094        let total_hits = total_seen;
1095        let hits: Vec<crate::query::SearchHit> = results
1096            .into_iter()
1097            .map(|result| crate::query::SearchHit {
1098                address: crate::query::DocAddress::new(result.segment_id, result.doc_id),
1099                score: result.score,
1100                matched_fields: result.extract_ordinals(),
1101            })
1102            .collect();
1103
1104        Ok(crate::query::SearchResponse { hits, total_hits })
1105    }
1106
1107    /// Get a document by its unique address
1108    pub async fn get_document(
1109        &self,
1110        address: &crate::query::DocAddress,
1111    ) -> Result<Option<crate::dsl::Document>> {
1112        let reader = self.reader().await?;
1113        let searcher = reader.searcher().await?;
1114        searcher.get_document(address).await
1115    }
1116
1117    /// Get posting lists for a term across all segments
1118    pub async fn get_postings(
1119        &self,
1120        field: crate::Field,
1121        term: &[u8],
1122    ) -> Result<
1123        Vec<(
1124            Arc<crate::segment::SegmentReader>,
1125            crate::structures::BlockPostingList,
1126        )>,
1127    > {
1128        let segments = self.segment_readers().await?;
1129        let mut results = Vec::new();
1130
1131        for segment in segments {
1132            if let Some(postings) = segment.get_postings(field, term).await? {
1133                results.push((segment, postings));
1134            }
1135        }
1136
1137        Ok(results)
1138    }
1139}
1140
1141/// Native-only methods for Index
1142#[cfg(feature = "native")]
1143impl<D: crate::directories::DirectoryWriter + 'static> Index<D> {
1144    /// Get an IndexWriter for adding documents
1145    pub fn writer(&self) -> writer::IndexWriter<D> {
1146        writer::IndexWriter::from_index(self)
1147    }
1148}
1149
1150#[cfg(test)]
1151mod tests;
1152
1153// (tests moved to index/tests/ module)