Skip to main content

hermes_core/index/
writer.rs

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