Skip to main content

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