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. Each item
73    /// owns an active-operation guard, so orphan sweeping cannot delete it.
74    flushed_segments: Vec<PreparedSegment<D>>,
75    /// Primary key dedup index (None if schema has no primary field)
76    primary_key_index: Option<super::primary_key::PrimaryKeyIndex>,
77}
78
79/// Shared state for worker threads.
80struct WorkerState<D: DirectoryWriter + 'static> {
81    directory: Arc<D>,
82    schema: Arc<Schema>,
83    builder_config: SegmentBuilderConfig,
84    tokenizers: parking_lot::RwLock<FxHashMap<Field, BoxedTokenizer>>,
85    /// Fixed per-worker memory budget (bytes). When a builder exceeds this, segment is built.
86    memory_budget_per_worker: usize,
87    /// Segment manager — workers read trained structures from its ArcSwap (lock-free).
88    segment_manager: Arc<crate::merge::SegmentManager<D>>,
89    /// Segments built by workers, collected by `prepare_commit()`. Their RAII
90    /// guards protect both in-progress and completed-uncommitted files.
91    built_segments: parking_lot::Mutex<Vec<PreparedSegment<D>>>,
92    /// First failure in the current flush generation. Worker-side indexing is
93    /// asynchronous, so `prepare_commit` is the only sound place to surface
94    /// it to the caller. A failed generation is aborted as a unit; publishing
95    /// only its successful segments would silently lose documents.
96    cycle_error: parking_lot::Mutex<Option<String>>,
97    cycle_failed: AtomicBool,
98
99    // === Worker lifecycle synchronization ===
100    // Workers survive across commits. On prepare_commit the channel is closed;
101    // workers flush their builders, increment flush_count, then wait on
102    // resume_cvar for a new receiver. commit/abort creates a fresh channel
103    // and wakes them.
104    /// Number of workers that have completed their flush.
105    flush_count: AtomicUsize,
106    /// Mutex + condvar for prepare_commit to wait on all workers flushed.
107    flush_mutex: parking_lot::Mutex<()>,
108    flush_cvar: parking_lot::Condvar,
109    /// Holds the new channel receiver after commit/abort. Workers clone from this.
110    resume_receiver: parking_lot::Mutex<Option<async_channel::Receiver<Document>>>,
111    /// Monotonically increasing epoch, bumped by each resume_workers call.
112    /// Workers compare against their local epoch to avoid re-cloning a stale receiver.
113    resume_epoch: AtomicUsize,
114    /// Condvar for workers to wait for resume (new channel) or shutdown.
115    resume_cvar: parking_lot::Condvar,
116    /// When true, workers should exit permanently (IndexWriter dropped).
117    shutdown: AtomicBool,
118    /// Total number of worker threads.
119    num_workers: usize,
120}
121
122/// A completed indexing segment that has not been published in metadata yet.
123///
124/// `operation` is intentionally data, not a side-channel set update: moving
125/// this value through worker → prepared commit → commit/abort moves lifecycle
126/// ownership with it, and every unwind/drop path releases ownership safely.
127struct PreparedSegment<D: DirectoryWriter + 'static> {
128    id: String,
129    segment_id: SegmentId,
130    num_docs: u32,
131    segment_manager: Arc<crate::merge::SegmentManager<D>>,
132    operation: Option<crate::merge::SegmentOperationGuard>,
133    runtime: tokio::runtime::Handle,
134    published: bool,
135}
136
137impl<D: DirectoryWriter + 'static> PreparedSegment<D> {
138    fn metadata_entry(&self) -> (String, u32) {
139        (self.id.clone(), self.num_docs)
140    }
141
142    fn mark_published(&mut self) {
143        self.published = true;
144        // Metadata + SegmentTracker are now the durable lifecycle owners.
145        drop(self.operation.take());
146    }
147}
148
149impl<D: DirectoryWriter + 'static> WorkerState<D> {
150    fn record_cycle_error(&self, error: impl Into<String>) {
151        let mut first_error = self.cycle_error.lock();
152        if first_error.is_none() {
153            *first_error = Some(error.into());
154        }
155        drop(first_error);
156        self.cycle_failed.store(true, Ordering::Release);
157    }
158}
159
160impl<D: DirectoryWriter + 'static> Drop for PreparedSegment<D> {
161    fn drop(&mut self) {
162        if self.published {
163            return;
164        }
165        let Some(operation) = self.operation.take() else {
166            return;
167        };
168        self.segment_manager.schedule_unpublished_segment_cleanup(
169            self.segment_id,
170            operation,
171            self.runtime.clone(),
172        );
173    }
174}
175
176impl<D: DirectoryWriter + 'static> IndexWriter<D> {
177    /// Create a new index in the directory
178    pub async fn create(directory: D, schema: Schema, config: IndexConfig) -> Result<Self> {
179        Self::create_with_config(directory, schema, config, SegmentBuilderConfig::default()).await
180    }
181
182    /// Create a new index with custom builder config
183    pub async fn create_with_config(
184        directory: D,
185        schema: Schema,
186        config: IndexConfig,
187        builder_config: SegmentBuilderConfig,
188    ) -> Result<Self> {
189        let directory = Arc::new(directory);
190        let schema = Arc::new(schema);
191        // Directory-layer metrics (cold writes, lazy reads) carry the index label
192        directory.set_index_label(schema.index_label());
193        let metadata = super::IndexMetadata::new((*schema).clone());
194
195        let segment_manager = Arc::new(crate::merge::SegmentManager::new(
196            Arc::clone(&directory),
197            Arc::clone(&schema),
198            metadata,
199            config.merge_policy.clone_box(),
200            config.term_cache_blocks,
201            config.max_concurrent_merges,
202            Arc::clone(&config.background_merge_permits),
203            config.merge_bp_time_budget,
204            config.bp_memory_budget_bytes,
205            Arc::clone(&config.background_reorder_permits),
206            config.background_reorder_pool.clone(),
207        ));
208        segment_manager.update_metadata(|_| {}).await?;
209
210        Ok(Self::new_with_parts(
211            directory,
212            schema,
213            config,
214            builder_config,
215            segment_manager,
216        ))
217    }
218
219    /// Open an existing index for exclusive writing.
220    ///
221    /// Multiple independent writers for the same directory are unsupported:
222    /// this path removes crash-leftover outputs before starting its workers.
223    /// Use [`Index::writer`](super::Index::writer) to share lifecycle state
224    /// with an already-open search index.
225    pub async fn open(directory: D, config: IndexConfig) -> Result<Self> {
226        Self::open_with_config(directory, config, SegmentBuilderConfig::default()).await
227    }
228
229    /// Open an existing index with custom builder config
230    pub async fn open_with_config(
231        directory: D,
232        config: IndexConfig,
233        builder_config: SegmentBuilderConfig,
234    ) -> Result<Self> {
235        let directory = Arc::new(directory);
236        let metadata = super::IndexMetadata::load(directory.as_ref()).await?;
237        let schema = Arc::new(metadata.schema.clone());
238        // Directory-layer metrics (cold writes, lazy reads) carry the index label
239        directory.set_index_label(schema.index_label());
240
241        let segment_manager = Arc::new(crate::merge::SegmentManager::new(
242            Arc::clone(&directory),
243            Arc::clone(&schema),
244            metadata,
245            config.merge_policy.clone_box(),
246            config.term_cache_blocks,
247            config.max_concurrent_merges,
248            Arc::clone(&config.background_merge_permits),
249            config.merge_bp_time_budget,
250            config.bp_memory_budget_bytes,
251            Arc::clone(&config.background_reorder_permits),
252            config.background_reorder_pool.clone(),
253        ));
254        let swept = segment_manager.cleanup_orphan_segments().await?;
255        if swept > 0 {
256            log::warn!(
257                "[segment_cleanup] swept {} orphan segment(s) while opening writer",
258                swept
259            );
260        }
261        segment_manager.load_and_publish_trained().await;
262
263        Ok(Self::new_with_parts(
264            directory,
265            schema,
266            config,
267            builder_config,
268            segment_manager,
269        ))
270    }
271
272    /// Create an IndexWriter from an existing Index.
273    /// Shares the SegmentManager for consistent segment lifecycle management.
274    pub fn from_index(index: &super::Index<D>) -> Self {
275        Self::new_with_parts(
276            Arc::clone(&index.directory),
277            Arc::clone(&index.schema),
278            index.config.clone(),
279            SegmentBuilderConfig::default(),
280            Arc::clone(&index.segment_manager),
281        )
282    }
283
284    // ========================================================================
285    // Construction + pipeline management
286    // ========================================================================
287
288    /// Common construction: creates worker state, spawns workers, assembles `Self`.
289    fn new_with_parts(
290        directory: Arc<D>,
291        schema: Arc<Schema>,
292        config: IndexConfig,
293        builder_config: SegmentBuilderConfig,
294        segment_manager: Arc<crate::merge::SegmentManager<D>>,
295    ) -> Self {
296        // Auto-configure tokenizers from schema for all text fields
297        let registry = crate::tokenizer::TokenizerRegistry::new();
298        let mut tokenizers = FxHashMap::default();
299        for (field, entry) in schema.fields() {
300            if matches!(entry.field_type, crate::dsl::FieldType::Text)
301                && let Some(ref tok_name) = entry.tokenizer
302                && let Some(tok) = registry.get(tok_name)
303            {
304                tokenizers.insert(field, tok);
305            }
306        }
307
308        let num_workers = config.num_indexing_threads.max(1);
309        let worker_state = Arc::new(WorkerState {
310            directory: Arc::clone(&directory),
311            schema: Arc::clone(&schema),
312            builder_config,
313            tokenizers: parking_lot::RwLock::new(tokenizers),
314            memory_budget_per_worker: config.max_indexing_memory_bytes / num_workers,
315            segment_manager: Arc::clone(&segment_manager),
316            built_segments: parking_lot::Mutex::new(Vec::new()),
317            cycle_error: parking_lot::Mutex::new(None),
318            cycle_failed: AtomicBool::new(false),
319            flush_count: AtomicUsize::new(0),
320            flush_mutex: parking_lot::Mutex::new(()),
321            flush_cvar: parking_lot::Condvar::new(),
322            resume_receiver: parking_lot::Mutex::new(None),
323            resume_epoch: AtomicUsize::new(0),
324            resume_cvar: parking_lot::Condvar::new(),
325            shutdown: AtomicBool::new(false),
326            num_workers,
327        });
328        let (doc_sender, workers) = Self::spawn_workers(&worker_state, num_workers);
329
330        Self {
331            directory,
332            schema,
333            config,
334            doc_sender,
335            workers,
336            worker_state,
337            segment_manager,
338            flushed_segments: Vec::new(),
339            primary_key_index: None,
340        }
341    }
342
343    fn spawn_workers(
344        worker_state: &Arc<WorkerState<D>>,
345        num_workers: usize,
346    ) -> (
347        async_channel::Sender<Document>,
348        Vec<std::thread::JoinHandle<()>>,
349    ) {
350        let (sender, receiver) = async_channel::bounded(PIPELINE_MAX_SIZE_IN_DOCS);
351        let handle = tokio::runtime::Handle::current();
352        let mut workers = Vec::with_capacity(num_workers);
353        for i in 0..num_workers {
354            let state = Arc::clone(worker_state);
355            let rx = receiver.clone();
356            let rt = handle.clone();
357            workers.push(
358                std::thread::Builder::new()
359                    .name(format!("index-worker-{}", i))
360                    .spawn(move || Self::worker_loop(state, rx, rt))
361                    .expect("failed to spawn index worker thread"),
362            );
363        }
364        (sender, workers)
365    }
366
367    /// Get the schema
368    pub fn schema(&self) -> &Schema {
369        &self.schema
370    }
371
372    /// Set tokenizer for a field.
373    /// Propagated to worker threads — takes effect for the next SegmentBuilder they create.
374    pub fn set_tokenizer<T: crate::tokenizer::Tokenizer>(&mut self, field: Field, tokenizer: T) {
375        self.worker_state
376            .tokenizers
377            .write()
378            .insert(field, Box::new(tokenizer));
379    }
380
381    /// Initialize primary key deduplication from committed segments.
382    ///
383    /// Tries to load a cached bloom filter from `pk_bloom.bin` first. If the
384    /// cache covers all current segments, the bloom is reused directly (fast
385    /// path). If new segments appeared since the cache was written, only their
386    /// keys are iterated (incremental). Falls back to a full rebuild when no
387    /// cache exists.
388    ///
389    /// Only loads fast-field data (text dictionaries) per segment — NOT full
390    /// `SegmentReader`s — to avoid duplicating dense/sparse index memory.
391    ///
392    /// The CPU-intensive bloom build is offloaded via `spawn_blocking` so it
393    /// does not block the tokio runtime.
394    ///
395    /// No-op if schema has no primary field.
396    pub async fn init_primary_key_dedup(&mut self) -> Result<()> {
397        use super::primary_key::{PK_BLOOM_FILE, deserialize_pk_bloom};
398
399        let field = match self.schema.primary_field() {
400            Some(f) => f,
401            None => return Ok(()),
402        };
403
404        let snapshot = self.segment_manager.acquire_snapshot().await;
405        let current_seg_ids: Vec<String> = snapshot.segment_ids().to_vec();
406
407        // Try to load persisted bloom filter.
408        let cached = match self
409            .directory
410            .open_read(std::path::Path::new(PK_BLOOM_FILE))
411            .await
412        {
413            Ok(handle) => {
414                let data = handle.read_bytes_range(0..handle.len()).await;
415                match data {
416                    Ok(bytes) => deserialize_pk_bloom(bytes.as_slice()),
417                    Err(_) => None,
418                }
419            }
420            Err(_) => None,
421        };
422
423        // Load lightweight fast-field data for all segments concurrently.
424        let load_futures: Vec<_> = current_seg_ids
425            .iter()
426            .map(|seg_id_str| {
427                let seg_id_str = seg_id_str.clone();
428                let dir = self.directory.as_ref();
429                let schema = Arc::clone(&self.schema);
430                async move { load_pk_segment_data(dir, &seg_id_str, &schema).await }
431            })
432            .collect();
433        let all_data = futures::future::try_join_all(load_futures).await?;
434
435        if let Some((persisted_seg_ids, bloom)) = cached {
436            // Partition: old segments (covered by bloom) first, new segments at end.
437            let mut pk_data = Vec::with_capacity(all_data.len());
438            let mut new_data = Vec::new();
439            for d in all_data {
440                if persisted_seg_ids.contains(&d.segment_id) {
441                    pk_data.push(d);
442                } else {
443                    new_data.push(d);
444                }
445            }
446            let needs_persist = !new_data.is_empty();
447            let new_start = pk_data.len();
448            pk_data.extend(new_data);
449
450            let pk_index = if new_start == pk_data.len() {
451                // Fast path: all segments covered by cache.
452                super::primary_key::PrimaryKeyIndex::from_persisted(
453                    field,
454                    bloom,
455                    pk_data,
456                    &[],
457                    snapshot,
458                )
459            } else {
460                // Incremental: only iterate new segments' keys.
461                tokio::task::spawn_blocking(move || {
462                    // Insert new segments' keys into the bloom, then construct
463                    // PrimaryKeyIndex with the pre-populated bloom.
464                    let mut bloom = bloom;
465                    let mut added = 0usize;
466                    let num_new = pk_data.len() - new_start;
467                    for data in &pk_data[new_start..] {
468                        if let Some(ff) = data.fast_fields.get(&field.0)
469                            && let Some(dict) = ff.text_dict()
470                        {
471                            for key in dict.iter() {
472                                bloom.insert(key.as_bytes());
473                                added += 1;
474                            }
475                        }
476                    }
477                    if added > 0 {
478                        log::info!(
479                            "[primary_key] bloom: added {} keys from {} new segment(s)",
480                            added,
481                            num_new,
482                        );
483                    }
484                    super::primary_key::PrimaryKeyIndex::from_persisted(
485                        field,
486                        bloom,
487                        pk_data,
488                        &[],
489                        snapshot,
490                    )
491                })
492                .await
493                .map_err(|e| Error::Internal(format!("spawn_blocking failed: {}", e)))?
494            };
495
496            if needs_persist {
497                self.persist_pk_bloom(&pk_index, &current_seg_ids).await;
498            }
499
500            self.primary_key_index = Some(pk_index);
501        } else {
502            // No cache — full rebuild, offloaded to blocking thread.
503            let pk_index = tokio::task::spawn_blocking(move || {
504                super::primary_key::PrimaryKeyIndex::new(field, all_data, snapshot)
505            })
506            .await
507            .map_err(|e| Error::Internal(format!("spawn_blocking failed: {}", e)))?;
508
509            self.persist_pk_bloom(&pk_index, &current_seg_ids).await;
510            self.primary_key_index = Some(pk_index);
511        }
512
513        Ok(())
514    }
515
516    /// Persist the primary-key bloom filter to `pk_bloom.bin`.
517    /// Best-effort: errors are logged but not propagated.
518    async fn persist_pk_bloom(
519        &self,
520        pk_index: &super::primary_key::PrimaryKeyIndex,
521        segment_ids: &[String],
522    ) {
523        use super::primary_key::{PK_BLOOM_FILE, serialize_pk_bloom};
524
525        let bloom_bytes = pk_index.bloom_to_bytes();
526        let data = serialize_pk_bloom(segment_ids, &bloom_bytes);
527        if let Err(e) = self
528            .directory
529            .write(std::path::Path::new(PK_BLOOM_FILE), &data)
530            .await
531        {
532            log::warn!("[primary_key] failed to persist bloom cache: {}", e);
533        }
534    }
535
536    /// Add a document to the indexing queue (sync, O(1), lock-free).
537    ///
538    /// `Document` is moved into the channel (zero-copy). Workers compete to pull it.
539    /// Returns `Error::QueueFull` when the queue is at capacity — caller must back off.
540    pub fn add_document(&self, doc: Document) -> Result<()> {
541        if self.worker_state.shutdown.load(Ordering::Acquire) {
542            return Err(Error::IndexClosed);
543        }
544        if let Some(ref pk_index) = self.primary_key_index {
545            pk_index.check_and_insert(&doc)?;
546        }
547        match self.doc_sender.try_send(doc) {
548            Ok(()) => Ok(()),
549            Err(async_channel::TrySendError::Full(doc)) => {
550                // Roll back PK registration so the caller can retry later
551                if let Some(ref pk_index) = self.primary_key_index {
552                    pk_index.rollback_uncommitted_key(&doc);
553                }
554                Err(Error::QueueFull)
555            }
556            Err(async_channel::TrySendError::Closed(doc)) => {
557                // Roll back PK registration for defense-in-depth
558                if let Some(ref pk_index) = self.primary_key_index {
559                    pk_index.rollback_uncommitted_key(&doc);
560                }
561                Err(Error::Internal("Document channel closed".into()))
562            }
563        }
564    }
565
566    /// Add multiple documents to the indexing queue.
567    ///
568    /// Returns the number of documents successfully queued. Stops at the first
569    /// `QueueFull` and returns the count queued so far.
570    pub fn add_documents(&self, documents: Vec<Document>) -> Result<usize> {
571        let total = documents.len();
572        for (i, doc) in documents.into_iter().enumerate() {
573            match self.add_document(doc) {
574                Ok(()) => {}
575                Err(Error::QueueFull) => return Ok(i),
576                Err(e) => return Err(e),
577            }
578        }
579        Ok(total)
580    }
581
582    // ========================================================================
583    // Worker loop
584    // ========================================================================
585
586    /// Worker loop — runs on a dedicated OS thread, survives across commits.
587    ///
588    /// Outer loop: each iteration processes one commit cycle.
589    ///   Inner loop: pull documents from MPMC queue, index them, build segments
590    ///   when memory budget is exceeded.
591    ///   On channel close (prepare_commit): flush current builder, signal
592    ///   flush_count, wait for resume with new receiver.
593    ///   On shutdown (Drop): exit permanently.
594    fn worker_loop(
595        state: Arc<WorkerState<D>>,
596        initial_receiver: async_channel::Receiver<Document>,
597        handle: tokio::runtime::Handle,
598    ) {
599        let mut receiver = initial_receiver;
600        let mut my_epoch = 0usize;
601
602        loop {
603            // Wrap the recv+build phase in catch_unwind so a panic doesn't
604            // prevent flush_count from being signaled (which would hang
605            // prepare_commit forever).
606            let build_result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
607                let mut builder: Option<SegmentBuilder> = None;
608
609                while let Ok(doc) = receiver.recv_blocking() {
610                    if state.shutdown.load(Ordering::Acquire) {
611                        break;
612                    }
613                    // Another worker already invalidated this generation.
614                    // Drain the shared queue so prepare_commit can complete,
615                    // but do not spend CPU/RAM building outputs that must be
616                    // discarded transactionally.
617                    if state.cycle_failed.load(Ordering::Acquire) {
618                        continue;
619                    }
620                    // Initialize builder if needed
621                    if builder.is_none() {
622                        match SegmentBuilder::new(
623                            Arc::clone(&state.schema),
624                            state.builder_config.clone(),
625                        ) {
626                            Ok(mut b) => {
627                                for (field, tokenizer) in state.tokenizers.read().iter() {
628                                    b.set_tokenizer(*field, tokenizer.clone_box());
629                                }
630                                builder = Some(b);
631                            }
632                            Err(e) => {
633                                log::error!("Failed to create segment builder: {:?}", e);
634                                state.record_cycle_error(format!(
635                                    "failed to create segment builder: {e}"
636                                ));
637                                continue;
638                            }
639                        }
640                    }
641
642                    let b = builder.as_mut().unwrap();
643                    if let Err(e) = b.add_document(doc) {
644                        log::error!("Failed to index document: {:?}", e);
645                        state.record_cycle_error(format!("failed to index document: {e}"));
646                        continue;
647                    }
648
649                    let builder_memory = b.estimated_memory_bytes();
650
651                    if b.num_docs() & 0x3FFF == 0 {
652                        log::debug!(
653                            "[indexing] docs={}, memory={:.2} MB, budget={:.2} MB",
654                            b.num_docs(),
655                            builder_memory as f64 / (1024.0 * 1024.0),
656                            state.memory_budget_per_worker as f64 / (1024.0 * 1024.0)
657                        );
658                    }
659
660                    // Require minimum 100 docs before flushing to avoid tiny segments
661                    const MIN_DOCS_BEFORE_FLUSH: u32 = 100;
662
663                    // Reserve 20% headroom for segment build overhead (vid_set,
664                    // VidLookup, postings_flat, grid_entries). These temporary
665                    // allocations exist alongside the builder's data during build.
666                    let effective_budget = state.memory_budget_per_worker * 4 / 5;
667
668                    if builder_memory >= effective_budget && b.num_docs() >= MIN_DOCS_BEFORE_FLUSH {
669                        log::info!(
670                            "[indexing] memory budget reached, building segment: \
671                             docs={}, memory={:.2} MB, budget={:.2} MB",
672                            b.num_docs(),
673                            builder_memory as f64 / (1024.0 * 1024.0),
674                            state.memory_budget_per_worker as f64 / (1024.0 * 1024.0),
675                        );
676                        let full_builder = builder.take().unwrap();
677                        Self::build_segment_inline(&state, full_builder, &handle);
678                    }
679                }
680
681                // Channel closed — flush current builder
682                if !state.cycle_failed.load(Ordering::Acquire)
683                    && let Some(b) = builder.take()
684                    && b.num_docs() > 0
685                {
686                    Self::build_segment_inline(&state, b, &handle);
687                }
688            }));
689
690            if build_result.is_err() {
691                log::error!(
692                    "[worker] panic during indexing cycle — documents in this cycle may be lost"
693                );
694                state.record_cycle_error("indexing worker panicked while building the batch");
695            }
696
697            // Signal flush completion (always, even after panic — prevents
698            // prepare_commit from hanging)
699            let prev = state.flush_count.fetch_add(1, Ordering::Release);
700            if prev + 1 == state.num_workers {
701                // Last worker — wake prepare_commit
702                let _lock = state.flush_mutex.lock();
703                state.flush_cvar.notify_one();
704            }
705
706            // Wait for resume (new channel) or shutdown.
707            // Check resume_epoch to avoid re-cloning a stale receiver from
708            // a previous cycle.
709            {
710                let mut lock = state.resume_receiver.lock();
711                loop {
712                    if state.shutdown.load(Ordering::Acquire) {
713                        return;
714                    }
715                    let current_epoch = state.resume_epoch.load(Ordering::Acquire);
716                    if current_epoch > my_epoch
717                        && let Some(rx) = lock.as_ref()
718                    {
719                        receiver = rx.clone();
720                        my_epoch = current_epoch;
721                        break;
722                    }
723                    state.resume_cvar.wait(&mut lock);
724                }
725            }
726        }
727    }
728
729    /// Build a segment on the worker thread. Uses `Handle::block_on()` to bridge
730    /// into async context for I/O (streaming writers). CPU work (rayon) stays on
731    /// the worker thread / rayon pool.
732    fn build_segment_inline(
733        state: &WorkerState<D>,
734        builder: SegmentBuilder,
735        handle: &tokio::runtime::Handle,
736    ) {
737        let segment_id = SegmentId::new();
738        let segment_hex = segment_id.to_hex();
739        // Claim the ID before the first file write. The guard is moved into
740        // `PreparedSegment` on success and otherwise releases automatically.
741        let operation = match state
742            .segment_manager
743            .protect_new_segment(segment_hex.clone())
744        {
745            Ok(operation) => operation,
746            Err(e) => {
747                log::error!(
748                    "[segment_build_failed] segment_id={} lifecycle_error={}",
749                    segment_hex,
750                    e,
751                );
752                state.record_cycle_error(format!(
753                    "failed to claim segment {segment_hex} for building: {e}"
754                ));
755                return;
756            }
757        };
758        let trained = state.segment_manager.trained();
759        let doc_count = builder.num_docs();
760        let build_start = std::time::Instant::now();
761
762        log::info!(
763            "[segment_build] segment_id={} doc_count={} ann={}",
764            segment_hex,
765            doc_count,
766            trained.is_some()
767        );
768
769        // Construct the cleanup owner before building. It keeps lifecycle
770        // ownership through async deletion on ordinary error, abort, and
771        // panic unwind; crash recovery is the only path left to the sweeper.
772        let mut prepared = PreparedSegment {
773            id: segment_hex.clone(),
774            segment_id,
775            num_docs: doc_count,
776            segment_manager: Arc::clone(&state.segment_manager),
777            operation: Some(operation),
778            runtime: handle.clone(),
779            published: false,
780        };
781
782        match handle.block_on(builder.build(
783            state.directory.as_ref(),
784            segment_id,
785            trained.as_deref(),
786        )) {
787            Ok(meta) if meta.num_docs == doc_count && meta.num_docs > 0 => {
788                let duration_ms = build_start.elapsed().as_millis() as u64;
789                log::info!(
790                    "[segment_build_done] segment_id={} doc_count={} duration_ms={}",
791                    segment_hex,
792                    meta.num_docs,
793                    duration_ms,
794                );
795                prepared.num_docs = meta.num_docs;
796                state.built_segments.lock().push(prepared);
797            }
798            Ok(meta) => {
799                let error = format!(
800                    "segment {segment_hex} built {} docs from a {doc_count}-document builder",
801                    meta.num_docs
802                );
803                log::error!("[segment_build_failed] {error}");
804                state.record_cycle_error(error);
805            }
806            Err(e) => {
807                log::error!(
808                    "[segment_build_failed] segment_id={} error={:?}",
809                    segment_hex,
810                    e
811                );
812                // `prepared` owns the lifecycle claim and schedules one
813                // tracked, idempotent cleanup pass when this scope ends.
814                state.record_cycle_error(format!("failed to build segment {segment_hex}: {e}"));
815            }
816        }
817    }
818
819    // ========================================================================
820    // Public API — commit, merge, etc.
821    // ========================================================================
822
823    /// Check merge policy and spawn a background merge if needed.
824    pub async fn maybe_merge(&self) {
825        self.segment_manager.maybe_merge().await;
826    }
827
828    /// Drain all in-flight merge tasks.
829    /// Blocking merge phases cannot be cancelled safely once started.
830    pub async fn abort_merges(&self) {
831        self.segment_manager.abort_merges().await;
832    }
833
834    /// Stop accepting lifecycle work, stop and join indexing workers, and
835    /// discard unpublished segments. Index deletion calls this while holding
836    /// the registry writer lock so in-flight requests finish first and stale
837    /// writer Arcs cannot restart work afterward.
838    pub async fn shutdown(&mut self) -> Result<()> {
839        self.segment_manager.begin_shutdown();
840        self.signal_worker_shutdown();
841
842        let workers = std::mem::take(&mut self.workers);
843        let panicked = tokio::task::spawn_blocking(move || {
844            workers
845                .into_iter()
846                .map(|worker| worker.join().is_err())
847                .filter(|panicked| *panicked)
848                .count()
849        })
850        .await
851        .map_err(|error| Error::Internal(format!("failed to join index workers: {}", error)))?;
852        if panicked > 0 {
853            log::error!("[index_shutdown] {} indexing worker(s) panicked", panicked);
854        }
855
856        // No commit is possible after shutdown. Dropping these RAII values
857        // releases their lifecycle ownership before directory deletion.
858        self.flushed_segments.clear();
859        self.worker_state.built_segments.lock().clear();
860        if let Some(pk_index) = &mut self.primary_key_index {
861            pk_index.clear_uncommitted();
862        }
863        Ok(())
864    }
865
866    /// Wait for the in-flight background merge to complete (if any).
867    pub async fn wait_for_merging_thread(&self) {
868        self.segment_manager.wait_for_merging_thread().await;
869    }
870
871    /// Wait for all eligible merges to complete, including cascading merges.
872    pub async fn wait_for_all_merges(&self) {
873        self.segment_manager.wait_for_all_merges().await;
874    }
875
876    /// Get the segment tracker for sharing with readers.
877    pub fn tracker(&self) -> std::sync::Arc<crate::segment::SegmentTracker> {
878        self.segment_manager.tracker()
879    }
880
881    /// Acquire a snapshot of current segments for reading.
882    pub async fn acquire_snapshot(&self) -> crate::segment::SegmentSnapshot {
883        self.segment_manager.acquire_snapshot().await
884    }
885
886    /// Clean up orphan segment files not registered in metadata.
887    pub async fn cleanup_orphan_segments(&self) -> Result<usize> {
888        self.segment_manager.cleanup_orphan_segments().await
889    }
890
891    /// Prepare commit — signal workers to flush, wait for completion, collect segments.
892    ///
893    /// All documents sent via `add_document` before this call are guaranteed
894    /// to be written to segment files on disk. Segments are NOT yet registered
895    /// in metadata — call `PreparedCommit::commit()` for that.
896    ///
897    /// Workers are NOT destroyed — they flush their builders and wait for
898    /// `resume_workers()` to give them a new channel.
899    ///
900    /// `add_document` will return `Closed` error until commit/abort resumes workers.
901    pub async fn prepare_commit(&mut self) -> Result<PreparedCommit<'_, D>> {
902        if self.worker_state.shutdown.load(Ordering::Acquire) {
903            return Err(Error::IndexClosed);
904        }
905        // 1. Close channel → workers drain remaining docs and flush builders
906        self.doc_sender.close();
907
908        // Wake any workers still waiting on resume_cvar from previous cycle.
909        // They'll clone the stale receiver, enter recv_blocking, get Err
910        // immediately (sender already closed), flush, and signal completion.
911        self.worker_state.resume_cvar.notify_all();
912
913        // 2. Wait for all workers to complete their flush (via spawn_blocking
914        //    to avoid blocking the tokio runtime)
915        let state = Arc::clone(&self.worker_state);
916        let all_flushed = tokio::task::spawn_blocking(move || {
917            let mut lock = state.flush_mutex.lock();
918            let deadline = std::time::Instant::now() + std::time::Duration::from_secs(300);
919            while state.flush_count.load(Ordering::Acquire) < state.num_workers {
920                let remaining = deadline.saturating_duration_since(std::time::Instant::now());
921                if remaining.is_zero() {
922                    log::error!(
923                        "[prepare_commit] timed out waiting for workers: {}/{} flushed",
924                        state.flush_count.load(Ordering::Acquire),
925                        state.num_workers
926                    );
927                    return false;
928                }
929                state.flush_cvar.wait_for(&mut lock, remaining);
930            }
931            true
932        })
933        .await
934        .map_err(|e| Error::Internal(format!("Failed to wait for workers: {}", e)))?;
935
936        if !all_flushed {
937            // Keep this commit cycle paused. Resetting flush_count and handing
938            // out a new receiver while an old worker is still building lets
939            // that late worker increment the *next* cycle's counter. A later
940            // prepare can then return before all of its workers flushed and
941            // publish an incomplete set of segments. The caller may retry
942            // prepare_commit; it will observe the same generation and collect
943            // every completed output once the lagging worker finishes.
944            return Err(Error::Internal(format!(
945                "prepare_commit timed out: {}/{} workers flushed; writer remains paused, retry commit",
946                self.worker_state.flush_count.load(Ordering::Acquire),
947                self.worker_state.num_workers
948            )));
949        }
950
951        let cycle_error = { self.worker_state.cycle_error.lock().take() };
952        if let Some(error) = cycle_error {
953            // No partial publication: some documents in this generation no
954            // longer exist in a worker builder, so successful sibling outputs
955            // cannot be committed without violating commit's all-prior-docs
956            // guarantee. Their RAII drops retain ownership through deletion.
957            self.flushed_segments.clear();
958            self.worker_state.built_segments.lock().clear();
959            if let Some(pk_index) = &mut self.primary_key_index {
960                pk_index.clear_uncommitted();
961            }
962            self.resume_workers();
963            return Err(Error::Internal(format!(
964                "indexing generation failed; no documents from this batch were committed: {error}"
965            )));
966        }
967
968        // 3. Collect built segments
969        let built = std::mem::take(&mut *self.worker_state.built_segments.lock());
970        self.flushed_segments.extend(built);
971
972        Ok(PreparedCommit {
973            writer: self,
974            is_resolved: false,
975            is_published: false,
976        })
977    }
978
979    /// Commit (convenience): prepare_commit + commit in one call.
980    ///
981    /// Guarantees all prior `add_document` calls are committed.
982    /// Vector training is decoupled — call `build_vector_index()` manually.
983    pub async fn commit(&mut self) -> Result<bool> {
984        self.prepare_commit().await?.commit().await
985    }
986
987    /// Force merge all segments into one.
988    pub async fn force_merge(&mut self) -> Result<()> {
989        self.prepare_commit().await?.commit().await?;
990        self.segment_manager.force_merge().await
991    }
992
993    /// Reorder all segments via Recursive Graph Bisection (BP) for better BMP pruning.
994    ///
995    /// Each segment is individually rebuilt with record-level BP reordering:
996    /// ordinals are shuffled across blocks so that similar content clusters tightly.
997    pub async fn reorder(&mut self) -> Result<()> {
998        self.prepare_commit().await?.commit().await?;
999        self.segment_manager.reorder_segments().await
1000    }
1001
1002    /// Get the segment manager (for background optimizer access).
1003    pub fn segment_manager(&self) -> &Arc<crate::merge::SegmentManager<D>> {
1004        &self.segment_manager
1005    }
1006
1007    /// Resume workers with a fresh channel. Called after commit or abort.
1008    ///
1009    /// Workers are already alive — just give them a new channel and wake them.
1010    /// If the tokio runtime has shut down (e.g., program exit), this is a no-op.
1011    fn resume_workers(&mut self) {
1012        if self.worker_state.shutdown.load(Ordering::Acquire) {
1013            return;
1014        }
1015        if tokio::runtime::Handle::try_current().is_err() {
1016            // Runtime is gone — signal permanent shutdown so workers don't
1017            // hang forever on resume_cvar.
1018            self.worker_state.shutdown.store(true, Ordering::Release);
1019            self.worker_state.resume_cvar.notify_all();
1020            return;
1021        }
1022
1023        // Reset flush count for next cycle
1024        self.worker_state.flush_count.store(0, Ordering::Release);
1025        *self.worker_state.cycle_error.lock() = None;
1026        self.worker_state
1027            .cycle_failed
1028            .store(false, Ordering::Release);
1029
1030        // Create new channel
1031        let (sender, receiver) = async_channel::bounded(PIPELINE_MAX_SIZE_IN_DOCS);
1032        self.doc_sender = sender;
1033
1034        // Set new receiver, bump epoch, and wake all workers
1035        {
1036            let mut lock = self.worker_state.resume_receiver.lock();
1037            *lock = Some(receiver);
1038        }
1039        self.worker_state
1040            .resume_epoch
1041            .fetch_add(1, Ordering::Release);
1042        self.worker_state.resume_cvar.notify_all();
1043    }
1044
1045    fn signal_worker_shutdown(&self) {
1046        self.worker_state.shutdown.store(true, Ordering::Release);
1047        self.doc_sender.close();
1048        self.worker_state.resume_cvar.notify_all();
1049    }
1050
1051    // Vector index methods (build_vector_index, etc.) are in vector_builder.rs
1052}
1053
1054impl<D: DirectoryWriter + 'static> Drop for IndexWriter<D> {
1055    fn drop(&mut self) {
1056        self.signal_worker_shutdown();
1057        for w in std::mem::take(&mut self.workers) {
1058            let _ = w.join();
1059        }
1060    }
1061}
1062
1063/// A prepared commit that can be finalized or aborted.
1064///
1065/// Two-phase commit guard. Between `prepare_commit()` and
1066/// `commit()`/`abort()`, segments are on disk but NOT in metadata.
1067/// Dropping without calling either will auto-abort (discard segments,
1068/// respawn workers).
1069pub struct PreparedCommit<'a, D: DirectoryWriter + 'static> {
1070    writer: &'a mut IndexWriter<D>,
1071    is_resolved: bool,
1072    /// Metadata publication is the commit point. Post-publication cache work
1073    /// may still fail or be cancelled, but must never be described as an abort.
1074    is_published: bool,
1075}
1076
1077impl<'a, D: DirectoryWriter + 'static> PreparedCommit<'a, D> {
1078    /// Finalize: register segments in metadata, evaluate merge policy, resume workers.
1079    ///
1080    /// Returns `true` if new segments were committed, `false` if nothing changed.
1081    pub async fn commit(mut self) -> Result<bool> {
1082        let mut segments = std::mem::take(&mut self.writer.flushed_segments);
1083
1084        // Fast path: nothing to commit
1085        if segments.is_empty() {
1086            log::debug!("[commit] no segments to commit, skipping");
1087            self.is_resolved = true;
1088            self.writer.resume_workers();
1089            return Ok(false);
1090        }
1091
1092        let metadata_entries: Vec<(String, u32)> = segments
1093            .iter()
1094            .map(PreparedSegment::metadata_entry)
1095            .collect();
1096        if let Err(error) = self.writer.segment_manager.commit(&metadata_entries).await {
1097            // Publication is transactional, so these guarded files remain a
1098            // valid prepared commit. Preserve them and keep this generation
1099            // paused for the caller's next commit/abort. Resuming here allowed
1100            // a later worker generation to mix with the retained outputs; a
1101            // failure in that generation then discarded documents from both.
1102            self.writer.flushed_segments = segments;
1103            self.is_resolved = true;
1104            return Err(error);
1105        }
1106        self.is_published = true;
1107
1108        // Metadata + tracker now own the IDs; releasing indexing ownership is
1109        // the final transition from prepared to live.
1110        for segment in &mut segments {
1111            segment.mark_published();
1112        }
1113        drop(segments);
1114
1115        // Refresh primary key index: only load fast fields for NEW segments.
1116        let post_publish_result = async {
1117            if let Some(ref mut pk_index) = self.writer.primary_key_index {
1118                let snapshot = self.writer.segment_manager.acquire_snapshot().await;
1119                let existing_ids: std::collections::HashSet<&str> =
1120                    pk_index.committed_segment_ids().collect();
1121
1122                // Only load fast fields for segments not already held.
1123                let load_futures: Vec<_> = snapshot
1124                    .segment_ids()
1125                    .iter()
1126                    .filter(|id| !existing_ids.contains(id.as_str()))
1127                    .map(|seg_id_str| {
1128                        let seg_id_str = seg_id_str.clone();
1129                        let dir = self.writer.directory.as_ref();
1130                        let schema = Arc::clone(&self.writer.schema);
1131                        async move { load_pk_segment_data(dir, &seg_id_str, &schema).await }
1132                    })
1133                    .collect();
1134                let new_data = futures::future::try_join_all(load_futures).await?;
1135
1136                let seg_ids: Vec<String> = snapshot.segment_ids().to_vec();
1137                pk_index.refresh_incremental(new_data, snapshot);
1138
1139                // Persist bloom cache (extract bytes to avoid borrow conflict).
1140                let bloom_bytes = pk_index.bloom_to_bytes();
1141                let data = super::primary_key::serialize_pk_bloom(&seg_ids, &bloom_bytes);
1142                if let Err(e) = self
1143                    .writer
1144                    .directory
1145                    .write(
1146                        std::path::Path::new(super::primary_key::PK_BLOOM_FILE),
1147                        &data,
1148                    )
1149                    .await
1150                {
1151                    log::warn!("[primary_key] failed to persist bloom cache: {}", e);
1152                }
1153            }
1154
1155            self.writer.segment_manager.maybe_merge().await;
1156            Ok(())
1157        }
1158        .await;
1159
1160        // Worker availability must not depend on optional post-publication
1161        // cache refresh or merge scheduling.
1162        self.is_resolved = true;
1163        self.writer.resume_workers();
1164        post_publish_result.map(|()| true)
1165    }
1166
1167    /// Abort: discard prepared segments, delete their files asynchronously,
1168    /// and resume workers. Lifecycle ownership is held until deletion ends.
1169    pub fn abort(mut self) {
1170        self.is_resolved = true;
1171        self.writer.flushed_segments.clear();
1172        if let Some(ref mut pk_index) = self.writer.primary_key_index {
1173            pk_index.clear_uncommitted();
1174        }
1175        self.writer.resume_workers();
1176    }
1177}
1178
1179impl<D: DirectoryWriter + 'static> Drop for PreparedCommit<'_, D> {
1180    fn drop(&mut self) {
1181        if !self.is_resolved {
1182            if self.is_published {
1183                log::warn!("PreparedCommit dropped after metadata publication — resuming workers");
1184            } else {
1185                log::warn!("PreparedCommit dropped without commit/abort — auto-aborting");
1186                self.writer.flushed_segments.clear();
1187                if let Some(ref mut pk_index) = self.writer.primary_key_index {
1188                    pk_index.clear_uncommitted();
1189                }
1190            }
1191            self.writer.resume_workers();
1192        }
1193    }
1194}
1195
1196/// Load only fast-field data for a segment (lightweight alternative to full SegmentReader).
1197async fn load_pk_segment_data<D: crate::directories::Directory>(
1198    dir: &D,
1199    seg_id_str: &str,
1200    schema: &Arc<crate::dsl::Schema>,
1201) -> Result<super::primary_key::PkSegmentData> {
1202    let seg_id = crate::segment::SegmentId::from_hex(seg_id_str)
1203        .ok_or_else(|| Error::Internal(format!("Invalid segment id: {}", seg_id_str)))?;
1204    let files = crate::segment::SegmentFiles::new(seg_id.0);
1205    let fast_fields =
1206        crate::segment::reader::loader::load_fast_fields_file(dir, &files, schema).await?;
1207    Ok(super::primary_key::PkSegmentData {
1208        segment_id: seg_id_str.to_string(),
1209        fast_fields,
1210    })
1211}