@@ -45,6 +45,16 @@ fn error_in_index_worker_thread(context: &str) -> TantivyError {
4545 ) )
4646}
4747
48+ const WORKER_FLUSH_FAILED : & str = "An indexing worker thread panicked before sealing its segments" ;
49+
50+ /// One commit epoch's work order for a persistent worker: drain `receiver`
51+ /// until it closes (sealing a segment when the memory budget trips), then ack
52+ /// through `flushed`. See [`IndexWriter::add_indexing_worker`].
53+ struct EpochTask < D : Document > {
54+ receiver : AddBatchReceiver < D > ,
55+ flushed : crossbeam_channel:: Sender < crate :: Result < ( ) > > ,
56+ }
57+
4858#[ derive( Clone , bon:: Builder ) ]
4959/// A builder for creating a new [IndexWriter] for an index.
5060pub struct IndexWriterOptions {
@@ -78,6 +88,12 @@ pub struct IndexWriter<D: Document = TantivyDocument> {
7888 options : IndexWriterOptions ,
7989
8090 workers_join_handle : Vec < JoinHandle < crate :: Result < ( ) > > > ,
91+ /// Delivers each worker its next [`EpochTask`]; dropping the senders tells
92+ /// parked workers to exit.
93+ worker_epoch_senders : Vec < crossbeam_channel:: Sender < EpochTask < D > > > ,
94+ /// Flush acks for the in-flight epoch, waited on by `prepare_commit`. A
95+ /// crossbeam receiver (not a `FutureResult`) keeps `IndexWriter: Sync`.
96+ worker_flushes : Vec < crossbeam_channel:: Receiver < crate :: Result < ( ) > > > ,
8197
8298 index_writer_status : IndexWriterStatus < D > ,
8399 operation_sender : AddBatchSender < D > ,
@@ -327,6 +343,8 @@ impl<D: Document> IndexWriter<D> {
327343 segment_updater,
328344
329345 workers_join_handle : vec ! [ ] ,
346+ worker_epoch_senders : vec ! [ ] ,
347+ worker_flushes : vec ! [ ] ,
330348
331349 delete_queue,
332350
@@ -355,6 +373,8 @@ impl<D: Document> IndexWriter<D> {
355373 // this will stop the indexing thread,
356374 // dropping the last reference to the segment_updater.
357375 self . drop_sender ( ) ;
376+ // Close epoch channels so parked workers exit and can be joined.
377+ self . worker_epoch_senders . clear ( ) ;
358378
359379 let former_workers_handles = std:: mem:: take ( & mut self . workers_join_handle ) ;
360380 for join_handle in former_workers_handles {
@@ -409,10 +429,12 @@ impl<D: Document> IndexWriter<D> {
409429 } )
410430 }
411431
412- /// Spawns a new worker thread for indexing.
413- /// The thread consumes documents from the pipeline.
432+ /// Spawns a persistent indexing worker that lives for the writer's whole
433+ /// life: each epoch it drains an [`EpochTask`]'s channel, acks through
434+ /// `flushed`, and parks for the next. Replaces the former
435+ /// join-then-respawn per commit, whose fixed cost dominated high-rate
436+ /// commits.
414437 fn add_indexing_worker ( & mut self ) -> crate :: Result < ( ) > {
415- let document_receiver_clone = self . operation_receiver ( ) ?;
416438 let index_writer_bomb = self . index_writer_status . create_bomb ( ) ;
417439
418440 let segment_updater = self . segment_updater . clone ( ) ;
@@ -421,42 +443,66 @@ impl<D: Document> IndexWriter<D> {
421443
422444 let mem_budget = self . options . memory_budget_per_thread ;
423445 let index = self . index . clone ( ) ;
446+ let ( epoch_sender, epoch_receiver) = crossbeam_channel:: unbounded :: < EpochTask < D > > ( ) ;
447+ // Seed the first epoch with the writer's current operation channel.
448+ let ( flushed_sender, flushed) = crossbeam_channel:: bounded ( 1 ) ;
449+ epoch_sender
450+ . send ( EpochTask {
451+ receiver : self . operation_receiver ( ) ?,
452+ flushed : flushed_sender,
453+ } )
454+ . expect ( "the worker's epoch receiver cannot be gone: we hold it" ) ;
424455 let join_handle: JoinHandle < crate :: Result < ( ) > > = thread:: Builder :: new ( )
425456 . name ( format ! ( "thrd-tantivy-index{}" , self . worker_id) )
426457 . spawn ( move || {
427- loop {
428- let mut document_iterator = document_receiver_clone
429- . clone ( )
430- . into_iter ( )
431- . filter ( |batch| !batch. is_empty ( ) )
432- . peekable ( ) ;
433-
434- // The peeking here is to avoid creating a new segment's files
435- // if no document are available.
436- //
437- // This is a valid guarantee as the peeked document now belongs to
438- // our local iterator.
439- if let Some ( batch) = document_iterator. peek ( ) {
440- assert ! ( !batch. is_empty( ) ) ;
441- delete_cursor. skip_to ( batch[ 0 ] . opstamp ) ;
442- } else {
443- // No more documents.
444- // It happens when there is a commit, or if the `IndexWriter`
445- // was dropped.
446- index_writer_bomb. defuse ( ) ;
447- return Ok ( ( ) ) ;
458+ // A closed epoch channel is the shutdown signal.
459+ while let Ok ( EpochTask { receiver, flushed } ) = epoch_receiver. recv ( ) {
460+ let epoch_result: crate :: Result < ( ) > = loop {
461+ let mut document_iterator = receiver
462+ . clone ( )
463+ . into_iter ( )
464+ . filter ( |batch| !batch. is_empty ( ) )
465+ . peekable ( ) ;
466+
467+ // The peeking here is to avoid creating a new segment's files
468+ // if no document are available.
469+ //
470+ // This is a valid guarantee as the peeked document now belongs to
471+ // our local iterator.
472+ if let Some ( batch) = document_iterator. peek ( ) {
473+ assert ! ( !batch. is_empty( ) ) ;
474+ delete_cursor. skip_to ( batch[ 0 ] . opstamp ) ;
475+ } else {
476+ // Channel drained and closed: flush barrier reached.
477+ break Ok ( ( ) ) ;
478+ }
479+
480+ if let Err ( err) = index_documents (
481+ mem_budget,
482+ index. new_segment ( ) ,
483+ & mut document_iterator,
484+ & segment_updater,
485+ delete_cursor. clone ( ) ,
486+ ) {
487+ break Err ( err) ;
488+ }
489+ } ;
490+ let failed = epoch_result. is_err ( ) ;
491+ // Forward the result verbatim; harmless if nobody waits.
492+ let _ = flushed. send ( epoch_result) ;
493+ if failed {
494+ // Leaving the bomb armed kills the writer.
495+ return Err ( error_in_index_worker_thread (
496+ "An indexing worker thread stopped after an indexing error" ,
497+ ) ) ;
448498 }
449-
450- index_documents (
451- mem_budget,
452- index. new_segment ( ) ,
453- & mut document_iterator,
454- & segment_updater,
455- delete_cursor. clone ( ) ,
456- ) ?;
457499 }
500+ index_writer_bomb. defuse ( ) ;
501+ Ok ( ( ) )
458502 } ) ?;
459503 self . worker_id += 1 ;
504+ self . worker_epoch_senders . push ( epoch_sender) ;
505+ self . worker_flushes . push ( flushed) ;
460506 self . workers_join_handle . push ( join_handle) ;
461507 Ok ( ( ) )
462508 }
@@ -542,15 +588,18 @@ impl<D: Document> IndexWriter<D> {
542588 /// and replace all the channels by new ones.
543589 ///
544590 /// The current workers will keep on indexing
545- /// the pending document and stop
591+ /// the pending document and seal their segment
546592 /// when no documents are remaining.
547593 ///
548- /// Returns the former segment_ready channel.
549- fn recreate_document_channel ( & mut self ) {
594+ /// Returns the new receiver, which `prepare_commit` hands to the workers
595+ /// as the next [`EpochTask`] after the current epoch flushes.
596+ fn recreate_document_channel ( & mut self ) -> AddBatchReceiver < D > {
550597 let ( document_sender, document_receiver) =
551598 crossbeam_channel:: bounded ( PIPELINE_MAX_SIZE_IN_DOCS ) ;
552599 self . operation_sender = document_sender;
553- self . index_writer_status = IndexWriterStatus :: from ( document_receiver) ;
600+ self . index_writer_status
601+ . replace_receiver ( document_receiver. clone ( ) ) ;
602+ document_receiver
554603 }
555604
556605 /// Rollback to the last commit
@@ -616,9 +665,9 @@ impl<D: Document> IndexWriter<D> {
616665 /// using this API.
617666 /// See [`PreparedCommit::set_payload()`].
618667 pub fn prepare_commit ( & mut self ) -> crate :: Result < PreparedCommit < ' _ , D > > {
619- // Here, because we join all of the worker threads,
620- // all of the segment update for this commit have been
621- // sent.
668+ // Here, because we wait for all of the worker threads to reach the
669+ // epoch's flush barrier, all of the segment updates for this commit
670+ // have been sent.
622671 //
623672 // No document belonging to the next commit have been
624673 // pushed too, because add_document can only happen
@@ -628,18 +677,28 @@ impl<D: Document> IndexWriter<D> {
628677 // committed segments.
629678 info ! ( "Preparing commit" ) ;
630679
631- // this will drop the current document channel
632- // and recreate a new one.
633- self . recreate_document_channel ( ) ;
634-
635- let former_workers_join_handle = std:: mem:: take ( & mut self . workers_join_handle ) ;
636-
637- for worker_handle in former_workers_join_handle {
638- let indexing_worker_result = worker_handle
639- . join ( )
640- . map_err ( |e| TantivyError :: ErrorInThread ( format ! ( "{e:?}" ) ) ) ?;
641- indexing_worker_result?;
642- self . add_indexing_worker ( ) ?;
680+ // Closing the current epoch channel makes workers drain it, seal their
681+ // segments, ack, and park — replacing the old join-then-respawn per
682+ // commit. Waiting on the acks is the barrier.
683+ let document_receiver = self . recreate_document_channel ( ) ;
684+ for flushed in std:: mem:: take ( & mut self . worker_flushes ) {
685+ // A disconnected channel means the worker panicked before acking.
686+ flushed
687+ . recv ( )
688+ . map_err ( |_| TantivyError :: SystemError ( WORKER_FLUSH_FAILED . to_string ( ) ) ) ??;
689+ }
690+ // Hand every (parked) worker the next epoch's channel.
691+ for epoch_sender in & self . worker_epoch_senders {
692+ let ( flushed_sender, flushed) = crossbeam_channel:: bounded ( 1 ) ;
693+ epoch_sender
694+ . send ( EpochTask {
695+ receiver : document_receiver. clone ( ) ,
696+ flushed : flushed_sender,
697+ } )
698+ . map_err ( |_| {
699+ error_in_index_worker_thread ( "An indexing worker thread is not running" )
700+ } ) ?;
701+ self . worker_flushes . push ( flushed) ;
643702 }
644703
645704 let commit_opstamp = self . stamper . stamp ( ) ;
@@ -808,6 +867,8 @@ impl<D: Document> Drop for IndexWriter<D> {
808867 fn drop ( & mut self ) {
809868 self . segment_updater . kill ( ) ;
810869 self . drop_sender ( ) ;
870+ // Close epoch channels so parked workers exit and can be joined.
871+ self . worker_epoch_senders . clear ( ) ;
811872 for work in self . workers_join_handle . drain ( ..) {
812873 let _ = work. join ( ) ;
813874 }
0 commit comments