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//!   Workers use deterministic, staggered soft flush thresholds within that
23//!   budget so equal-size builders do not all stop draining at once.
24//! - **Build concurrency reserve**: while input is open, at most `N - 1`
25//!   workers build segments concurrently. A worker that cannot get a slot
26//!   keeps draining up to the former 80% flush boundary. Once input closes,
27//!   all `N` tail builds may finish concurrently because no drainer is needed.
28//! - **Two-phase commit**:
29//!   1. `prepare_commit()` — closes queue, workers flush builders to disk.
30//!      Returns a `PreparedCommit` guard. No new documents accepted until resolved.
31//!   2. `PreparedCommit::commit()` — registers segments in metadata, resumes workers.
32//!   3. `PreparedCommit::abort()` — discards prepared segments, resumes workers.
33//!   4. `commit()` — convenience: `prepare_commit().await?.commit().await`.
34//!
35//! Since `prepare_commit`/`commit` take `&mut self`, Rust’s borrow checker
36//! guarantees no concurrent `add_document` calls during the commit window.
37
38use std::sync::Arc;
39use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
40
41use futures::FutureExt;
42use rustc_hash::FxHashMap;
43
44use crate::directories::DirectoryWriter;
45use crate::dsl::{Document, Field, Schema};
46use crate::error::{Error, Result};
47use crate::segment::{SegmentBuilder, SegmentBuilderConfig, SegmentId};
48use crate::tokenizer::BoxedTokenizer;
49
50use super::IndexConfig;
51
52/// Total pipeline capacity (in documents).
53const PIPELINE_MAX_SIZE_IN_DOCS: usize = 10_000;
54
55/// Builder memory percentage at which the first worker starts trying to flush.
56///
57/// The last worker starts at [`SOFT_FLUSH_MAX_PERCENT`], preserving the former
58/// 20% segment-build headroom. Intermediate workers are evenly staggered
59/// between the two bounds.
60const SOFT_FLUSH_MIN_PERCENT: usize = 70;
61const SOFT_FLUSH_MAX_PERCENT: usize = 80;
62
63/// File name of the advisory single-writer lock inside the index directory.
64pub const WRITER_LOCK_FILENAME: &str = ".hermes_writer.lock";
65
66/// Return a deterministic per-worker soft flush threshold.
67///
68/// All thresholds remain at or below the former uniform 80% trigger, so their
69/// sum cannot increase builder memory. The 70–80% spread prevents identical
70/// workers consuming the shared queue at the same rate from entering segment
71/// builds in lockstep.
72fn soft_flush_threshold(memory_budget: usize, worker_id: usize, num_workers: usize) -> usize {
73    if num_workers <= 1 {
74        return hard_flush_threshold(memory_budget);
75    }
76
77    let worker_id = worker_id.min(num_workers - 1);
78    let span = SOFT_FLUSH_MAX_PERCENT - SOFT_FLUSH_MIN_PERCENT;
79    let denominator = 100u128 * (num_workers - 1) as u128;
80    let numerator = (SOFT_FLUSH_MIN_PERCENT * (num_workers - 1) + span * worker_id) as u128;
81    ((memory_budget as u128 * numerator) / denominator) as usize
82}
83
84/// Preserve the historical 20% allowance for allocations created while a
85/// segment is finalized. Workers may stagger below this boundary, but no
86/// queued builder consumes the scratch reserve while waiting for a build slot.
87fn hard_flush_threshold(memory_budget: usize) -> usize {
88    memory_budget.saturating_mul(SOFT_FLUSH_MAX_PERCENT) / 100
89}
90
91/// Derive the builder defaults used by the standard writer constructors.
92///
93/// `IndexConfig::num_compression_threads` is the public per-index setting; it
94/// must reach segment builders instead of being replaced by the machine-wide
95/// `SegmentBuilderConfig` default. Explicit `*_with_config` constructors bypass
96/// this helper and continue honoring every supplied builder option.
97fn default_builder_config(index_config: &IndexConfig) -> SegmentBuilderConfig {
98    SegmentBuilderConfig {
99        num_compression_threads: index_config.num_compression_threads,
100        ..SegmentBuilderConfig::default()
101    }
102}
103
104/// Bounds simultaneous segment finalization while reserving an indexing
105/// worker to drain the shared document queue.
106///
107/// A soft-threshold worker first tries to acquire without waiting. If every
108/// slot is occupied it may continue indexing until its hard per-worker memory
109/// flush boundary. At that hard limit it waits, preserving the remaining 20%
110/// of every worker share for finalization scratch.
111struct SegmentBuildLimiter {
112    live_max_active: usize,
113    flush_max_active: usize,
114    active: AtomicUsize,
115    flushing: AtomicBool,
116    wait_mutex: parking_lot::Mutex<()>,
117    available: parking_lot::Condvar,
118}
119
120impl SegmentBuildLimiter {
121    fn new(num_workers: usize) -> Self {
122        Self {
123            // A single-worker writer must still be able to build. With two or
124            // more workers, reserve one worker from concurrent finalization.
125            live_max_active: num_workers.saturating_sub(1).max(1),
126            // Once the input queue closes there is no ingestion to reserve;
127            // flush every tail concurrently as the old writer did.
128            flush_max_active: num_workers.max(1),
129            active: AtomicUsize::new(0),
130            flushing: AtomicBool::new(false),
131            wait_mutex: parking_lot::Mutex::new(()),
132            available: parking_lot::Condvar::new(),
133        }
134    }
135
136    fn try_acquire(&self) -> Option<SegmentBuildPermit<'_>> {
137        self.try_acquire_up_to(self.live_max_active)
138    }
139
140    fn try_acquire_up_to(&self, limit: usize) -> Option<SegmentBuildPermit<'_>> {
141        let mut active = self.active.load(Ordering::Acquire);
142        loop {
143            if active >= limit {
144                return None;
145            }
146            match self.active.compare_exchange_weak(
147                active,
148                active + 1,
149                Ordering::AcqRel,
150                Ordering::Acquire,
151            ) {
152                Ok(_) => return Some(SegmentBuildPermit { limiter: self }),
153                Err(observed) => active = observed,
154            }
155        }
156    }
157
158    fn acquire(&self) -> SegmentBuildPermit<'_> {
159        let mut wait = self.wait_mutex.lock();
160        loop {
161            let limit = if self.flushing.load(Ordering::Acquire) {
162                self.flush_max_active
163            } else {
164                self.live_max_active
165            };
166            if let Some(permit) = self.try_acquire_up_to(limit) {
167                return permit;
168            }
169            self.available.wait(&mut wait);
170        }
171    }
172
173    fn acquire_flush(&self) -> SegmentBuildPermit<'_> {
174        self.acquire_up_to(self.flush_max_active)
175    }
176
177    fn acquire_up_to(&self, limit: usize) -> SegmentBuildPermit<'_> {
178        let mut wait = self.wait_mutex.lock();
179        loop {
180            if let Some(permit) = self.try_acquire_up_to(limit) {
181                return permit;
182            }
183            self.available.wait(&mut wait);
184        }
185    }
186
187    /// Promote existing live waiters when input closes. Store the phase before
188    /// taking the condvar mutex; a waiter either observes it directly or
189    /// releases the mutex in `wait`, after which this notification reaches it.
190    fn begin_flush(&self) {
191        self.flushing.store(true, Ordering::Release);
192        let _wait = self.wait_mutex.lock();
193        self.available.notify_all();
194    }
195
196    fn end_flush(&self) {
197        self.flushing.store(false, Ordering::Release);
198    }
199
200    /// Reserve a build slot once `builder_memory` reaches its soft threshold.
201    ///
202    /// When all slots are busy, `None` below `hard_budget` means "keep
203    /// draining". At the hard budget this waits for a slot rather than
204    /// exceeding the configured per-worker memory share.
205    fn reserve_if_due(
206        &self,
207        builder_memory: usize,
208        soft_threshold: usize,
209        hard_budget: usize,
210    ) -> Option<SegmentBuildPermit<'_>> {
211        if builder_memory < soft_threshold {
212            return None;
213        }
214        if let Some(permit) = self.try_acquire() {
215            return Some(permit);
216        }
217        if builder_memory < hard_budget {
218            return None;
219        }
220        Some(self.acquire())
221    }
222}
223
224struct SegmentBuildPermit<'a> {
225    limiter: &'a SegmentBuildLimiter,
226}
227
228impl Drop for SegmentBuildPermit<'_> {
229    fn drop(&mut self) {
230        let previous = self.limiter.active.fetch_sub(1, Ordering::AcqRel);
231        debug_assert!(previous > 0);
232        // Synchronize notification with acquire's condvar wait so a permit
233        // becoming free cannot be missed between its check and sleeping.
234        let _wait = self.limiter.wait_mutex.lock();
235        // Live-ingestion and closed-queue flush waiters have different limits;
236        // wake both classes so the reserved flush slot cannot be stranded.
237        self.limiter.available.notify_all();
238    }
239}
240
241#[cfg(test)]
242mod indexing_pipeline_tests {
243    use std::sync::Arc;
244    use std::time::Duration;
245
246    use super::{
247        SOFT_FLUSH_MAX_PERCENT, SOFT_FLUSH_MIN_PERCENT, SegmentBuildLimiter,
248        default_builder_config, hard_flush_threshold, soft_flush_threshold,
249    };
250
251    #[test]
252    fn standard_builder_config_honors_index_compression_width() {
253        let index_config = crate::index::IndexConfig {
254            num_compression_threads: 7,
255            ..Default::default()
256        };
257
258        let builder_config = default_builder_config(&index_config);
259
260        assert_eq!(builder_config.num_compression_threads, 7);
261    }
262
263    #[test]
264    fn flush_thresholds_are_staggered_without_increasing_memory_budget() {
265        const WORKERS: usize = 12;
266        const PER_WORKER_BUDGET: usize = 1024 * 1024 * 1024;
267
268        let thresholds: Vec<_> = (0..WORKERS)
269            .map(|worker| soft_flush_threshold(PER_WORKER_BUDGET, worker, WORKERS))
270            .collect();
271
272        assert_eq!(
273            thresholds[0],
274            PER_WORKER_BUDGET * SOFT_FLUSH_MIN_PERCENT / 100
275        );
276        assert_eq!(
277            thresholds[WORKERS - 1],
278            PER_WORKER_BUDGET * SOFT_FLUSH_MAX_PERCENT / 100
279        );
280        assert!(
281            thresholds.windows(2).all(|pair| pair[0] < pair[1]),
282            "production-width workers must not reach identical flush thresholds: {thresholds:?}"
283        );
284
285        let staggered_total: usize = thresholds.iter().sum();
286        let former_uniform_total = WORKERS * (PER_WORKER_BUDGET * SOFT_FLUSH_MAX_PERCENT / 100);
287        assert!(
288            staggered_total <= former_uniform_total,
289            "staggering must not increase aggregate builder memory"
290        );
291
292        assert_eq!(
293            soft_flush_threshold(PER_WORKER_BUDGET, 0, 1),
294            PER_WORKER_BUDGET * SOFT_FLUSH_MAX_PERCENT / 100,
295            "single-worker behavior retains the former 80% build headroom"
296        );
297
298        let hard_threshold = hard_flush_threshold(PER_WORKER_BUDGET);
299        let build_scratch = PER_WORKER_BUDGET - hard_threshold;
300        let steady_state_peak = (WORKERS - 1) * (hard_threshold + build_scratch) + hard_threshold;
301        assert!(
302            steady_state_peak <= WORKERS * PER_WORKER_BUDGET,
303            "rotated hard-threshold builds must retain aggregate scratch headroom"
304        );
305    }
306
307    #[test]
308    fn full_build_gate_leaves_soft_threshold_worker_draining() {
309        const WORKERS: usize = 12;
310        let limiter = SegmentBuildLimiter::new(WORKERS);
311        let mut active_builds: Vec<_> = (0..WORKERS - 1)
312            .map(|_| {
313                limiter
314                    .try_acquire()
315                    .expect("N - 1 builds should be admitted")
316            })
317            .collect();
318
319        assert!(
320            limiter.try_acquire().is_none(),
321            "the final worker must be reserved from concurrent segment builds"
322        );
323        assert!(
324            limiter.reserve_if_due(750, 700, 800).is_none(),
325            "a worker below its hard budget must keep draining when builds are saturated"
326        );
327
328        drop(active_builds.pop());
329        let replacement = limiter
330            .reserve_if_due(750, 700, 800)
331            .expect("a completed build must immediately rotate draining capacity");
332        assert!(limiter.try_acquire().is_none());
333        drop(replacement);
334        drop(active_builds);
335    }
336
337    #[test]
338    fn closed_queue_flush_uses_the_reserved_build_slot() {
339        let limiter = SegmentBuildLimiter::new(2);
340        let live_build = limiter
341            .try_acquire()
342            .expect("one live build should be admitted");
343        assert!(limiter.try_acquire().is_none());
344
345        let tail_build = limiter.acquire_flush();
346        assert!(
347            limiter
348                .try_acquire_up_to(limiter.flush_max_active)
349                .is_none(),
350            "closed-queue flushes must remain bounded by the worker count"
351        );
352
353        drop(tail_build);
354        drop(live_build);
355    }
356
357    #[test]
358    fn closing_input_promotes_an_existing_live_waiter() {
359        let limiter = Arc::new(SegmentBuildLimiter::new(2));
360        let live_build = limiter.try_acquire().unwrap();
361        let waiter_limiter = Arc::clone(&limiter);
362        let (started_tx, started_rx) = std::sync::mpsc::channel();
363        let (acquired_tx, acquired_rx) = std::sync::mpsc::channel();
364
365        let waiter = std::thread::spawn(move || {
366            started_tx.send(()).unwrap();
367            let _permit = waiter_limiter
368                .reserve_if_due(800, 700, 800)
369                .expect("closed input must promote a hard-boundary waiter");
370            acquired_tx.send(()).unwrap();
371        });
372
373        started_rx.recv_timeout(Duration::from_secs(1)).unwrap();
374        assert!(acquired_rx.recv_timeout(Duration::from_millis(50)).is_err());
375
376        limiter.begin_flush();
377        acquired_rx
378            .recv_timeout(Duration::from_secs(1))
379            .expect("live waiter did not adopt the closed-queue build limit");
380        waiter.join().unwrap();
381        limiter.end_flush();
382        drop(live_build);
383    }
384
385    #[test]
386    fn hard_budget_waiter_resumes_when_a_build_finishes() {
387        let limiter = Arc::new(SegmentBuildLimiter::new(3));
388        let first = limiter.try_acquire().unwrap();
389        let second = limiter.try_acquire().unwrap();
390        let waiter_limiter = Arc::clone(&limiter);
391        let (started_tx, started_rx) = std::sync::mpsc::channel();
392        let (acquired_tx, acquired_rx) = std::sync::mpsc::channel();
393
394        let waiter = std::thread::spawn(move || {
395            started_tx.send(()).unwrap();
396            let _permit = waiter_limiter
397                .reserve_if_due(800, 700, 800)
398                .expect("hard-budget worker must eventually acquire a build slot");
399            acquired_tx.send(()).unwrap();
400        });
401
402        started_rx.recv_timeout(Duration::from_secs(1)).unwrap();
403        assert!(
404            acquired_rx.recv_timeout(Duration::from_millis(50)).is_err(),
405            "hard-budget worker must not over-subscribe segment builds"
406        );
407        drop(first);
408        acquired_rx
409            .recv_timeout(Duration::from_secs(1))
410            .expect("hard-budget worker did not wake after a build completed");
411        waiter.join().unwrap();
412        drop(second);
413    }
414}
415
416/// Advisory single-writer lock state.
417///
418/// Two independent writers on one index directory silently destroy each
419/// other's data: the orphan sweep at writer open deletes the other process's
420/// unpublished segment files, and metadata saves are last-writer-wins. For
421/// directories rooted on a local filesystem the writer therefore holds an OS
422/// advisory lock for its whole lifetime; the kernel releases it automatically
423/// when the process dies.
424enum WriterLock {
425    /// Lock acquired. Closing the file (writer drop) releases it.
426    Held { _file: std::fs::File },
427    /// The directory has no lockable local filesystem root (e.g. RAM or
428    /// remote directories) — cross-process locking is not applicable.
429    NotApplicable,
430    /// Another writer holds the lock. Every mutating operation fails loudly
431    /// with this message instead of silently double-writing.
432    Unavailable { reason: String },
433}
434
435/// Local filesystem root of the index directory, when the directory type
436/// exposes one.
437fn writer_lock_root<D: DirectoryWriter + 'static>(directory: &D) -> Option<std::path::PathBuf> {
438    let any: &dyn std::any::Any = directory;
439    if let Some(mmap) = any.downcast_ref::<crate::directories::MmapDirectory>() {
440        return Some(mmap.root().to_path_buf());
441    }
442    // FsDirectory does not expose its root path, so the single-writer lock
443    // cannot be enforced for it yet. Say so loudly instead of silently
444    // skipping protection for a filesystem-backed writer.
445    if any
446        .downcast_ref::<crate::directories::FsDirectory>()
447        .is_some()
448    {
449        log::warn!(
450            "[writer_lock] FsDirectory exposes no root path; single-writer locking \
451             is not enforced for this writer — do not open a second writer for the \
452             same index directory"
453        );
454    }
455    None
456}
457
458/// Try to take the exclusive single-writer lock for `directory`.
459///
460/// Returns `WriterLock::Unavailable` (not `Err`) on conflict so infallible
461/// constructors can defer the failure to their first mutating operation.
462fn try_acquire_writer_lock<D: DirectoryWriter + 'static>(directory: &D) -> Result<WriterLock> {
463    let Some(root) = writer_lock_root(directory) else {
464        return Ok(WriterLock::NotApplicable);
465    };
466    std::fs::create_dir_all(&root)?;
467    let lock_path = root.join(WRITER_LOCK_FILENAME);
468    let file = std::fs::OpenOptions::new()
469        .create(true)
470        .truncate(false)
471        .write(true)
472        .open(&lock_path)?;
473    match file.try_lock() {
474        Ok(()) => Ok(WriterLock::Held { _file: file }),
475        Err(std::fs::TryLockError::WouldBlock) => Ok(WriterLock::Unavailable {
476            reason: format!(
477                "another IndexWriter already holds the single-writer lock for this \
478                 index ({}); Hermes supports one writer per index directory — stop \
479                 the other writer (e.g. a running hermes-server or hermes-tool) \
480                 before opening this one",
481                lock_path.display()
482            ),
483        }),
484        Err(std::fs::TryLockError::Error(error)) => Err(Error::Io(error)),
485    }
486}
487
488/// Async IndexWriter for adding documents and committing segments.
489///
490/// **Backpressure:** `add_document()` is sync and O(1). It returns
491/// `Error::QueueFull` when the shared queue is full and
492/// `Error::CommitInProgress` while a generation is publishing or awaiting
493/// retry; callers must back off.
494///
495/// **Two-phase commit:**
496/// - `prepare_commit()` → `PreparedCommit::commit()` or `PreparedCommit::abort()`
497/// - `commit()` is a convenience that does both phases.
498/// - Between prepare and commit, the caller can do external work (WAL, sync, etc.)
499///   knowing that abort is possible if something fails.
500/// - Dropping `PreparedCommit` without calling commit/abort auto-aborts.
501pub struct IndexWriter<D: DirectoryWriter + 'static> {
502    pub(super) directory: Arc<D>,
503    pub(super) schema: Arc<Schema>,
504    pub(super) config: IndexConfig,
505    /// MPMC sender, replaced under a brief lock on each commit cycle (workers
506    /// get the corresponding new receiver via resume).
507    doc_sender: Arc<parking_lot::RwLock<async_channel::Sender<Document>>>,
508    /// Worker OS thread handles — long-lived, survive across commits.
509    workers: Vec<std::thread::JoinHandle<()>>,
510    /// Shared worker state (immutable config + mutable segment output + sync)
511    worker_state: Arc<WorkerState<D>>,
512    /// Segment manager — owns metadata.json, handles segments and background merging
513    pub(super) segment_manager: Arc<crate::merge::SegmentManager<D>>,
514    /// Segments flushed to disk but not yet registered in metadata. Each item
515    /// owns an active-operation guard, so orphan sweeping cannot delete it.
516    flushed_segments: Arc<parking_lot::Mutex<Vec<PreparedSegment<D>>>>,
517    /// Primary key dedup index (None if schema has no primary field)
518    primary_key_index: Arc<parking_lot::RwLock<Option<super::primary_key::PrimaryKeyIndex>>>,
519    /// Serializes async snapshot acquisition/loading across commits and
520    /// lifecycle-owned merge/reorder topology refreshes.
521    primary_key_refresh_lock: Arc<tokio::sync::Mutex<()>>,
522    /// Tracks the owned finalizer spawned by `PreparedCommit::commit`. The
523    /// requesting future may disappear, but a second commit generation must
524    /// not start until this one has made publication and worker state agree.
525    commit_finalization: Arc<CommitFinalizationState>,
526    /// True while a failed post-commit PK refresh has left the uncommitted
527    /// reservations as the ONLY record of already-committed keys (fail-closed,
528    /// see `finalize_prepared_commit`). While set, abort paths must NOT clear
529    /// the reservations or duplicate primary keys could be admitted.
530    pk_reservations_retained: Arc<AtomicBool>,
531    /// Advisory single-writer lock, held for the writer's lifetime.
532    /// `Unavailable` is retryable: the conflicting holder may exit at any
533    /// time (the kernel then releases its lock), so `ensure_writer_lock`
534    /// re-attempts acquisition instead of caching the conflict forever.
535    writer_lock: parking_lot::RwLock<WriterLock>,
536}
537
538#[derive(Default)]
539struct CommitFinalizationState {
540    in_progress: AtomicBool,
541    idle: tokio::sync::Notify,
542}
543
544impl CommitFinalizationState {
545    fn begin(&self) -> bool {
546        self.in_progress
547            .compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire)
548            .is_ok()
549    }
550
551    fn finish(&self) {
552        self.in_progress.store(false, Ordering::Release);
553        self.idle.notify_waiters();
554    }
555
556    async fn wait_until_idle(&self) {
557        while self.in_progress.load(Ordering::Acquire) {
558            let notified = self.idle.notified();
559            if !self.in_progress.load(Ordering::Acquire) {
560                break;
561            }
562            notified.await;
563        }
564    }
565}
566
567/// Shared state for worker threads.
568struct WorkerState<D: DirectoryWriter + 'static> {
569    directory: Arc<D>,
570    schema: Arc<Schema>,
571    builder_config: SegmentBuilderConfig,
572    tokenizers: parking_lot::RwLock<FxHashMap<Field, BoxedTokenizer>>,
573    /// Fixed per-worker memory budget (bytes). When a builder exceeds this, segment is built.
574    memory_budget_per_worker: usize,
575    /// Limits live segment finalization to N - 1 workers, reserving
576    /// queue-draining capacity; closed-queue tail flushes may use all N.
577    segment_build_limiter: SegmentBuildLimiter,
578    /// Segment manager — workers read trained structures from its ArcSwap (lock-free).
579    segment_manager: Arc<crate::merge::SegmentManager<D>>,
580    /// Segments built by workers, collected by `prepare_commit()`. Their RAII
581    /// guards protect both in-progress and completed-uncommitted files.
582    built_segments: parking_lot::Mutex<Vec<PreparedSegment<D>>>,
583    /// First failure in the current flush generation. Worker-side indexing is
584    /// asynchronous, so `prepare_commit` is the only sound place to surface
585    /// it to the caller. A failed generation is aborted as a unit; publishing
586    /// only its successful segments would silently lose documents.
587    cycle_error: parking_lot::Mutex<Option<String>>,
588    cycle_failed: AtomicBool,
589
590    // === Worker lifecycle synchronization ===
591    // Workers survive across commits. On prepare_commit the channel is closed;
592    // workers flush their builders, increment flush_count, then wait on
593    // resume_cvar for a new receiver. commit/abort creates a fresh channel
594    // and wakes them.
595    /// Number of workers that have completed their flush.
596    flush_count: AtomicUsize,
597    /// Mutex + condvar for prepare_commit to wait on all workers flushed.
598    flush_mutex: parking_lot::Mutex<()>,
599    flush_cvar: parking_lot::Condvar,
600    /// Holds the new channel receiver after commit/abort. Workers clone from this.
601    resume_receiver: parking_lot::Mutex<Option<async_channel::Receiver<Document>>>,
602    /// Monotonically increasing epoch, bumped by each resume_workers call.
603    /// Workers compare against their local epoch to avoid re-cloning a stale receiver.
604    resume_epoch: AtomicUsize,
605    /// Condvar for workers to wait for resume (new channel) or shutdown.
606    resume_cvar: parking_lot::Condvar,
607    /// When true, workers should exit permanently (IndexWriter dropped).
608    shutdown: AtomicBool,
609    /// Total number of worker threads.
610    num_workers: usize,
611}
612
613/// A completed indexing segment that has not been published in metadata yet.
614///
615/// `operation` is intentionally data, not a side-channel set update: moving
616/// this value through worker → prepared commit → commit/abort moves lifecycle
617/// ownership with it, and every unwind/drop path releases ownership safely.
618struct PreparedSegment<D: DirectoryWriter + 'static> {
619    id: String,
620    segment_id: SegmentId,
621    num_docs: u32,
622    segment_manager: Arc<crate::merge::SegmentManager<D>>,
623    operation: Option<crate::merge::SegmentOperationGuard>,
624    runtime: tokio::runtime::Handle,
625    needs_vector_upgrade: bool,
626    published: bool,
627}
628
629impl<D: DirectoryWriter + 'static> PreparedSegment<D> {
630    fn metadata_entry(&self) -> (String, u32) {
631        (self.id.clone(), self.num_docs)
632    }
633
634    fn mark_published(&mut self) {
635        self.published = true;
636        // Metadata + SegmentTracker are now the durable lifecycle owners.
637        drop(self.operation.take());
638    }
639}
640
641impl<D: DirectoryWriter + 'static> WorkerState<D> {
642    fn record_cycle_error(&self, error: impl Into<String>) {
643        let mut first_error = self.cycle_error.lock();
644        if first_error.is_none() {
645            *first_error = Some(error.into());
646        }
647        drop(first_error);
648        self.cycle_failed.store(true, Ordering::Release);
649    }
650}
651
652impl<D: DirectoryWriter + 'static> Drop for PreparedSegment<D> {
653    fn drop(&mut self) {
654        if self.published {
655            return;
656        }
657        let Some(operation) = self.operation.take() else {
658            return;
659        };
660        self.segment_manager.schedule_unpublished_segment_cleanup(
661            self.segment_id,
662            operation,
663            self.runtime.clone(),
664        );
665    }
666}
667
668impl<D: DirectoryWriter + 'static> IndexWriter<D> {
669    /// Create a new index in the directory
670    pub async fn create(directory: D, schema: Schema, config: IndexConfig) -> Result<Self> {
671        let builder_config = default_builder_config(&config);
672        Self::create_with_config(directory, schema, config, builder_config).await
673    }
674
675    /// Create a new index with custom builder config
676    pub async fn create_with_config(
677        directory: D,
678        schema: Schema,
679        config: IndexConfig,
680        builder_config: SegmentBuilderConfig,
681    ) -> Result<Self> {
682        crate::dsl::reject_removed_vector_index_types(&schema).map_err(Error::Schema)?;
683        let directory = Arc::new(directory);
684        let schema = Arc::new(schema);
685        // Directory-layer metrics (cold writes, lazy reads) carry the index label
686        directory.set_index_label(schema.index_label());
687
688        // Refuse a second writer before touching any index state.
689        let writer_lock = try_acquire_writer_lock(directory.as_ref())?;
690        if let WriterLock::Unavailable { reason } = &writer_lock {
691            return Err(Error::Internal(reason.clone()));
692        }
693        // Refuse to clobber an existing index: persisting a fresh empty
694        // metadata.json would orphan every committed segment, and the next
695        // writer open's orphan sweep would permanently delete them.
696        if directory
697            .exists(std::path::Path::new(super::INDEX_META_FILENAME))
698            .await?
699        {
700            return Err(Error::Internal(format!(
701                "refusing to create index: {} already exists in this directory; \
702                 use IndexWriter::open to open the existing index, or delete the \
703                 directory first if you really want to start over",
704                super::INDEX_META_FILENAME
705            )));
706        }
707
708        let metadata = super::IndexMetadata::new((*schema).clone());
709
710        let segment_manager = Arc::new(crate::merge::SegmentManager::new(
711            Arc::clone(&directory),
712            Arc::clone(&schema),
713            metadata,
714            config.merge_policy.clone_box(),
715            config.term_cache_blocks,
716            config.max_concurrent_merges,
717            Arc::clone(&config.background_merge_permits),
718            config.merge_bp_time_budget,
719            config.bp_memory_budget_bytes,
720            Arc::clone(&config.background_reorder_permits),
721            config.background_reorder_pool.clone(),
722        ));
723        segment_manager.update_metadata(|_| {}).await?;
724
725        Ok(Self::new_with_parts(
726            directory,
727            schema,
728            config,
729            builder_config,
730            segment_manager,
731            writer_lock,
732        ))
733    }
734
735    /// Open an existing index for exclusive writing.
736    ///
737    /// Multiple independent writers for the same directory are unsupported;
738    /// for filesystem-rooted directories this is enforced with an advisory
739    /// single-writer lock ([`WRITER_LOCK_FILENAME`]) held for the writer's
740    /// lifetime. This path removes crash-leftover outputs before starting its
741    /// workers. Use [`Index::writer`](super::Index::writer) to share lifecycle
742    /// state with an already-open search index.
743    pub async fn open(directory: D, config: IndexConfig) -> Result<Self> {
744        let builder_config = default_builder_config(&config);
745        Self::open_with_config(directory, config, builder_config).await
746    }
747
748    /// Open an existing index with custom builder config
749    pub async fn open_with_config(
750        directory: D,
751        config: IndexConfig,
752        builder_config: SegmentBuilderConfig,
753    ) -> Result<Self> {
754        let directory = Arc::new(directory);
755
756        // The lock must be held before the orphan sweep below: sweeping while
757        // another process's writer is live deletes its in-flight outputs.
758        let writer_lock = try_acquire_writer_lock(directory.as_ref())?;
759        if let WriterLock::Unavailable { reason } = &writer_lock {
760            return Err(Error::Internal(reason.clone()));
761        }
762
763        let metadata = super::IndexMetadata::load(directory.as_ref()).await?;
764        let schema = Arc::new(metadata.schema.clone());
765        // Directory-layer metrics (cold writes, lazy reads) carry the index label
766        directory.set_index_label(schema.index_label());
767
768        let segment_manager = Arc::new(crate::merge::SegmentManager::new(
769            Arc::clone(&directory),
770            Arc::clone(&schema),
771            metadata,
772            config.merge_policy.clone_box(),
773            config.term_cache_blocks,
774            config.max_concurrent_merges,
775            Arc::clone(&config.background_merge_permits),
776            config.merge_bp_time_budget,
777            config.bp_memory_budget_bytes,
778            Arc::clone(&config.background_reorder_permits),
779            config.background_reorder_pool.clone(),
780        ));
781        let swept = segment_manager.cleanup_orphan_segments().await?;
782        if swept > 0 {
783            log::warn!(
784                "[segment_cleanup] swept {} orphan segment(s) while opening writer",
785                swept
786            );
787        }
788        segment_manager.try_load_and_publish_trained().await?;
789
790        Ok(Self::new_with_parts(
791            directory,
792            schema,
793            config,
794            builder_config,
795            segment_manager,
796            writer_lock,
797        ))
798    }
799
800    /// Create an IndexWriter from an existing Index.
801    /// Shares the SegmentManager for consistent segment lifecycle management.
802    ///
803    /// This constructor is infallible, so a single-writer lock conflict is
804    /// deferred: the returned writer fails loudly on its first mutating
805    /// operation instead of silently double-writing next to another writer.
806    pub fn from_index(index: &super::Index<D>) -> Self {
807        let writer_lock = match try_acquire_writer_lock(index.directory.as_ref()) {
808            Ok(lock) => lock,
809            Err(error) => WriterLock::Unavailable {
810                reason: format!("failed to acquire the single-writer lock: {error}"),
811            },
812        };
813        if let WriterLock::Unavailable { reason } = &writer_lock {
814            log::error!("[writer_lock] {reason}");
815        }
816        let builder_config = default_builder_config(&index.config);
817        Self::new_with_parts(
818            Arc::clone(&index.directory),
819            Arc::clone(&index.schema),
820            index.config.clone(),
821            builder_config,
822            Arc::clone(&index.segment_manager),
823            writer_lock,
824        )
825    }
826
827    // ========================================================================
828    // Construction + pipeline management
829    // ========================================================================
830
831    /// Common construction: creates worker state, spawns workers, assembles `Self`.
832    fn new_with_parts(
833        directory: Arc<D>,
834        schema: Arc<Schema>,
835        config: IndexConfig,
836        builder_config: SegmentBuilderConfig,
837        segment_manager: Arc<crate::merge::SegmentManager<D>>,
838        writer_lock: WriterLock,
839    ) -> Self {
840        // Auto-configure tokenizers from schema for all text fields
841        let registry = crate::tokenizer::TokenizerRegistry::new();
842        let mut tokenizers = FxHashMap::default();
843        for (field, entry) in schema.fields() {
844            if matches!(entry.field_type, crate::dsl::FieldType::Text)
845                && let Some(ref tok_name) = entry.tokenizer
846                && let Some(tok) = registry.get(tok_name)
847            {
848                tokenizers.insert(field, tok);
849            }
850        }
851
852        let num_workers = config.num_indexing_threads.max(1);
853        let worker_state = Arc::new(WorkerState {
854            directory: Arc::clone(&directory),
855            schema: Arc::clone(&schema),
856            builder_config,
857            tokenizers: parking_lot::RwLock::new(tokenizers),
858            memory_budget_per_worker: config.max_indexing_memory_bytes / num_workers,
859            segment_build_limiter: SegmentBuildLimiter::new(num_workers),
860            segment_manager: Arc::clone(&segment_manager),
861            built_segments: parking_lot::Mutex::new(Vec::new()),
862            cycle_error: parking_lot::Mutex::new(None),
863            cycle_failed: AtomicBool::new(false),
864            flush_count: AtomicUsize::new(0),
865            flush_mutex: parking_lot::Mutex::new(()),
866            flush_cvar: parking_lot::Condvar::new(),
867            resume_receiver: parking_lot::Mutex::new(None),
868            resume_epoch: AtomicUsize::new(0),
869            resume_cvar: parking_lot::Condvar::new(),
870            shutdown: AtomicBool::new(false),
871            num_workers,
872        });
873        let (doc_sender, workers) = Self::spawn_workers(&worker_state, num_workers);
874        let primary_key_index = Arc::new(parking_lot::RwLock::new(None));
875        let primary_key_refresh_lock = Arc::new(tokio::sync::Mutex::new(()));
876
877        Self {
878            directory,
879            schema,
880            config,
881            doc_sender: Arc::new(parking_lot::RwLock::new(doc_sender)),
882            workers,
883            worker_state,
884            segment_manager,
885            flushed_segments: Arc::new(parking_lot::Mutex::new(Vec::new())),
886            primary_key_index,
887            primary_key_refresh_lock,
888            commit_finalization: Arc::new(CommitFinalizationState::default()),
889            pk_reservations_retained: Arc::new(AtomicBool::new(false)),
890            writer_lock: parking_lot::RwLock::new(writer_lock),
891        }
892    }
893
894    /// Fail loudly when another writer owns the single-writer lock.
895    ///
896    /// A deferred conflict (`from_index` during a writer handover, e.g. a
897    /// rolling pod restart) is not permanent: the holder exits and the kernel
898    /// releases its advisory lock. Re-attempt acquisition on every call in
899    /// the `Unavailable` state so the writer recovers as soon as the lock
900    /// frees, instead of rejecting all writes for its lifetime.
901    fn ensure_writer_lock(&self) -> Result<()> {
902        // Fast path: uncontended read on the healthy states.
903        if !matches!(&*self.writer_lock.read(), WriterLock::Unavailable { .. }) {
904            return Ok(());
905        }
906
907        let mut lock = self.writer_lock.write();
908        // Another thread may have recovered while we waited for the write lock.
909        if !matches!(&*lock, WriterLock::Unavailable { .. }) {
910            return Ok(());
911        }
912        match try_acquire_writer_lock(self.directory.as_ref())? {
913            acquired @ (WriterLock::Held { .. } | WriterLock::NotApplicable) => {
914                log::info!(
915                    "[writer_lock] index={} single-writer lock acquired after retry; \
916                     the previous holder has released it — resuming writes",
917                    self.schema.index_label()
918                );
919                *lock = acquired;
920                Ok(())
921            }
922            WriterLock::Unavailable { reason } => {
923                let err = Error::Internal(reason.clone());
924                *lock = WriterLock::Unavailable { reason };
925                Err(err)
926            }
927        }
928    }
929
930    /// Clear primary-key reservations after an aborted or failed generation.
931    ///
932    /// Skipped while a failed post-commit PK refresh has left the uncommitted
933    /// reservations as the ONLY record of already-committed keys (fail-closed,
934    /// see `finalize_prepared_commit`): wiping them would admit duplicate
935    /// primary keys. Retaining the aborted generation's keys as well is
936    /// deliberately conservative — they clear on the next successful commit's
937    /// refresh.
938    fn clear_uncommitted_pk_reservations(&self) {
939        if self.pk_reservations_retained.load(Ordering::Acquire) {
940            log::warn!(
941                "[primary_key] index={} keeping uncommitted reservations through abort: a \
942                 failed post-commit refresh left them as the only record of \
943                 committed keys; they are cleared by the next successful commit",
944                self.schema.index_label()
945            );
946            return;
947        }
948        if let Some(pk_index) = self.primary_key_index.write().as_mut() {
949            pk_index.clear_uncommitted();
950        }
951    }
952
953    fn spawn_workers(
954        worker_state: &Arc<WorkerState<D>>,
955        num_workers: usize,
956    ) -> (
957        async_channel::Sender<Document>,
958        Vec<std::thread::JoinHandle<()>>,
959    ) {
960        let (sender, receiver) = async_channel::bounded(PIPELINE_MAX_SIZE_IN_DOCS);
961        let handle = tokio::runtime::Handle::current();
962        let mut workers = Vec::with_capacity(num_workers);
963        for i in 0..num_workers {
964            let state = Arc::clone(worker_state);
965            let rx = receiver.clone();
966            let rt = handle.clone();
967            workers.push(
968                std::thread::Builder::new()
969                    .name(format!("index-worker-{}", i))
970                    .spawn(move || Self::worker_loop(state, rx, rt, i))
971                    .expect("failed to spawn index worker thread"),
972            );
973        }
974        (sender, workers)
975    }
976
977    /// Get the schema
978    pub fn schema(&self) -> &Schema {
979        &self.schema
980    }
981
982    /// Set tokenizer for a field.
983    /// Propagated to worker threads — takes effect for the next SegmentBuilder they create.
984    pub fn set_tokenizer<T: crate::tokenizer::Tokenizer>(&mut self, field: Field, tokenizer: T) {
985        self.worker_state
986            .tokenizers
987            .write()
988            .insert(field, Box::new(tokenizer));
989    }
990
991    /// Initialize primary key deduplication from committed segments.
992    ///
993    /// Tries to load a cached bloom filter from `pk_bloom.bin` first. If the
994    /// cache covers all current segments, the bloom is reused directly (fast
995    /// path). If new segments appeared since the cache was written, only their
996    /// keys are iterated (incremental). Falls back to a full rebuild when no
997    /// cache exists.
998    ///
999    /// Only loads fast-field data (text dictionaries) per segment — NOT full
1000    /// `SegmentReader`s — to avoid duplicating dense/sparse index memory.
1001    ///
1002    /// The CPU-intensive bloom build is offloaded via `spawn_blocking` so it
1003    /// does not block the tokio runtime.
1004    ///
1005    /// No-op if schema has no primary field.
1006    pub async fn init_primary_key_dedup(&mut self) -> Result<()> {
1007        use super::primary_key::{PK_BLOOM_FILE, deserialize_pk_bloom};
1008
1009        self.commit_finalization.wait_until_idle().await;
1010        self.ensure_writer_lock()?;
1011
1012        let field = match self.schema.primary_field() {
1013            Some(f) => f,
1014            None => return Ok(()),
1015        };
1016
1017        // A merge/reorder replacement can publish while this initialization
1018        // performs async segment loads. Serialize both paths so an older
1019        // initialization snapshot cannot overwrite the replacement refresh
1020        // and keep retired source segments pinned indefinitely.
1021        let _refresh_guard = self.primary_key_refresh_lock.lock().await;
1022        {
1023            let callback_directory = Arc::clone(&self.directory);
1024            let callback_schema = Arc::clone(&self.schema);
1025            let callback_manager = Arc::downgrade(&self.segment_manager);
1026            let callback_primary_key = Arc::downgrade(&self.primary_key_index);
1027            let callback_refresh_lock = Arc::downgrade(&self.primary_key_refresh_lock);
1028            self.segment_manager.set_replacement_refresh(move || {
1029                let directory = Arc::clone(&callback_directory);
1030                let schema = Arc::clone(&callback_schema);
1031                let manager = callback_manager.clone();
1032                let primary_key = callback_primary_key.clone();
1033                let refresh_lock = callback_refresh_lock.clone();
1034                async move {
1035                    let (Some(manager), Some(primary_key), Some(refresh_lock)) = (
1036                        manager.upgrade(),
1037                        primary_key.upgrade(),
1038                        refresh_lock.upgrade(),
1039                    ) else {
1040                        return Ok(());
1041                    };
1042                    refresh_primary_key_snapshot(
1043                        &directory,
1044                        &schema,
1045                        &manager,
1046                        &primary_key,
1047                        &refresh_lock,
1048                        PrimaryKeyRefresh::Replacement,
1049                    )
1050                    .await
1051                }
1052            });
1053        }
1054
1055        let snapshot = self.segment_manager.acquire_snapshot().await;
1056        let current_seg_ids: Vec<String> = snapshot.segment_ids().to_vec();
1057
1058        // Try to load persisted bloom filter.
1059        let cached = match self
1060            .directory
1061            .open_read(std::path::Path::new(PK_BLOOM_FILE))
1062            .await
1063        {
1064            Ok(handle) => {
1065                let data = handle.read_bytes_range(0..handle.len()).await;
1066                match data {
1067                    Ok(bytes) => deserialize_pk_bloom(bytes.as_slice()),
1068                    Err(_) => None,
1069                }
1070            }
1071            Err(_) => None,
1072        };
1073
1074        // Load lightweight fast-field data for all segments concurrently.
1075        let load_futures: Vec<_> = current_seg_ids
1076            .iter()
1077            .map(|seg_id_str| {
1078                let seg_id_str = seg_id_str.clone();
1079                let dir = self.directory.as_ref();
1080                let schema = Arc::clone(&self.schema);
1081                async move { load_pk_segment_data(dir, &seg_id_str, &schema).await }
1082            })
1083            .collect();
1084        let all_data = futures::future::try_join_all(load_futures).await?;
1085
1086        if let Some((persisted_seg_ids, bloom)) = cached {
1087            // Partition: old segments (covered by bloom) first, new segments at end.
1088            let mut pk_data = Vec::with_capacity(all_data.len());
1089            let mut new_data = Vec::new();
1090            for d in all_data {
1091                if persisted_seg_ids.contains(&d.segment_id) {
1092                    pk_data.push(d);
1093                } else {
1094                    new_data.push(d);
1095                }
1096            }
1097            let needs_persist = !new_data.is_empty();
1098            let new_start = pk_data.len();
1099            pk_data.extend(new_data);
1100
1101            let pk_index = if new_start == pk_data.len() {
1102                // Fast path: all segments covered by cache.
1103                super::primary_key::PrimaryKeyIndex::from_persisted(field, bloom, pk_data, snapshot)
1104            } else {
1105                // Incremental: only iterate new segments' keys.
1106                let index_label = self.schema.index_label().to_owned();
1107                tokio::task::spawn_blocking(move || {
1108                    // Insert new segments' keys into the bloom, then construct
1109                    // PrimaryKeyIndex with the pre-populated bloom.
1110                    let mut bloom = bloom;
1111                    let mut added = 0usize;
1112                    let num_new = pk_data.len() - new_start;
1113                    for data in &pk_data[new_start..] {
1114                        if let Some(ff) = data.fast_fields.get(&field.0)
1115                            && let Some(dict) = ff.text_dict()
1116                        {
1117                            for key in dict.iter() {
1118                                bloom.insert(key.as_bytes());
1119                                added += 1;
1120                            }
1121                        }
1122                    }
1123                    if added > 0 {
1124                        log::info!(
1125                            "[primary_key] index={index_label} bloom: added {} keys from {} new segment(s)",
1126                            added,
1127                            num_new,
1128                        );
1129                    }
1130                    super::primary_key::PrimaryKeyIndex::from_persisted(
1131                        field, bloom, pk_data, snapshot,
1132                    )
1133                })
1134                .await
1135                .map_err(|e| Error::Internal(format!("spawn_blocking failed: {}", e)))?
1136            };
1137
1138            if needs_persist {
1139                self.persist_pk_bloom(&pk_index, &current_seg_ids).await;
1140            }
1141
1142            *self.primary_key_index.write() = Some(pk_index);
1143        } else {
1144            // No cache — full rebuild, offloaded to blocking thread.
1145            let pk_index = tokio::task::spawn_blocking(move || {
1146                super::primary_key::PrimaryKeyIndex::new(field, all_data, snapshot)
1147            })
1148            .await
1149            .map_err(|e| Error::Internal(format!("spawn_blocking failed: {}", e)))?;
1150
1151            self.persist_pk_bloom(&pk_index, &current_seg_ids).await;
1152            *self.primary_key_index.write() = Some(pk_index);
1153        }
1154
1155        // The freshly built index covers every committed segment, so any
1156        // reservations retained after a failed post-commit refresh are
1157        // superseded by committed_data.
1158        self.pk_reservations_retained
1159            .store(false, Ordering::Release);
1160
1161        Ok(())
1162    }
1163
1164    /// Persist the primary-key bloom filter to `pk_bloom.bin`.
1165    /// Best-effort: errors are logged but not propagated.
1166    async fn persist_pk_bloom(
1167        &self,
1168        pk_index: &super::primary_key::PrimaryKeyIndex,
1169        segment_ids: &[String],
1170    ) {
1171        use super::primary_key::PK_BLOOM_FILE;
1172
1173        let writer = match self
1174            .directory
1175            .streaming_writer(std::path::Path::new(PK_BLOOM_FILE))
1176            .await
1177        {
1178            Ok(writer) => writer,
1179            Err(error) => {
1180                log::warn!(
1181                    "[primary_key] index={} failed to open bloom cache: {}",
1182                    self.schema.index_label(),
1183                    error
1184                );
1185                return;
1186            }
1187        };
1188        let result = crate::segment::block_in_place_if_multithread(|| {
1189            write_pk_bloom_stream(pk_index, segment_ids, writer)
1190        });
1191        if let Err(e) = result {
1192            log::warn!(
1193                "[primary_key] index={} failed to persist bloom cache: {}",
1194                self.schema.index_label(),
1195                e
1196            );
1197        }
1198    }
1199
1200    /// Add a document to the indexing queue (sync, O(1)).
1201    ///
1202    /// `Document` is moved into the channel (zero-copy). Workers compete to pull it.
1203    /// Returns an explicit backpressure error when the queue is at capacity or
1204    /// a prepared commit generation is not yet resolved.
1205    pub fn add_document(&self, doc: Document) -> Result<()> {
1206        self.ensure_writer_lock()?;
1207        if self.worker_state.shutdown.load(Ordering::Acquire) {
1208            return Err(Error::IndexClosed);
1209        }
1210        if self.commit_finalization.in_progress.load(Ordering::Acquire) {
1211            return Err(Error::CommitInProgress);
1212        }
1213        let sender = self.doc_sender.read().clone();
1214        // A publication error deliberately leaves the prepared generation and
1215        // its workers paused for a lossless retry. Report this as backpressure
1216        // instead of inserting/rolling back a PK key against a closed channel.
1217        if sender.is_closed() {
1218            return Err(Error::CommitInProgress);
1219        }
1220        let primary_key_index = self.primary_key_index.read();
1221        if let Some(ref pk_index) = *primary_key_index {
1222            pk_index.check_and_insert(&doc)?;
1223        }
1224        match sender.try_send(doc) {
1225            Ok(()) => Ok(()),
1226            Err(async_channel::TrySendError::Full(doc)) => {
1227                // Roll back PK registration so the caller can retry later
1228                if let Some(ref pk_index) = *primary_key_index {
1229                    pk_index.rollback_uncommitted_key(&doc);
1230                }
1231                Err(Error::QueueFull)
1232            }
1233            Err(async_channel::TrySendError::Closed(doc)) => {
1234                // Roll back PK registration for defense-in-depth
1235                if let Some(ref pk_index) = *primary_key_index {
1236                    pk_index.rollback_uncommitted_key(&doc);
1237                }
1238                Err(Error::CommitInProgress)
1239            }
1240        }
1241    }
1242
1243    /// Add multiple documents to the indexing queue.
1244    ///
1245    /// Returns the number of documents successfully queued. Stops at the first
1246    /// backpressure error and returns the count queued so far.
1247    pub fn add_documents(&self, documents: Vec<Document>) -> Result<usize> {
1248        let total = documents.len();
1249        for (i, doc) in documents.into_iter().enumerate() {
1250            match self.add_document(doc) {
1251                Ok(()) => {}
1252                Err(Error::QueueFull | Error::CommitInProgress) => return Ok(i),
1253                Err(e) => return Err(e),
1254            }
1255        }
1256        Ok(total)
1257    }
1258
1259    // ========================================================================
1260    // Worker loop
1261    // ========================================================================
1262
1263    /// Worker loop — runs on a dedicated OS thread, survives across commits.
1264    ///
1265    /// Outer loop: each iteration processes one commit cycle.
1266    ///   Inner loop: pull documents from MPMC queue, index them, build segments
1267    ///   when memory budget is exceeded.
1268    ///   On channel close (prepare_commit): flush current builder, signal
1269    ///   flush_count, wait for resume with new receiver.
1270    ///   On shutdown (Drop): exit permanently.
1271    fn worker_loop(
1272        state: Arc<WorkerState<D>>,
1273        initial_receiver: async_channel::Receiver<Document>,
1274        handle: tokio::runtime::Handle,
1275        worker_id: usize,
1276    ) {
1277        let mut receiver = initial_receiver;
1278        let mut my_epoch = 0usize;
1279        let soft_flush_threshold =
1280            soft_flush_threshold(state.memory_budget_per_worker, worker_id, state.num_workers);
1281        let hard_flush_threshold = hard_flush_threshold(state.memory_budget_per_worker);
1282
1283        loop {
1284            // Wrap the recv+build phase in catch_unwind so a panic doesn't
1285            // prevent flush_count from being signaled (which would hang
1286            // prepare_commit forever).
1287            let build_result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
1288                let mut builder: Option<SegmentBuilder> = None;
1289
1290                while let Ok(doc) = receiver.recv_blocking() {
1291                    if state.shutdown.load(Ordering::Acquire) {
1292                        break;
1293                    }
1294                    // Another worker already invalidated this generation.
1295                    // Drain the shared queue so prepare_commit can complete,
1296                    // but do not spend CPU/RAM building outputs that must be
1297                    // discarded transactionally.
1298                    if state.cycle_failed.load(Ordering::Acquire) {
1299                        continue;
1300                    }
1301                    // Initialize builder if needed
1302                    if builder.is_none() {
1303                        match SegmentBuilder::new(
1304                            Arc::clone(&state.schema),
1305                            state.builder_config.clone(),
1306                        ) {
1307                            Ok(mut b) => {
1308                                for (field, tokenizer) in state.tokenizers.read().iter() {
1309                                    b.set_tokenizer(*field, tokenizer.clone_box());
1310                                }
1311                                builder = Some(b);
1312                            }
1313                            Err(e) => {
1314                                log::error!("Failed to create segment builder: {:?}", e);
1315                                state.record_cycle_error(format!(
1316                                    "failed to create segment builder: {e}"
1317                                ));
1318                                continue;
1319                            }
1320                        }
1321                    }
1322
1323                    let b = builder.as_mut().unwrap();
1324                    if let Err(e) = b.add_document(doc) {
1325                        log::error!("Failed to index document: {:?}", e);
1326                        state.record_cycle_error(format!("failed to index document: {e}"));
1327                        continue;
1328                    }
1329
1330                    let builder_memory = b.estimated_memory_bytes();
1331
1332                    if b.num_docs() & 0x3FFF == 0 {
1333                        log::debug!(
1334                            "[indexing] index={} docs={}, memory={}, budget={}",
1335                            state.schema.index_label(),
1336                            b.num_docs(),
1337                            crate::format_bytes(builder_memory as u64),
1338                            crate::format_bytes(state.memory_budget_per_worker as u64)
1339                        );
1340                    }
1341
1342                    // Require minimum 100 docs before flushing to avoid tiny segments
1343                    const MIN_DOCS_BEFORE_FLUSH: u32 = 100;
1344
1345                    if b.num_docs() >= MIN_DOCS_BEFORE_FLUSH
1346                        && let Some(_build_permit) = state.segment_build_limiter.reserve_if_due(
1347                            builder_memory,
1348                            soft_flush_threshold,
1349                            hard_flush_threshold,
1350                        )
1351                    {
1352                        log::info!(
1353                            "[indexing] index={} memory budget reached, building segment: \
1354                             worker={}, docs={}, memory={}, soft_budget={}, hard_budget={}",
1355                            state.schema.index_label(),
1356                            worker_id,
1357                            b.num_docs(),
1358                            crate::format_bytes(builder_memory as u64),
1359                            crate::format_bytes(soft_flush_threshold as u64),
1360                            crate::format_bytes(hard_flush_threshold as u64),
1361                        );
1362                        let full_builder = builder.take().unwrap();
1363                        Self::build_segment_inline(&state, full_builder, &handle);
1364                    }
1365                }
1366
1367                // Channel closed — flush current builder
1368                if !state.cycle_failed.load(Ordering::Acquire)
1369                    && let Some(b) = builder.take()
1370                    && b.num_docs() > 0
1371                {
1372                    let _build_permit = state.segment_build_limiter.acquire_flush();
1373                    Self::build_segment_inline(&state, b, &handle);
1374                }
1375            }));
1376
1377            if build_result.is_err() {
1378                log::error!(
1379                    "[worker] index={} panic during indexing cycle — documents in this cycle may be lost",
1380                    state.schema.index_label()
1381                );
1382                state.record_cycle_error("indexing worker panicked while building the batch");
1383            }
1384
1385            // Signal flush completion (always, even after panic — prevents
1386            // prepare_commit from hanging)
1387            let prev = state.flush_count.fetch_add(1, Ordering::Release);
1388            if prev + 1 == state.num_workers {
1389                // Last worker — wake prepare_commit. notify_all, not
1390                // notify_one: a cancelled commit leaves its detached
1391                // spawn_blocking waiter parked on this condvar, and with a
1392                // single notification that dead waiter would consume the
1393                // only wakeup, stalling a retried prepare_commit for its
1394                // full deadline.
1395                let _lock = state.flush_mutex.lock();
1396                state.flush_cvar.notify_all();
1397            }
1398
1399            // Wait for resume (new channel) or shutdown.
1400            // Check resume_epoch to avoid re-cloning a stale receiver from
1401            // a previous cycle.
1402            {
1403                let mut lock = state.resume_receiver.lock();
1404                loop {
1405                    if state.shutdown.load(Ordering::Acquire) {
1406                        return;
1407                    }
1408                    let current_epoch = state.resume_epoch.load(Ordering::Acquire);
1409                    if current_epoch > my_epoch
1410                        && let Some(rx) = lock.as_ref()
1411                    {
1412                        receiver = rx.clone();
1413                        my_epoch = current_epoch;
1414                        break;
1415                    }
1416                    state.resume_cvar.wait(&mut lock);
1417                }
1418            }
1419        }
1420    }
1421
1422    /// Build a segment on the worker thread. Uses `Handle::block_on()` to bridge
1423    /// into async context for I/O (streaming writers). CPU work (rayon) stays on
1424    /// the worker thread / rayon pool.
1425    fn build_segment_inline(
1426        state: &WorkerState<D>,
1427        builder: SegmentBuilder,
1428        handle: &tokio::runtime::Handle,
1429    ) {
1430        let segment_id = SegmentId::new();
1431        let segment_hex = segment_id.to_hex();
1432        // Claim the ID before the first file write. The guard is moved into
1433        // `PreparedSegment` on success and otherwise releases automatically.
1434        let operation = match state
1435            .segment_manager
1436            .protect_new_segment(segment_hex.clone())
1437        {
1438            Ok(operation) => operation,
1439            Err(e) => {
1440                log::error!(
1441                    "[segment_build_failed] index={} segment_id={} lifecycle_error={}",
1442                    state.schema.index_label(),
1443                    segment_hex,
1444                    e,
1445                );
1446                state.record_cycle_error(format!(
1447                    "failed to claim segment {segment_hex} for building: {e}"
1448                ));
1449                return;
1450            }
1451        };
1452        let trained = state.segment_manager.trained_for_segment_build();
1453        let doc_count = builder.num_docs();
1454        let build_start = std::time::Instant::now();
1455
1456        log::info!(
1457            "[segment_build] index={} segment_id={} doc_count={} ann={}",
1458            state.schema.index_label(),
1459            segment_hex,
1460            doc_count,
1461            trained.is_some()
1462        );
1463
1464        // Construct the cleanup owner before building. It keeps lifecycle
1465        // ownership through async deletion on ordinary error, abort, and
1466        // panic unwind; crash recovery is the only path left to the sweeper.
1467        let mut prepared = PreparedSegment {
1468            id: segment_hex.clone(),
1469            segment_id,
1470            num_docs: doc_count,
1471            segment_manager: Arc::clone(&state.segment_manager),
1472            operation: Some(operation),
1473            runtime: handle.clone(),
1474            needs_vector_upgrade: trained.is_none(),
1475            published: false,
1476        };
1477
1478        match handle.block_on(builder.build(
1479            state.directory.as_ref(),
1480            segment_id,
1481            trained.as_deref(),
1482        )) {
1483            Ok(meta) if meta.num_docs == doc_count && meta.num_docs > 0 => {
1484                let duration_ms = build_start.elapsed().as_millis() as u64;
1485                log::info!(
1486                    "[segment_build_done] index={} segment_id={} doc_count={} duration_ms={}",
1487                    state.schema.index_label(),
1488                    segment_hex,
1489                    meta.num_docs,
1490                    duration_ms,
1491                );
1492                prepared.num_docs = meta.num_docs;
1493                state.built_segments.lock().push(prepared);
1494            }
1495            Ok(meta) => {
1496                let error = format!(
1497                    "segment {segment_hex} built {} docs from a {doc_count}-document builder",
1498                    meta.num_docs
1499                );
1500                log::error!(
1501                    "[segment_build_failed] index={} {error}",
1502                    state.schema.index_label()
1503                );
1504                state.record_cycle_error(error);
1505            }
1506            Err(e) => {
1507                log::error!(
1508                    "[segment_build_failed] index={} segment_id={} error={:?}",
1509                    state.schema.index_label(),
1510                    segment_hex,
1511                    e
1512                );
1513                // `prepared` owns the lifecycle claim and schedules one
1514                // tracked, idempotent cleanup pass when this scope ends.
1515                state.record_cycle_error(format!("failed to build segment {segment_hex}: {e}"));
1516            }
1517        }
1518    }
1519
1520    // ========================================================================
1521    // Public API — commit, merge, etc.
1522    // ========================================================================
1523
1524    /// Check merge policy and spawn a background merge if needed.
1525    pub async fn maybe_merge(&self) {
1526        self.segment_manager.maybe_merge().await;
1527    }
1528
1529    /// Drain all in-flight merge tasks.
1530    /// Blocking merge phases cannot be cancelled safely once started.
1531    pub async fn abort_merges(&self) {
1532        self.segment_manager.abort_merges().await;
1533    }
1534
1535    /// Stop accepting lifecycle work, stop and join indexing workers, and
1536    /// discard unpublished segments. Index deletion calls this while holding
1537    /// the registry writer lock so in-flight requests finish first and stale
1538    /// writer Arcs cannot restart work afterward.
1539    pub async fn shutdown(&mut self) -> Result<()> {
1540        self.segment_manager.begin_shutdown();
1541        self.signal_worker_shutdown();
1542
1543        // A cancelled commit request leaves its owned finalizer running. Do not
1544        // clear shared PK/prepared state while that task may still publish or
1545        // refresh it. Worker shutdown is signalled first, so a successful
1546        // finalizer cannot restart ingestion while deletion is waiting.
1547        self.commit_finalization.wait_until_idle().await;
1548
1549        let workers = std::mem::take(&mut self.workers);
1550        let panicked = tokio::task::spawn_blocking(move || {
1551            workers
1552                .into_iter()
1553                .map(|worker| worker.join().is_err())
1554                .filter(|panicked| *panicked)
1555                .count()
1556        })
1557        .await
1558        .map_err(|error| Error::Internal(format!("failed to join index workers: {}", error)))?;
1559        if panicked > 0 {
1560            log::error!(
1561                "[index_shutdown] index={} {} indexing worker(s) panicked",
1562                self.schema.index_label(),
1563                panicked
1564            );
1565        }
1566
1567        // No commit is possible after shutdown. Dropping these RAII values
1568        // releases their lifecycle ownership before directory deletion.
1569        self.flushed_segments.lock().clear();
1570        self.worker_state.built_segments.lock().clear();
1571        if let Some(pk_index) = self.primary_key_index.write().as_mut() {
1572            pk_index.clear_uncommitted();
1573        }
1574        Ok(())
1575    }
1576
1577    /// Wait for the in-flight background merge to complete (if any).
1578    pub async fn wait_for_merging_thread(&self) {
1579        self.segment_manager.wait_for_merging_thread().await;
1580    }
1581
1582    /// Wait for all eligible merges to complete, including cascading merges.
1583    pub async fn wait_for_all_merges(&self) {
1584        self.segment_manager.wait_for_all_merges().await;
1585    }
1586
1587    /// Wait until an owned commit finalizer has reconciled durable metadata,
1588    /// primary-key state, and worker availability. Normally callers need not
1589    /// use this: it exists for orderly shutdown and request supervisors that
1590    /// want to observe completion after cancelling their original waiter.
1591    pub async fn wait_for_commit_finalization(&self) {
1592        self.commit_finalization.wait_until_idle().await;
1593    }
1594
1595    /// Get the segment tracker for sharing with readers.
1596    pub fn tracker(&self) -> std::sync::Arc<crate::segment::SegmentTracker> {
1597        self.segment_manager.tracker()
1598    }
1599
1600    /// Acquire a snapshot of current segments for reading.
1601    pub async fn acquire_snapshot(&self) -> crate::segment::SegmentSnapshot {
1602        self.segment_manager.acquire_snapshot().await
1603    }
1604
1605    /// Clean up orphan segment files not registered in metadata.
1606    ///
1607    /// Requires the single-writer lock: sweeping while another process's
1608    /// writer is live would delete its in-flight segment outputs.
1609    pub async fn cleanup_orphan_segments(&self) -> Result<usize> {
1610        self.ensure_writer_lock()?;
1611        self.segment_manager.cleanup_orphan_segments().await
1612    }
1613
1614    /// Prepare commit — signal workers to flush, wait for completion, collect segments.
1615    ///
1616    /// All documents sent via `add_document` before this call are guaranteed
1617    /// to be written to segment files on disk. Segments are NOT yet registered
1618    /// in metadata — call `PreparedCommit::commit()` for that.
1619    ///
1620    /// Workers are NOT destroyed — they flush their builders and wait for
1621    /// `resume_workers()` to give them a new channel.
1622    ///
1623    /// `add_document` returns `CommitInProgress` until commit/abort resumes workers.
1624    pub async fn prepare_commit(&mut self) -> Result<PreparedCommit<'_, D>> {
1625        self.ensure_writer_lock()?;
1626        if self.worker_state.shutdown.load(Ordering::Acquire) {
1627            return Err(Error::IndexClosed);
1628        }
1629        if self.commit_finalization.in_progress.load(Ordering::Acquire) {
1630            return Err(Error::CommitInProgress);
1631        }
1632        // 1. Close channel → workers drain remaining docs and flush builders
1633        self.doc_sender.read().close();
1634        self.worker_state.segment_build_limiter.begin_flush();
1635
1636        // Wake any workers still waiting on resume_cvar from previous cycle.
1637        // They'll clone the stale receiver, enter recv_blocking, get Err
1638        // immediately (sender already closed), flush, and signal completion.
1639        self.worker_state.resume_cvar.notify_all();
1640
1641        // 2. Wait for all workers to complete their flush (via spawn_blocking
1642        //    to avoid blocking the tokio runtime)
1643        let state = Arc::clone(&self.worker_state);
1644        let index_label = self.schema.index_label().to_owned();
1645        let all_flushed = tokio::task::spawn_blocking(move || {
1646            let mut lock = state.flush_mutex.lock();
1647            let deadline = std::time::Instant::now() + std::time::Duration::from_secs(300);
1648            while state.flush_count.load(Ordering::Acquire) < state.num_workers {
1649                let remaining = deadline.saturating_duration_since(std::time::Instant::now());
1650                if remaining.is_zero() {
1651                    log::error!(
1652                        "[prepare_commit] index={index_label} timed out waiting for workers: {}/{} flushed",
1653                        state.flush_count.load(Ordering::Acquire),
1654                        state.num_workers
1655                    );
1656                    return false;
1657                }
1658                state.flush_cvar.wait_for(&mut lock, remaining);
1659            }
1660            true
1661        })
1662        .await
1663        .map_err(|e| Error::Internal(format!("Failed to wait for workers: {}", e)))?;
1664
1665        if !all_flushed {
1666            // Keep this commit cycle paused. Resetting flush_count and handing
1667            // out a new receiver while an old worker is still building lets
1668            // that late worker increment the *next* cycle's counter. A later
1669            // prepare can then return before all of its workers flushed and
1670            // publish an incomplete set of segments. The caller may retry
1671            // prepare_commit; it will observe the same generation and collect
1672            // every completed output once the lagging worker finishes.
1673            return Err(Error::Internal(format!(
1674                "prepare_commit timed out: {}/{} workers flushed; writer remains paused, retry commit",
1675                self.worker_state.flush_count.load(Ordering::Acquire),
1676                self.worker_state.num_workers
1677            )));
1678        }
1679
1680        let cycle_error = { self.worker_state.cycle_error.lock().take() };
1681        if let Some(error) = cycle_error {
1682            // No partial publication: some documents in this generation no
1683            // longer exist in a worker builder, so successful sibling outputs
1684            // cannot be committed without violating commit's all-prior-docs
1685            // guarantee. Their RAII drops retain ownership through deletion.
1686            self.flushed_segments.lock().clear();
1687            self.worker_state.built_segments.lock().clear();
1688            self.clear_uncommitted_pk_reservations();
1689            self.resume_workers();
1690            return Err(Error::Internal(format!(
1691                "indexing generation failed; no documents from this batch were committed: {error}"
1692            )));
1693        }
1694
1695        // 3. Collect built segments
1696        let built = std::mem::take(&mut *self.worker_state.built_segments.lock());
1697        self.flushed_segments.lock().extend(built);
1698
1699        Ok(PreparedCommit {
1700            writer: self,
1701            is_resolved: false,
1702        })
1703    }
1704
1705    /// Commit (convenience): prepare_commit + commit in one call.
1706    ///
1707    /// Guarantees all prior `add_document` calls are committed.
1708    /// Vector training is decoupled — call `build_vector_index()` manually.
1709    pub async fn commit(&mut self) -> Result<bool> {
1710        self.prepare_commit().await?.commit().await
1711    }
1712
1713    /// Force merge all segments into one.
1714    pub async fn force_merge(&mut self) -> Result<()> {
1715        self.force_merge_with_snapshot_refresh(|| std::future::ready(Ok(())))
1716            .await
1717    }
1718
1719    /// Force merge while refreshing an external segment consumer after the
1720    /// background-merge drain and every durable replacement.
1721    ///
1722    /// Segment publication refreshes the writer's primary-key topology through
1723    /// the manager's lifecycle-owned hook. Servers use this callback to reload
1724    /// their cached `IndexReader` as well.
1725    pub async fn force_merge_with_snapshot_refresh<F, Fut>(
1726        &mut self,
1727        refresh_external: F,
1728    ) -> Result<()>
1729    where
1730        F: FnMut() -> Fut,
1731        Fut: std::future::Future<Output = Result<()>>,
1732    {
1733        self.prepare_commit().await?.commit().await?;
1734
1735        self.segment_manager
1736            .force_merge_with_snapshot_refresh(refresh_external)
1737            .await?;
1738
1739        // Segment IDs in the on-disk bloom cache need only the final
1740        // generation. Persisting the unchanged bloom after every hierarchy
1741        // level adds avoidable I/O on large primary-key indexes.
1742        self.persist_replacement_snapshot().await
1743    }
1744
1745    /// Reorder all segments via Recursive Graph Bisection (BP) for better BMP pruning.
1746    ///
1747    /// Each segment is individually rebuilt with record-level BP reordering:
1748    /// ordinals are shuffled across blocks so that similar content clusters tightly.
1749    pub async fn reorder(&mut self) -> Result<()> {
1750        self.reorder_with_snapshot_refresh(|| std::future::ready(Ok(())))
1751            .await
1752    }
1753
1754    /// Reorder while refreshing an external reader after each durable segment
1755    /// replacement, so retired sources are released during a long pass.
1756    pub async fn reorder_with_snapshot_refresh<F, Fut>(&mut self, refresh_external: F) -> Result<()>
1757    where
1758        F: FnMut() -> Fut,
1759        Fut: std::future::Future<Output = Result<()>>,
1760    {
1761        self.prepare_commit().await?.commit().await?;
1762
1763        self.segment_manager
1764            .reorder_segments_with_snapshot_refresh(refresh_external)
1765            .await?;
1766        self.persist_replacement_snapshot().await
1767    }
1768
1769    /// Persist the final topology after a bounded series of replacements.
1770    async fn persist_replacement_snapshot(&self) -> Result<()> {
1771        refresh_primary_key_snapshot(
1772            &self.directory,
1773            &self.schema,
1774            &self.segment_manager,
1775            &self.primary_key_index,
1776            &self.primary_key_refresh_lock,
1777            PrimaryKeyRefresh::FinalReplacement,
1778        )
1779        .await
1780    }
1781
1782    /// Get the segment manager (for background optimizer access).
1783    pub fn segment_manager(&self) -> &Arc<crate::merge::SegmentManager<D>> {
1784        &self.segment_manager
1785    }
1786
1787    /// Resume workers with a fresh channel. Called after commit or abort.
1788    ///
1789    /// Workers are already alive — just give them a new channel and wake them.
1790    /// If the tokio runtime has shut down (e.g., program exit), this is a no-op.
1791    fn resume_workers(&mut self) {
1792        Self::resume_workers_shared(&self.worker_state, &self.doc_sender);
1793    }
1794
1795    fn resume_workers_shared(
1796        worker_state: &Arc<WorkerState<D>>,
1797        doc_sender: &Arc<parking_lot::RwLock<async_channel::Sender<Document>>>,
1798    ) {
1799        if worker_state.shutdown.load(Ordering::Acquire) {
1800            return;
1801        }
1802        if tokio::runtime::Handle::try_current().is_err() {
1803            // Runtime is gone — signal permanent shutdown so workers don't
1804            // hang forever on resume_cvar.
1805            worker_state.shutdown.store(true, Ordering::Release);
1806            worker_state.resume_cvar.notify_all();
1807            return;
1808        }
1809
1810        // Reset flush count for next cycle
1811        worker_state.segment_build_limiter.end_flush();
1812        worker_state.flush_count.store(0, Ordering::Release);
1813        *worker_state.cycle_error.lock() = None;
1814        worker_state.cycle_failed.store(false, Ordering::Release);
1815
1816        // Create new channel
1817        let (sender, receiver) = async_channel::bounded(PIPELINE_MAX_SIZE_IN_DOCS);
1818        *doc_sender.write() = sender;
1819
1820        // Set new receiver, bump epoch, and wake all workers
1821        {
1822            let mut lock = worker_state.resume_receiver.lock();
1823            *lock = Some(receiver);
1824        }
1825        worker_state.resume_epoch.fetch_add(1, Ordering::Release);
1826        worker_state.resume_cvar.notify_all();
1827    }
1828
1829    fn signal_worker_shutdown(&self) {
1830        self.worker_state.shutdown.store(true, Ordering::Release);
1831        self.doc_sender.read().close();
1832        self.worker_state.segment_build_limiter.begin_flush();
1833        self.worker_state.resume_cvar.notify_all();
1834    }
1835
1836    // Vector index methods (build_vector_index, etc.) are in vector_builder.rs
1837}
1838
1839impl<D: DirectoryWriter + 'static> Drop for IndexWriter<D> {
1840    fn drop(&mut self) {
1841        self.signal_worker_shutdown();
1842        for w in std::mem::take(&mut self.workers) {
1843            let _ = w.join();
1844        }
1845    }
1846}
1847
1848/// A prepared commit that can be finalized or aborted.
1849///
1850/// Two-phase commit guard. Between `prepare_commit()` and
1851/// `commit()`/`abort()`, segments are on disk but NOT in metadata.
1852/// Dropping without calling either will auto-abort (discard segments,
1853/// respawn workers).
1854pub struct PreparedCommit<'a, D: DirectoryWriter + 'static> {
1855    writer: &'a mut IndexWriter<D>,
1856    is_resolved: bool,
1857}
1858
1859/// Returns prepared segments to the writer if an owned commit finalizer fails
1860/// or unwinds before it can establish that metadata owns them. Retrying commit
1861/// is safe even when publication actually won the race: `SegmentManager::commit`
1862/// is idempotent and the operation guards keep the files protected meanwhile.
1863struct PreparedSegmentsGuard<D: DirectoryWriter + 'static> {
1864    segments: Option<Vec<PreparedSegment<D>>>,
1865    retry_slot: Arc<parking_lot::Mutex<Vec<PreparedSegment<D>>>>,
1866}
1867
1868impl<D: DirectoryWriter + 'static> PreparedSegmentsGuard<D> {
1869    fn metadata_entries(&self) -> Vec<(String, u32)> {
1870        self.segments
1871            .as_deref()
1872            .unwrap_or_default()
1873            .iter()
1874            .map(PreparedSegment::metadata_entry)
1875            .collect()
1876    }
1877
1878    fn take_published(&mut self) -> Vec<PreparedSegment<D>> {
1879        self.segments.take().unwrap_or_default()
1880    }
1881
1882    fn vector_upgrade_segment_ids(&self) -> Vec<String> {
1883        self.segments
1884            .as_deref()
1885            .unwrap_or_default()
1886            .iter()
1887            .filter(|segment| segment.needs_vector_upgrade)
1888            .map(|segment| segment.id.clone())
1889            .collect()
1890    }
1891}
1892
1893impl<D: DirectoryWriter + 'static> Drop for PreparedSegmentsGuard<D> {
1894    fn drop(&mut self) {
1895        if let Some(segments) = self.segments.take() {
1896            self.retry_slot.lock().extend(segments);
1897        }
1898    }
1899}
1900
1901/// Couples completion of the owned commit task to writer availability. The
1902/// default is deliberately fail-closed: a pre-publication error or panic keeps
1903/// workers paused so the retained prepared generation can be retried. Only the
1904/// normal published path arms resumption.
1905struct CommitFinalizationGuard<D: DirectoryWriter + 'static> {
1906    state: Arc<CommitFinalizationState>,
1907    worker_state: Arc<WorkerState<D>>,
1908    doc_sender: Arc<parking_lot::RwLock<async_channel::Sender<Document>>>,
1909    resume_workers: bool,
1910}
1911
1912impl<D: DirectoryWriter + 'static> CommitFinalizationGuard<D> {
1913    fn resume_on_drop(&mut self) {
1914        self.resume_workers = true;
1915    }
1916}
1917
1918impl<D: DirectoryWriter + 'static> Drop for CommitFinalizationGuard<D> {
1919    fn drop(&mut self) {
1920        if self.resume_workers {
1921            IndexWriter::<D>::resume_workers_shared(&self.worker_state, &self.doc_sender);
1922        }
1923        self.state.finish();
1924    }
1925}
1926
1927/// Everything needed to finish one prepared generation is moved into this
1928/// value before spawning. Its two guards therefore reconcile segment
1929/// ownership and writer availability even if Tokio drops the task before its
1930/// first poll.
1931struct OwnedCommitFinalization<D: DirectoryWriter + 'static> {
1932    directory: Arc<D>,
1933    schema: Arc<Schema>,
1934    segment_manager: Arc<crate::merge::SegmentManager<D>>,
1935    primary_key_index: Arc<parking_lot::RwLock<Option<super::primary_key::PrimaryKeyIndex>>>,
1936    primary_key_refresh_lock: Arc<tokio::sync::Mutex<()>>,
1937    prepared: PreparedSegmentsGuard<D>,
1938    finalization: Option<CommitFinalizationGuard<D>>,
1939    publication_observed: Arc<AtomicBool>,
1940    pk_reservations_retained: Arc<AtomicBool>,
1941}
1942
1943#[derive(Clone, Copy)]
1944enum PrimaryKeyRefresh {
1945    /// A commit may introduce genuinely new keys and persists the cache.
1946    Commit,
1947    /// A merge/reorder only changes segment topology; keys are already in the
1948    /// monotonic bloom and the intermediate segment IDs need not be persisted.
1949    Replacement,
1950    /// Final topology refresh: still no key hashing, but persist the new set of
1951    /// segment IDs alongside the unchanged bloom.
1952    FinalReplacement,
1953}
1954
1955async fn refresh_primary_key_snapshot<D: DirectoryWriter + 'static>(
1956    directory: &Arc<D>,
1957    schema: &Arc<Schema>,
1958    segment_manager: &Arc<crate::merge::SegmentManager<D>>,
1959    primary_key_index: &Arc<parking_lot::RwLock<Option<super::primary_key::PrimaryKeyIndex>>>,
1960    primary_key_refresh_lock: &Arc<tokio::sync::Mutex<()>>,
1961    refresh: PrimaryKeyRefresh,
1962) -> Result<()> {
1963    let _refresh_guard = primary_key_refresh_lock.lock().await;
1964    let existing_ids: std::collections::HashSet<String> = {
1965        let guard = primary_key_index.read();
1966        let Some(pk_index) = guard.as_ref() else {
1967            return Ok(());
1968        };
1969        pk_index
1970            .committed_segment_ids()
1971            .map(ToOwned::to_owned)
1972            .collect()
1973    };
1974
1975    let snapshot = segment_manager.acquire_snapshot().await;
1976    let load_futures: Vec<_> = snapshot
1977        .segment_ids()
1978        .iter()
1979        .filter(|id| !existing_ids.contains(id.as_str()))
1980        .map(|seg_id_str| {
1981            let seg_id_str = seg_id_str.clone();
1982            let dir = directory.as_ref();
1983            let schema = Arc::clone(schema);
1984            async move { load_pk_segment_data(dir, &seg_id_str, &schema).await }
1985        })
1986        .collect();
1987    let new_data = futures::future::try_join_all(load_futures).await?;
1988    let seg_ids: Vec<String> = snapshot.segment_ids().to_vec();
1989
1990    let persist_bloom = {
1991        let mut guard = primary_key_index.write();
1992        let Some(pk_index) = guard.as_mut() else {
1993            return Ok(());
1994        };
1995        match refresh {
1996            PrimaryKeyRefresh::Commit => pk_index.refresh_incremental(new_data, snapshot),
1997            PrimaryKeyRefresh::Replacement | PrimaryKeyRefresh::FinalReplacement => {
1998                pk_index.refresh_replacement(new_data, snapshot);
1999            }
2000        }
2001        matches!(
2002            refresh,
2003            PrimaryKeyRefresh::Commit | PrimaryKeyRefresh::FinalReplacement
2004        )
2005    };
2006
2007    if persist_bloom {
2008        let writer = match directory
2009            .streaming_writer(std::path::Path::new(super::primary_key::PK_BLOOM_FILE))
2010            .await
2011        {
2012            Ok(writer) => writer,
2013            Err(error) => {
2014                log::warn!(
2015                    "[primary_key] index={} failed to open bloom cache: {}",
2016                    schema.index_label(),
2017                    error
2018                );
2019                return Ok(());
2020            }
2021        };
2022        // The outer read guard prevents replacement of the PK index while the
2023        // inner state lock streams its bloom. No corpus-sized Vec is created.
2024        let guard = primary_key_index.read();
2025        if let Some(pk_index) = guard.as_ref()
2026            && let Err(error) = crate::segment::block_in_place_if_multithread(|| {
2027                write_pk_bloom_stream(pk_index, &seg_ids, writer)
2028            })
2029        {
2030            log::warn!(
2031                "[primary_key] index={} failed to persist bloom cache: {}",
2032                schema.index_label(),
2033                error
2034            );
2035        }
2036    }
2037    Ok(())
2038}
2039
2040fn write_pk_bloom_stream(
2041    pk_index: &super::primary_key::PrimaryKeyIndex,
2042    segment_ids: &[String],
2043    mut writer: Box<dyn crate::directories::StreamingWriter>,
2044) -> std::io::Result<()> {
2045    pk_index.write_bloom_cache(segment_ids, writer.as_mut())?;
2046    writer.finish()
2047}
2048
2049async fn finalize_prepared_commit<D: DirectoryWriter + 'static>(
2050    mut commit: OwnedCommitFinalization<D>,
2051) -> Result<bool> {
2052    let metadata_entries = commit.prepared.metadata_entries();
2053    let published_segment_ids = commit.prepared.vector_upgrade_segment_ids();
2054
2055    // This entire future is owned by a Tokio task. Cancelling the RPC only
2056    // drops its JoinHandle; it cannot split durable metadata publication from
2057    // PK reservations or worker resumption.
2058    commit.segment_manager.commit(&metadata_entries).await?;
2059    commit.publication_observed.store(true, Ordering::Release);
2060
2061    let mut published = commit.prepared.take_published();
2062    for segment in &mut published {
2063        segment.mark_published();
2064    }
2065    drop(published);
2066    commit
2067        .segment_manager
2068        .schedule_vector_segment_upgrades(published_segment_ids);
2069    // Publication is irreversible. From here onward every exit path, including
2070    // panic unwind, must make the writer available again while PK reservations
2071    // remain fail-closed until refresh succeeds.
2072    if let Some(finalization) = commit.finalization.as_mut() {
2073        finalization.resume_on_drop();
2074    } else {
2075        log::error!("owned commit finalization guard was already released after publication");
2076    }
2077
2078    // Metadata publication is the commit point. Cache refresh is fail-closed:
2079    // retaining the generation's uncommitted keys may cause conservative
2080    // duplicate rejections, but can never admit a duplicate or turn a durable
2081    // commit into an API error.
2082    match refresh_primary_key_snapshot(
2083        &commit.directory,
2084        &commit.schema,
2085        &commit.segment_manager,
2086        &commit.primary_key_index,
2087        &commit.primary_key_refresh_lock,
2088        PrimaryKeyRefresh::Commit,
2089    )
2090    .await
2091    {
2092        // A successful refresh folded every committed key into committed_data
2093        // and cleared the reservations — nothing retained anymore.
2094        Ok(()) => commit
2095            .pk_reservations_retained
2096            .store(false, Ordering::Release),
2097        Err(error) => {
2098            // The retained reservations are now the ONLY record of the
2099            // published segments' keys. Abort paths must not clear them
2100            // (see clear_uncommitted_pk_reservations) or duplicates would
2101            // be admitted.
2102            commit
2103                .pk_reservations_retained
2104                .store(true, Ordering::Release);
2105            log::error!(
2106                "[primary_key] committed metadata but failed to refresh dedup state; \
2107                 retaining reservations until a later successful commit: {}",
2108                error,
2109            );
2110        }
2111    }
2112
2113    // Merge scheduling is optional post-commit work and may briefly wait on
2114    // manager state. Reconcile worker availability first so it cannot extend
2115    // ingestion backpressure after metadata and PK state already agree.
2116    drop(commit.finalization.take());
2117    commit.segment_manager.maybe_merge().await;
2118    Ok(true)
2119}
2120
2121impl<'a, D: DirectoryWriter + 'static> PreparedCommit<'a, D> {
2122    /// Finalize: register segments in metadata, evaluate merge policy, resume workers.
2123    ///
2124    /// Returns `true` if new segments were committed, `false` if nothing changed.
2125    pub async fn commit(mut self) -> Result<bool> {
2126        let segments = std::mem::take(&mut *self.writer.flushed_segments.lock());
2127
2128        // Fast path: nothing to commit
2129        if segments.is_empty() {
2130            log::debug!(
2131                "[commit] index={} no segments to commit, skipping",
2132                self.writer.schema.index_label()
2133            );
2134            self.is_resolved = true;
2135            self.writer.resume_workers();
2136            return Ok(false);
2137        }
2138
2139        if !self.writer.commit_finalization.begin() {
2140            self.writer.flushed_segments.lock().extend(segments);
2141            // Keep the prepared generation paused. Letting `Drop` auto-abort
2142            // here would delete the retryable segments owned by another
2143            // finalization state transition.
2144            self.is_resolved = true;
2145            return Err(Error::CommitInProgress);
2146        }
2147
2148        let publication_observed = Arc::new(AtomicBool::new(false));
2149        let owned = OwnedCommitFinalization {
2150            directory: Arc::clone(&self.writer.directory),
2151            schema: Arc::clone(&self.writer.schema),
2152            segment_manager: Arc::clone(&self.writer.segment_manager),
2153            primary_key_index: Arc::clone(&self.writer.primary_key_index),
2154            primary_key_refresh_lock: Arc::clone(&self.writer.primary_key_refresh_lock),
2155            prepared: PreparedSegmentsGuard {
2156                segments: Some(segments),
2157                retry_slot: Arc::clone(&self.writer.flushed_segments),
2158            },
2159            finalization: Some(CommitFinalizationGuard {
2160                state: Arc::clone(&self.writer.commit_finalization),
2161                worker_state: Arc::clone(&self.writer.worker_state),
2162                doc_sender: Arc::clone(&self.writer.doc_sender),
2163                resume_workers: false,
2164            }),
2165            publication_observed: Arc::clone(&publication_observed),
2166            pk_reservations_retained: Arc::clone(&self.writer.pk_reservations_retained),
2167        };
2168
2169        // From this point the owned value, not this cancel-sensitive guard,
2170        // controls every segment and the paused worker generation. Resolve the
2171        // local guard before spawning so even a runtime-spawn panic cannot
2172        // auto-abort the retryable generation during unwind.
2173        self.is_resolved = true;
2174        let task_publication = Arc::clone(&publication_observed);
2175        let task = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
2176            tokio::spawn(async move {
2177                match std::panic::AssertUnwindSafe(finalize_prepared_commit(owned))
2178                    .catch_unwind()
2179                    .await
2180                {
2181                    Ok(result) => result,
2182                    Err(_) if task_publication.load(Ordering::Acquire) => {
2183                        log::error!(
2184                            "owned commit finalizer panicked after metadata publication; \
2185                             treating the durable generation as committed"
2186                        );
2187                        Ok(true)
2188                    }
2189                    Err(_) => Err(Error::Internal(
2190                        "owned commit finalizer panicked before metadata publication".into(),
2191                    )),
2192                }
2193            })
2194        }))
2195        .map_err(|_| Error::Internal("runtime rejected owned commit finalizer".into()))?;
2196
2197        match task.await {
2198            Ok(result) => result,
2199            Err(error) if publication_observed.load(Ordering::Acquire) => {
2200                log::error!(
2201                    "owned commit finalizer terminated after metadata publication: {}; \
2202                     treating the durable generation as committed",
2203                    error,
2204                );
2205                Ok(true)
2206            }
2207            Err(error) => Err(Error::Internal(format!(
2208                "owned commit finalizer terminated unexpectedly: {error}"
2209            ))),
2210        }
2211    }
2212
2213    /// Abort: discard prepared segments, delete their files asynchronously,
2214    /// and resume workers. Lifecycle ownership is held until deletion ends.
2215    pub fn abort(mut self) {
2216        self.is_resolved = true;
2217        self.writer.flushed_segments.lock().clear();
2218        self.writer.clear_uncommitted_pk_reservations();
2219        self.writer.resume_workers();
2220    }
2221}
2222
2223impl<D: DirectoryWriter + 'static> Drop for PreparedCommit<'_, D> {
2224    fn drop(&mut self) {
2225        if !self.is_resolved {
2226            log::warn!("PreparedCommit dropped without commit/abort — auto-aborting");
2227            self.writer.flushed_segments.lock().clear();
2228            self.writer.clear_uncommitted_pk_reservations();
2229            self.writer.resume_workers();
2230        }
2231    }
2232}
2233
2234/// Load only fast-field data for a segment (lightweight alternative to full SegmentReader).
2235async fn load_pk_segment_data<D: crate::directories::Directory>(
2236    dir: &D,
2237    seg_id_str: &str,
2238    schema: &Arc<crate::dsl::Schema>,
2239) -> Result<super::primary_key::PkSegmentData> {
2240    let seg_id = crate::segment::SegmentId::from_hex(seg_id_str)
2241        .ok_or_else(|| Error::Internal(format!("Invalid segment id: {}", seg_id_str)))?;
2242    let files = crate::segment::SegmentFiles::new(seg_id.0);
2243    let fast_fields =
2244        crate::segment::reader::loader::load_fast_fields_file(dir, &files, schema).await?;
2245    Ok(super::primary_key::PkSegmentData {
2246        segment_id: seg_id_str.to_string(),
2247        fast_fields,
2248    })
2249}