Skip to main content

hermes_core/index/
writer.rs

1//! IndexWriter — async document indexing with parallel segment building.
2//!
3//! This module is only compiled with the "native" feature.
4//!
5//! # Architecture
6//!
7//! ```text
8//! add_document() ──try_send──► [shared bounded MPMC] ◄──recv── worker 0
9//!                                                     ◄──recv── worker 1
10//!                                                     ◄──recv── worker N
11//! ```
12//!
13//! - **Shared MPMC queue** (`async_channel`): all workers compete for documents.
14//!   Busy workers (building segments) naturally stop pulling; free workers pick up slack.
15//! - **Zero-copy pipeline**: `Document` is moved (never cloned) through every stage:
16//!   `add_document()` → channel → `recv_blocking()` → `SegmentBuilder::add_document()`.
17//! - `add_document` returns `QueueFull` when the queue is at capacity.
18//! - **Workers are OS threads**: CPU-intensive work (tokenization, posting list building)
19//!   runs on dedicated threads, never blocking the tokio async runtime.
20//!   Async I/O (segment file writes) is bridged via `Handle::block_on()`.
21//! - **Fixed per-worker memory budget**: `max_indexing_memory_bytes / num_workers`.
22//! - **Two-phase commit**:
23//!   1. `prepare_commit()` — closes queue, workers flush builders to disk.
24//!      Returns a `PreparedCommit` guard. No new documents accepted until resolved.
25//!   2. `PreparedCommit::commit()` — registers segments in metadata, resumes workers.
26//!   3. `PreparedCommit::abort()` — discards prepared segments, resumes workers.
27//!   4. `commit()` — convenience: `prepare_commit().await?.commit().await`.
28//!
29//! Since `prepare_commit`/`commit` take `&mut self`, Rust’s borrow checker
30//! guarantees no concurrent `add_document` calls during the commit window.
31
32use std::sync::Arc;
33use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
34
35use rustc_hash::FxHashMap;
36
37use crate::directories::DirectoryWriter;
38use crate::dsl::{Document, Field, Schema};
39use crate::error::{Error, Result};
40use crate::segment::{SegmentBuilder, SegmentBuilderConfig, SegmentId};
41use crate::tokenizer::BoxedTokenizer;
42
43use super::IndexConfig;
44
45/// Total pipeline capacity (in documents).
46const PIPELINE_MAX_SIZE_IN_DOCS: usize = 10_000;
47
48/// Async IndexWriter for adding documents and committing segments.
49///
50/// **Backpressure:** `add_document()` is sync, O(1). Returns `Error::QueueFull`
51/// when the shared queue is at capacity — caller must back off.
52///
53/// **Two-phase commit:**
54/// - `prepare_commit()` → `PreparedCommit::commit()` or `PreparedCommit::abort()`
55/// - `commit()` is a convenience that does both phases.
56/// - Between prepare and commit, the caller can do external work (WAL, sync, etc.)
57///   knowing that abort is possible if something fails.
58/// - Dropping `PreparedCommit` without calling commit/abort auto-aborts.
59pub struct IndexWriter<D: DirectoryWriter + 'static> {
60    pub(super) directory: Arc<D>,
61    pub(super) schema: Arc<Schema>,
62    pub(super) config: IndexConfig,
63    /// MPMC sender — `try_send(&self)` is thread-safe, no lock needed.
64    /// Replaced on each commit cycle (workers get new receiver via resume).
65    doc_sender: async_channel::Sender<Document>,
66    /// Worker OS thread handles — long-lived, survive across commits.
67    workers: Vec<std::thread::JoinHandle<()>>,
68    /// Shared worker state (immutable config + mutable segment output + sync)
69    worker_state: Arc<WorkerState<D>>,
70    /// Segment manager — owns metadata.json, handles segments and background merging
71    pub(super) segment_manager: Arc<crate::merge::SegmentManager<D>>,
72    /// Segments flushed to disk but not yet registered in metadata
73    flushed_segments: Vec<(String, u32)>,
74    /// Primary key dedup index (None if schema has no primary field)
75    primary_key_index: Option<super::primary_key::PrimaryKeyIndex>,
76}
77
78/// Shared state for worker threads.
79struct WorkerState<D: DirectoryWriter + 'static> {
80    directory: Arc<D>,
81    schema: Arc<Schema>,
82    builder_config: SegmentBuilderConfig,
83    tokenizers: parking_lot::RwLock<FxHashMap<Field, BoxedTokenizer>>,
84    /// Fixed per-worker memory budget (bytes). When a builder exceeds this, segment is built.
85    memory_budget_per_worker: usize,
86    /// Segment manager — workers read trained structures from its ArcSwap (lock-free).
87    segment_manager: Arc<crate::merge::SegmentManager<D>>,
88    /// Segments built by workers, collected by `prepare_commit()`. Sync mutex for sub-μs push.
89    built_segments: parking_lot::Mutex<Vec<(String, u32)>>,
90
91    // === Worker lifecycle synchronization ===
92    // Workers survive across commits. On prepare_commit the channel is closed;
93    // workers flush their builders, increment flush_count, then wait on
94    // resume_cvar for a new receiver. commit/abort creates a fresh channel
95    // and wakes them.
96    /// Number of workers that have completed their flush.
97    flush_count: AtomicUsize,
98    /// Mutex + condvar for prepare_commit to wait on all workers flushed.
99    flush_mutex: parking_lot::Mutex<()>,
100    flush_cvar: parking_lot::Condvar,
101    /// Holds the new channel receiver after commit/abort. Workers clone from this.
102    resume_receiver: parking_lot::Mutex<Option<async_channel::Receiver<Document>>>,
103    /// Monotonically increasing epoch, bumped by each resume_workers call.
104    /// Workers compare against their local epoch to avoid re-cloning a stale receiver.
105    resume_epoch: AtomicUsize,
106    /// Condvar for workers to wait for resume (new channel) or shutdown.
107    resume_cvar: parking_lot::Condvar,
108    /// When true, workers should exit permanently (IndexWriter dropped).
109    shutdown: AtomicBool,
110    /// Total number of worker threads.
111    num_workers: usize,
112}
113
114impl<D: DirectoryWriter + 'static> IndexWriter<D> {
115    /// Create a new index in the directory
116    pub async fn create(directory: D, schema: Schema, config: IndexConfig) -> Result<Self> {
117        Self::create_with_config(directory, schema, config, SegmentBuilderConfig::default()).await
118    }
119
120    /// Create a new index with custom builder config
121    pub async fn create_with_config(
122        directory: D,
123        schema: Schema,
124        config: IndexConfig,
125        builder_config: SegmentBuilderConfig,
126    ) -> Result<Self> {
127        let directory = Arc::new(directory);
128        let schema = Arc::new(schema);
129        // Directory-layer metrics (cold writes, lazy reads) carry the index label
130        directory.set_index_label(schema.index_label());
131        let metadata = super::IndexMetadata::new((*schema).clone());
132
133        let segment_manager = Arc::new(crate::merge::SegmentManager::new(
134            Arc::clone(&directory),
135            Arc::clone(&schema),
136            metadata,
137            config.merge_policy.clone_box(),
138            config.term_cache_blocks,
139            config.max_concurrent_merges,
140        ));
141        segment_manager.update_metadata(|_| {}).await?;
142
143        Ok(Self::new_with_parts(
144            directory,
145            schema,
146            config,
147            builder_config,
148            segment_manager,
149        ))
150    }
151
152    /// Open an existing index for writing
153    pub async fn open(directory: D, config: IndexConfig) -> Result<Self> {
154        Self::open_with_config(directory, config, SegmentBuilderConfig::default()).await
155    }
156
157    /// Open an existing index with custom builder config
158    pub async fn open_with_config(
159        directory: D,
160        config: IndexConfig,
161        builder_config: SegmentBuilderConfig,
162    ) -> Result<Self> {
163        let directory = Arc::new(directory);
164        let metadata = super::IndexMetadata::load(directory.as_ref()).await?;
165        let schema = Arc::new(metadata.schema.clone());
166        // Directory-layer metrics (cold writes, lazy reads) carry the index label
167        directory.set_index_label(schema.index_label());
168
169        let segment_manager = Arc::new(crate::merge::SegmentManager::new(
170            Arc::clone(&directory),
171            Arc::clone(&schema),
172            metadata,
173            config.merge_policy.clone_box(),
174            config.term_cache_blocks,
175            config.max_concurrent_merges,
176        ));
177        segment_manager.load_and_publish_trained().await;
178
179        Ok(Self::new_with_parts(
180            directory,
181            schema,
182            config,
183            builder_config,
184            segment_manager,
185        ))
186    }
187
188    /// Create an IndexWriter from an existing Index.
189    /// Shares the SegmentManager for consistent segment lifecycle management.
190    pub fn from_index(index: &super::Index<D>) -> Self {
191        Self::new_with_parts(
192            Arc::clone(&index.directory),
193            Arc::clone(&index.schema),
194            index.config.clone(),
195            SegmentBuilderConfig::default(),
196            Arc::clone(&index.segment_manager),
197        )
198    }
199
200    // ========================================================================
201    // Construction + pipeline management
202    // ========================================================================
203
204    /// Common construction: creates worker state, spawns workers, assembles `Self`.
205    fn new_with_parts(
206        directory: Arc<D>,
207        schema: Arc<Schema>,
208        config: IndexConfig,
209        builder_config: SegmentBuilderConfig,
210        segment_manager: Arc<crate::merge::SegmentManager<D>>,
211    ) -> Self {
212        // Auto-configure tokenizers from schema for all text fields
213        let registry = crate::tokenizer::TokenizerRegistry::new();
214        let mut tokenizers = FxHashMap::default();
215        for (field, entry) in schema.fields() {
216            if matches!(entry.field_type, crate::dsl::FieldType::Text)
217                && let Some(ref tok_name) = entry.tokenizer
218                && let Some(tok) = registry.get(tok_name)
219            {
220                tokenizers.insert(field, tok);
221            }
222        }
223
224        let num_workers = config.num_indexing_threads.max(1);
225        let worker_state = Arc::new(WorkerState {
226            directory: Arc::clone(&directory),
227            schema: Arc::clone(&schema),
228            builder_config,
229            tokenizers: parking_lot::RwLock::new(tokenizers),
230            memory_budget_per_worker: config.max_indexing_memory_bytes / num_workers,
231            segment_manager: Arc::clone(&segment_manager),
232            built_segments: parking_lot::Mutex::new(Vec::new()),
233            flush_count: AtomicUsize::new(0),
234            flush_mutex: parking_lot::Mutex::new(()),
235            flush_cvar: parking_lot::Condvar::new(),
236            resume_receiver: parking_lot::Mutex::new(None),
237            resume_epoch: AtomicUsize::new(0),
238            resume_cvar: parking_lot::Condvar::new(),
239            shutdown: AtomicBool::new(false),
240            num_workers,
241        });
242        let (doc_sender, workers) = Self::spawn_workers(&worker_state, num_workers);
243
244        Self {
245            directory,
246            schema,
247            config,
248            doc_sender,
249            workers,
250            worker_state,
251            segment_manager,
252            flushed_segments: Vec::new(),
253            primary_key_index: None,
254        }
255    }
256
257    fn spawn_workers(
258        worker_state: &Arc<WorkerState<D>>,
259        num_workers: usize,
260    ) -> (
261        async_channel::Sender<Document>,
262        Vec<std::thread::JoinHandle<()>>,
263    ) {
264        let (sender, receiver) = async_channel::bounded(PIPELINE_MAX_SIZE_IN_DOCS);
265        let handle = tokio::runtime::Handle::current();
266        let mut workers = Vec::with_capacity(num_workers);
267        for i in 0..num_workers {
268            let state = Arc::clone(worker_state);
269            let rx = receiver.clone();
270            let rt = handle.clone();
271            workers.push(
272                std::thread::Builder::new()
273                    .name(format!("index-worker-{}", i))
274                    .spawn(move || Self::worker_loop(state, rx, rt))
275                    .expect("failed to spawn index worker thread"),
276            );
277        }
278        (sender, workers)
279    }
280
281    /// Get the schema
282    pub fn schema(&self) -> &Schema {
283        &self.schema
284    }
285
286    /// Set tokenizer for a field.
287    /// Propagated to worker threads — takes effect for the next SegmentBuilder they create.
288    pub fn set_tokenizer<T: crate::tokenizer::Tokenizer>(&mut self, field: Field, tokenizer: T) {
289        self.worker_state
290            .tokenizers
291            .write()
292            .insert(field, Box::new(tokenizer));
293    }
294
295    /// Initialize primary key deduplication from committed segments.
296    ///
297    /// Tries to load a cached bloom filter from `pk_bloom.bin` first. If the
298    /// cache covers all current segments, the bloom is reused directly (fast
299    /// path). If new segments appeared since the cache was written, only their
300    /// keys are iterated (incremental). Falls back to a full rebuild when no
301    /// cache exists.
302    ///
303    /// Only loads fast-field data (text dictionaries) per segment — NOT full
304    /// `SegmentReader`s — to avoid duplicating dense/sparse index memory.
305    ///
306    /// The CPU-intensive bloom build is offloaded via `spawn_blocking` so it
307    /// does not block the tokio runtime.
308    ///
309    /// No-op if schema has no primary field.
310    pub async fn init_primary_key_dedup(&mut self) -> Result<()> {
311        use super::primary_key::{PK_BLOOM_FILE, deserialize_pk_bloom};
312
313        let field = match self.schema.primary_field() {
314            Some(f) => f,
315            None => return Ok(()),
316        };
317
318        let snapshot = self.segment_manager.acquire_snapshot().await;
319        let current_seg_ids: Vec<String> = snapshot.segment_ids().to_vec();
320
321        // Try to load persisted bloom filter.
322        let cached = match self
323            .directory
324            .open_read(std::path::Path::new(PK_BLOOM_FILE))
325            .await
326        {
327            Ok(handle) => {
328                let data = handle.read_bytes_range(0..handle.len()).await;
329                match data {
330                    Ok(bytes) => deserialize_pk_bloom(bytes.as_slice()),
331                    Err(_) => None,
332                }
333            }
334            Err(_) => None,
335        };
336
337        // Load lightweight fast-field data for all segments concurrently.
338        let load_futures: Vec<_> = current_seg_ids
339            .iter()
340            .map(|seg_id_str| {
341                let seg_id_str = seg_id_str.clone();
342                let dir = self.directory.as_ref();
343                let schema = Arc::clone(&self.schema);
344                async move { load_pk_segment_data(dir, &seg_id_str, &schema).await }
345            })
346            .collect();
347        let all_data = futures::future::try_join_all(load_futures).await?;
348
349        if let Some((persisted_seg_ids, bloom)) = cached {
350            // Partition: old segments (covered by bloom) first, new segments at end.
351            let mut pk_data = Vec::with_capacity(all_data.len());
352            let mut new_data = Vec::new();
353            for d in all_data {
354                if persisted_seg_ids.contains(&d.segment_id) {
355                    pk_data.push(d);
356                } else {
357                    new_data.push(d);
358                }
359            }
360            let needs_persist = !new_data.is_empty();
361            let new_start = pk_data.len();
362            pk_data.extend(new_data);
363
364            let pk_index = if new_start == pk_data.len() {
365                // Fast path: all segments covered by cache.
366                super::primary_key::PrimaryKeyIndex::from_persisted(
367                    field,
368                    bloom,
369                    pk_data,
370                    &[],
371                    snapshot,
372                )
373            } else {
374                // Incremental: only iterate new segments' keys.
375                tokio::task::spawn_blocking(move || {
376                    // Insert new segments' keys into the bloom, then construct
377                    // PrimaryKeyIndex with the pre-populated bloom.
378                    let mut bloom = bloom;
379                    let mut added = 0usize;
380                    let num_new = pk_data.len() - new_start;
381                    for data in &pk_data[new_start..] {
382                        if let Some(ff) = data.fast_fields.get(&field.0)
383                            && let Some(dict) = ff.text_dict()
384                        {
385                            for key in dict.iter() {
386                                bloom.insert(key.as_bytes());
387                                added += 1;
388                            }
389                        }
390                    }
391                    if added > 0 {
392                        log::info!(
393                            "[primary_key] bloom: added {} keys from {} new segment(s)",
394                            added,
395                            num_new,
396                        );
397                    }
398                    super::primary_key::PrimaryKeyIndex::from_persisted(
399                        field,
400                        bloom,
401                        pk_data,
402                        &[],
403                        snapshot,
404                    )
405                })
406                .await
407                .map_err(|e| Error::Internal(format!("spawn_blocking failed: {}", e)))?
408            };
409
410            if needs_persist {
411                self.persist_pk_bloom(&pk_index, &current_seg_ids).await;
412            }
413
414            self.primary_key_index = Some(pk_index);
415        } else {
416            // No cache — full rebuild, offloaded to blocking thread.
417            let pk_index = tokio::task::spawn_blocking(move || {
418                super::primary_key::PrimaryKeyIndex::new(field, all_data, snapshot)
419            })
420            .await
421            .map_err(|e| Error::Internal(format!("spawn_blocking failed: {}", e)))?;
422
423            self.persist_pk_bloom(&pk_index, &current_seg_ids).await;
424            self.primary_key_index = Some(pk_index);
425        }
426
427        Ok(())
428    }
429
430    /// Persist the primary-key bloom filter to `pk_bloom.bin`.
431    /// Best-effort: errors are logged but not propagated.
432    async fn persist_pk_bloom(
433        &self,
434        pk_index: &super::primary_key::PrimaryKeyIndex,
435        segment_ids: &[String],
436    ) {
437        use super::primary_key::{PK_BLOOM_FILE, serialize_pk_bloom};
438
439        let bloom_bytes = pk_index.bloom_to_bytes();
440        let data = serialize_pk_bloom(segment_ids, &bloom_bytes);
441        if let Err(e) = self
442            .directory
443            .write(std::path::Path::new(PK_BLOOM_FILE), &data)
444            .await
445        {
446            log::warn!("[primary_key] failed to persist bloom cache: {}", e);
447        }
448    }
449
450    /// Add a document to the indexing queue (sync, O(1), lock-free).
451    ///
452    /// `Document` is moved into the channel (zero-copy). Workers compete to pull it.
453    /// Returns `Error::QueueFull` when the queue is at capacity — caller must back off.
454    pub fn add_document(&self, doc: Document) -> Result<()> {
455        if let Some(ref pk_index) = self.primary_key_index {
456            pk_index.check_and_insert(&doc)?;
457        }
458        match self.doc_sender.try_send(doc) {
459            Ok(()) => Ok(()),
460            Err(async_channel::TrySendError::Full(doc)) => {
461                // Roll back PK registration so the caller can retry later
462                if let Some(ref pk_index) = self.primary_key_index {
463                    pk_index.rollback_uncommitted_key(&doc);
464                }
465                Err(Error::QueueFull)
466            }
467            Err(async_channel::TrySendError::Closed(doc)) => {
468                // Roll back PK registration for defense-in-depth
469                if let Some(ref pk_index) = self.primary_key_index {
470                    pk_index.rollback_uncommitted_key(&doc);
471                }
472                Err(Error::Internal("Document channel closed".into()))
473            }
474        }
475    }
476
477    /// Add multiple documents to the indexing queue.
478    ///
479    /// Returns the number of documents successfully queued. Stops at the first
480    /// `QueueFull` and returns the count queued so far.
481    pub fn add_documents(&self, documents: Vec<Document>) -> Result<usize> {
482        let total = documents.len();
483        for (i, doc) in documents.into_iter().enumerate() {
484            match self.add_document(doc) {
485                Ok(()) => {}
486                Err(Error::QueueFull) => return Ok(i),
487                Err(e) => return Err(e),
488            }
489        }
490        Ok(total)
491    }
492
493    // ========================================================================
494    // Worker loop
495    // ========================================================================
496
497    /// Worker loop — runs on a dedicated OS thread, survives across commits.
498    ///
499    /// Outer loop: each iteration processes one commit cycle.
500    ///   Inner loop: pull documents from MPMC queue, index them, build segments
501    ///   when memory budget is exceeded.
502    ///   On channel close (prepare_commit): flush current builder, signal
503    ///   flush_count, wait for resume with new receiver.
504    ///   On shutdown (Drop): exit permanently.
505    fn worker_loop(
506        state: Arc<WorkerState<D>>,
507        initial_receiver: async_channel::Receiver<Document>,
508        handle: tokio::runtime::Handle,
509    ) {
510        let mut receiver = initial_receiver;
511        let mut my_epoch = 0usize;
512
513        loop {
514            // Wrap the recv+build phase in catch_unwind so a panic doesn't
515            // prevent flush_count from being signaled (which would hang
516            // prepare_commit forever).
517            let build_result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
518                let mut builder: Option<SegmentBuilder> = None;
519
520                while let Ok(doc) = receiver.recv_blocking() {
521                    // Initialize builder if needed
522                    if builder.is_none() {
523                        match SegmentBuilder::new(
524                            Arc::clone(&state.schema),
525                            state.builder_config.clone(),
526                        ) {
527                            Ok(mut b) => {
528                                for (field, tokenizer) in state.tokenizers.read().iter() {
529                                    b.set_tokenizer(*field, tokenizer.clone_box());
530                                }
531                                builder = Some(b);
532                            }
533                            Err(e) => {
534                                log::error!("Failed to create segment builder: {:?}", e);
535                                continue;
536                            }
537                        }
538                    }
539
540                    let b = builder.as_mut().unwrap();
541                    if let Err(e) = b.add_document(doc) {
542                        log::error!("Failed to index document: {:?}", e);
543                        continue;
544                    }
545
546                    let builder_memory = b.estimated_memory_bytes();
547
548                    if b.num_docs() & 0x3FFF == 0 {
549                        log::debug!(
550                            "[indexing] docs={}, memory={:.2} MB, budget={:.2} MB",
551                            b.num_docs(),
552                            builder_memory as f64 / (1024.0 * 1024.0),
553                            state.memory_budget_per_worker as f64 / (1024.0 * 1024.0)
554                        );
555                    }
556
557                    // Require minimum 100 docs before flushing to avoid tiny segments
558                    const MIN_DOCS_BEFORE_FLUSH: u32 = 100;
559
560                    // Reserve 20% headroom for segment build overhead (vid_set,
561                    // VidLookup, postings_flat, grid_entries). These temporary
562                    // allocations exist alongside the builder's data during build.
563                    let effective_budget = state.memory_budget_per_worker * 4 / 5;
564
565                    if builder_memory >= effective_budget && b.num_docs() >= MIN_DOCS_BEFORE_FLUSH {
566                        log::info!(
567                            "[indexing] memory budget reached, building segment: \
568                             docs={}, memory={:.2} MB, budget={:.2} MB",
569                            b.num_docs(),
570                            builder_memory as f64 / (1024.0 * 1024.0),
571                            state.memory_budget_per_worker as f64 / (1024.0 * 1024.0),
572                        );
573                        let full_builder = builder.take().unwrap();
574                        Self::build_segment_inline(&state, full_builder, &handle);
575                    }
576                }
577
578                // Channel closed — flush current builder
579                if let Some(b) = builder.take()
580                    && b.num_docs() > 0
581                {
582                    Self::build_segment_inline(&state, b, &handle);
583                }
584            }));
585
586            if build_result.is_err() {
587                log::error!(
588                    "[worker] panic during indexing cycle — documents in this cycle may be lost"
589                );
590            }
591
592            // Signal flush completion (always, even after panic — prevents
593            // prepare_commit from hanging)
594            let prev = state.flush_count.fetch_add(1, Ordering::Release);
595            if prev + 1 == state.num_workers {
596                // Last worker — wake prepare_commit
597                let _lock = state.flush_mutex.lock();
598                state.flush_cvar.notify_one();
599            }
600
601            // Wait for resume (new channel) or shutdown.
602            // Check resume_epoch to avoid re-cloning a stale receiver from
603            // a previous cycle.
604            {
605                let mut lock = state.resume_receiver.lock();
606                loop {
607                    if state.shutdown.load(Ordering::Acquire) {
608                        return;
609                    }
610                    let current_epoch = state.resume_epoch.load(Ordering::Acquire);
611                    if current_epoch > my_epoch
612                        && let Some(rx) = lock.as_ref()
613                    {
614                        receiver = rx.clone();
615                        my_epoch = current_epoch;
616                        break;
617                    }
618                    state.resume_cvar.wait(&mut lock);
619                }
620            }
621        }
622    }
623
624    /// Build a segment on the worker thread. Uses `Handle::block_on()` to bridge
625    /// into async context for I/O (streaming writers). CPU work (rayon) stays on
626    /// the worker thread / rayon pool.
627    fn build_segment_inline(
628        state: &WorkerState<D>,
629        builder: SegmentBuilder,
630        handle: &tokio::runtime::Handle,
631    ) {
632        let segment_id = SegmentId::new();
633        let segment_hex = segment_id.to_hex();
634        let trained = state.segment_manager.trained();
635        let doc_count = builder.num_docs();
636        let build_start = std::time::Instant::now();
637
638        log::info!(
639            "[segment_build] segment_id={} doc_count={} ann={}",
640            segment_hex,
641            doc_count,
642            trained.is_some()
643        );
644
645        match handle.block_on(builder.build(
646            state.directory.as_ref(),
647            segment_id,
648            trained.as_deref(),
649        )) {
650            Ok(meta) if meta.num_docs > 0 => {
651                let duration_ms = build_start.elapsed().as_millis() as u64;
652                log::info!(
653                    "[segment_build_done] segment_id={} doc_count={} duration_ms={}",
654                    segment_hex,
655                    meta.num_docs,
656                    duration_ms,
657                );
658                state
659                    .built_segments
660                    .lock()
661                    .push((segment_hex, meta.num_docs));
662            }
663            Ok(_) => {}
664            Err(e) => {
665                log::error!(
666                    "[segment_build_failed] segment_id={} error={:?}",
667                    segment_hex,
668                    e
669                );
670            }
671        }
672    }
673
674    // ========================================================================
675    // Public API — commit, merge, etc.
676    // ========================================================================
677
678    /// Check merge policy and spawn a background merge if needed.
679    pub async fn maybe_merge(&self) {
680        self.segment_manager.maybe_merge().await;
681    }
682
683    /// Abort all in-flight merge tasks without waiting for completion.
684    pub async fn abort_merges(&self) {
685        self.segment_manager.abort_merges().await;
686    }
687
688    /// Wait for the in-flight background merge to complete (if any).
689    pub async fn wait_for_merging_thread(&self) {
690        self.segment_manager.wait_for_merging_thread().await;
691    }
692
693    /// Wait for all eligible merges to complete, including cascading merges.
694    pub async fn wait_for_all_merges(&self) {
695        self.segment_manager.wait_for_all_merges().await;
696    }
697
698    /// Get the segment tracker for sharing with readers.
699    pub fn tracker(&self) -> std::sync::Arc<crate::segment::SegmentTracker> {
700        self.segment_manager.tracker()
701    }
702
703    /// Acquire a snapshot of current segments for reading.
704    pub async fn acquire_snapshot(&self) -> crate::segment::SegmentSnapshot {
705        self.segment_manager.acquire_snapshot().await
706    }
707
708    /// Clean up orphan segment files not registered in metadata.
709    pub async fn cleanup_orphan_segments(&self) -> Result<usize> {
710        self.segment_manager.cleanup_orphan_segments().await
711    }
712
713    /// Prepare commit — signal workers to flush, wait for completion, collect segments.
714    ///
715    /// All documents sent via `add_document` before this call are guaranteed
716    /// to be written to segment files on disk. Segments are NOT yet registered
717    /// in metadata — call `PreparedCommit::commit()` for that.
718    ///
719    /// Workers are NOT destroyed — they flush their builders and wait for
720    /// `resume_workers()` to give them a new channel.
721    ///
722    /// `add_document` will return `Closed` error until commit/abort resumes workers.
723    pub async fn prepare_commit(&mut self) -> Result<PreparedCommit<'_, D>> {
724        // 1. Close channel → workers drain remaining docs and flush builders
725        self.doc_sender.close();
726
727        // Wake any workers still waiting on resume_cvar from previous cycle.
728        // They'll clone the stale receiver, enter recv_blocking, get Err
729        // immediately (sender already closed), flush, and signal completion.
730        self.worker_state.resume_cvar.notify_all();
731
732        // 2. Wait for all workers to complete their flush (via spawn_blocking
733        //    to avoid blocking the tokio runtime)
734        let state = Arc::clone(&self.worker_state);
735        let all_flushed = tokio::task::spawn_blocking(move || {
736            let mut lock = state.flush_mutex.lock();
737            let deadline = std::time::Instant::now() + std::time::Duration::from_secs(300);
738            while state.flush_count.load(Ordering::Acquire) < state.num_workers {
739                let remaining = deadline.saturating_duration_since(std::time::Instant::now());
740                if remaining.is_zero() {
741                    log::error!(
742                        "[prepare_commit] timed out waiting for workers: {}/{} flushed",
743                        state.flush_count.load(Ordering::Acquire),
744                        state.num_workers
745                    );
746                    return false;
747                }
748                state.flush_cvar.wait_for(&mut lock, remaining);
749            }
750            true
751        })
752        .await
753        .map_err(|e| Error::Internal(format!("Failed to wait for workers: {}", e)))?;
754
755        if !all_flushed {
756            // Resume workers so the system isn't stuck, then return error
757            self.resume_workers();
758            return Err(Error::Internal(format!(
759                "prepare_commit timed out: {}/{} workers flushed",
760                self.worker_state.flush_count.load(Ordering::Acquire),
761                self.worker_state.num_workers
762            )));
763        }
764
765        // 3. Collect built segments
766        let built = std::mem::take(&mut *self.worker_state.built_segments.lock());
767        self.flushed_segments.extend(built);
768
769        Ok(PreparedCommit {
770            writer: self,
771            is_resolved: false,
772        })
773    }
774
775    /// Commit (convenience): prepare_commit + commit in one call.
776    ///
777    /// Guarantees all prior `add_document` calls are committed.
778    /// Vector training is decoupled — call `build_vector_index()` manually.
779    pub async fn commit(&mut self) -> Result<bool> {
780        self.prepare_commit().await?.commit().await
781    }
782
783    /// Force merge all segments into one.
784    pub async fn force_merge(&mut self) -> Result<()> {
785        self.prepare_commit().await?.commit().await?;
786        self.segment_manager.force_merge().await
787    }
788
789    /// Reorder all segments via Recursive Graph Bisection (BP) for better BMP pruning.
790    ///
791    /// Each segment is individually rebuilt with record-level BP reordering:
792    /// ordinals are shuffled across blocks so that similar content clusters tightly.
793    pub async fn reorder(&mut self) -> Result<()> {
794        self.prepare_commit().await?.commit().await?;
795        self.segment_manager.reorder_segments().await
796    }
797
798    /// Get the segment manager (for background optimizer access).
799    pub fn segment_manager(&self) -> &Arc<crate::merge::SegmentManager<D>> {
800        &self.segment_manager
801    }
802
803    /// Resume workers with a fresh channel. Called after commit or abort.
804    ///
805    /// Workers are already alive — just give them a new channel and wake them.
806    /// If the tokio runtime has shut down (e.g., program exit), this is a no-op.
807    fn resume_workers(&mut self) {
808        if tokio::runtime::Handle::try_current().is_err() {
809            // Runtime is gone — signal permanent shutdown so workers don't
810            // hang forever on resume_cvar.
811            self.worker_state.shutdown.store(true, Ordering::Release);
812            self.worker_state.resume_cvar.notify_all();
813            return;
814        }
815
816        // Reset flush count for next cycle
817        self.worker_state.flush_count.store(0, Ordering::Release);
818
819        // Create new channel
820        let (sender, receiver) = async_channel::bounded(PIPELINE_MAX_SIZE_IN_DOCS);
821        self.doc_sender = sender;
822
823        // Set new receiver, bump epoch, and wake all workers
824        {
825            let mut lock = self.worker_state.resume_receiver.lock();
826            *lock = Some(receiver);
827        }
828        self.worker_state
829            .resume_epoch
830            .fetch_add(1, Ordering::Release);
831        self.worker_state.resume_cvar.notify_all();
832    }
833
834    // Vector index methods (build_vector_index, etc.) are in vector_builder.rs
835}
836
837impl<D: DirectoryWriter + 'static> Drop for IndexWriter<D> {
838    fn drop(&mut self) {
839        // 1. Signal permanent shutdown
840        self.worker_state.shutdown.store(true, Ordering::Release);
841        // 2. Close channel to wake workers blocked on recv_blocking
842        self.doc_sender.close();
843        // 3. Wake workers that might be waiting on resume_cvar
844        self.worker_state.resume_cvar.notify_all();
845        // 4. Join worker threads
846        for w in std::mem::take(&mut self.workers) {
847            let _ = w.join();
848        }
849    }
850}
851
852/// A prepared commit that can be finalized or aborted.
853///
854/// Two-phase commit guard. Between `prepare_commit()` and
855/// `commit()`/`abort()`, segments are on disk but NOT in metadata.
856/// Dropping without calling either will auto-abort (discard segments,
857/// respawn workers).
858pub struct PreparedCommit<'a, D: DirectoryWriter + 'static> {
859    writer: &'a mut IndexWriter<D>,
860    is_resolved: bool,
861}
862
863impl<'a, D: DirectoryWriter + 'static> PreparedCommit<'a, D> {
864    /// Finalize: register segments in metadata, evaluate merge policy, resume workers.
865    ///
866    /// Returns `true` if new segments were committed, `false` if nothing changed.
867    pub async fn commit(mut self) -> Result<bool> {
868        self.is_resolved = true;
869        let segments = std::mem::take(&mut self.writer.flushed_segments);
870
871        // Fast path: nothing to commit
872        if segments.is_empty() {
873            log::debug!("[commit] no segments to commit, skipping");
874            self.writer.resume_workers();
875            return Ok(false);
876        }
877
878        self.writer.segment_manager.commit(segments).await?;
879
880        // Refresh primary key index: only load fast fields for NEW segments.
881        if let Some(ref mut pk_index) = self.writer.primary_key_index {
882            let snapshot = self.writer.segment_manager.acquire_snapshot().await;
883            let existing_ids: std::collections::HashSet<&str> =
884                pk_index.committed_segment_ids().collect();
885
886            // Only load fast fields for segments not already held.
887            let load_futures: Vec<_> = snapshot
888                .segment_ids()
889                .iter()
890                .filter(|id| !existing_ids.contains(id.as_str()))
891                .map(|seg_id_str| {
892                    let seg_id_str = seg_id_str.clone();
893                    let dir = self.writer.directory.as_ref();
894                    let schema = Arc::clone(&self.writer.schema);
895                    async move { load_pk_segment_data(dir, &seg_id_str, &schema).await }
896                })
897                .collect();
898            let new_data = futures::future::try_join_all(load_futures).await?;
899
900            let seg_ids: Vec<String> = snapshot.segment_ids().to_vec();
901            pk_index.refresh_incremental(new_data, snapshot);
902
903            // Persist bloom cache (extract bytes to avoid borrow conflict).
904            let bloom_bytes = pk_index.bloom_to_bytes();
905            let data = super::primary_key::serialize_pk_bloom(&seg_ids, &bloom_bytes);
906            if let Err(e) = self
907                .writer
908                .directory
909                .write(
910                    std::path::Path::new(super::primary_key::PK_BLOOM_FILE),
911                    &data,
912                )
913                .await
914            {
915                log::warn!("[primary_key] failed to persist bloom cache: {}", e);
916            }
917        }
918
919        self.writer.segment_manager.maybe_merge().await;
920        self.writer.resume_workers();
921        Ok(true)
922    }
923
924    /// Abort: discard prepared segments, resume workers.
925    /// Segment files become orphans (cleaned up by `cleanup_orphan_segments`).
926    pub fn abort(mut self) {
927        self.is_resolved = true;
928        self.writer.flushed_segments.clear();
929        if let Some(ref mut pk_index) = self.writer.primary_key_index {
930            pk_index.clear_uncommitted();
931        }
932        self.writer.resume_workers();
933    }
934}
935
936impl<D: DirectoryWriter + 'static> Drop for PreparedCommit<'_, D> {
937    fn drop(&mut self) {
938        if !self.is_resolved {
939            log::warn!("PreparedCommit dropped without commit/abort — auto-aborting");
940            self.writer.flushed_segments.clear();
941            if let Some(ref mut pk_index) = self.writer.primary_key_index {
942                pk_index.clear_uncommitted();
943            }
944            self.writer.resume_workers();
945        }
946    }
947}
948
949/// Load only fast-field data for a segment (lightweight alternative to full SegmentReader).
950async fn load_pk_segment_data<D: crate::directories::Directory>(
951    dir: &D,
952    seg_id_str: &str,
953    schema: &Arc<crate::dsl::Schema>,
954) -> Result<super::primary_key::PkSegmentData> {
955    let seg_id = crate::segment::SegmentId::from_hex(seg_id_str)
956        .ok_or_else(|| Error::Internal(format!("Invalid segment id: {}", seg_id_str)))?;
957    let files = crate::segment::SegmentFiles::new(seg_id.0);
958    let fast_fields =
959        crate::segment::reader::loader::load_fast_fields_file(dir, &files, schema).await?;
960    Ok(super::primary_key::PkSegmentData {
961        segment_id: seg_id_str.to_string(),
962        fast_fields,
963    })
964}