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