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