@@ -45,6 +45,17 @@ 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+ delete_cursor : DeleteCursor ,
57+ }
58+
4859#[ derive( Clone , bon:: Builder ) ]
4960/// A builder for creating a new [IndexWriter] for an index.
5061pub struct IndexWriterOptions {
@@ -78,6 +89,12 @@ pub struct IndexWriter<D: Document = TantivyDocument> {
7889 options : IndexWriterOptions ,
7990
8091 workers_join_handle : Vec < JoinHandle < crate :: Result < ( ) > > > ,
92+ /// Delivers each worker its next [`EpochTask`]; dropping the senders tells
93+ /// parked workers to exit.
94+ worker_epoch_senders : Vec < crossbeam_channel:: Sender < EpochTask < D > > > ,
95+ /// Flush acks for the in-flight epoch, waited on by `prepare_commit`. A
96+ /// crossbeam receiver (not a `FutureResult`) keeps `IndexWriter: Sync`.
97+ worker_flushes : Vec < crossbeam_channel:: Receiver < crate :: Result < ( ) > > > ,
8198
8299 index_writer_status : IndexWriterStatus < D > ,
83100 operation_sender : AddBatchSender < D > ,
@@ -327,6 +344,8 @@ impl<D: Document> IndexWriter<D> {
327344 segment_updater,
328345
329346 workers_join_handle : vec ! [ ] ,
347+ worker_epoch_senders : vec ! [ ] ,
348+ worker_flushes : vec ! [ ] ,
330349
331350 delete_queue,
332351
@@ -355,6 +374,8 @@ impl<D: Document> IndexWriter<D> {
355374 // this will stop the indexing thread,
356375 // dropping the last reference to the segment_updater.
357376 self . drop_sender ( ) ;
377+ // Close epoch channels so parked workers exit and can be joined.
378+ self . worker_epoch_senders . clear ( ) ;
358379
359380 let former_workers_handles = std:: mem:: take ( & mut self . workers_join_handle ) ;
360381 for join_handle in former_workers_handles {
@@ -409,54 +430,84 @@ impl<D: Document> IndexWriter<D> {
409430 } )
410431 }
411432
412- /// Spawns a new worker thread for indexing.
413- /// The thread consumes documents from the pipeline.
433+ /// Spawns a persistent indexing worker that lives for the writer's whole
434+ /// life: each epoch it drains an [`EpochTask`]'s channel, acks through
435+ /// `flushed`, and parks for the next. Replaces the former
436+ /// join-then-respawn per commit, whose fixed cost dominated high-rate
437+ /// commits.
414438 fn add_indexing_worker ( & mut self ) -> crate :: Result < ( ) > {
415- let document_receiver_clone = self . operation_receiver ( ) ?;
416439 let index_writer_bomb = self . index_writer_status . create_bomb ( ) ;
417440
418441 let segment_updater = self . segment_updater . clone ( ) ;
419442
420- let mut delete_cursor = self . delete_queue . cursor ( ) ;
421-
422443 let mem_budget = self . options . memory_budget_per_thread ;
423444 let index = self . index . clone ( ) ;
445+ let ( epoch_sender, epoch_receiver) = crossbeam_channel:: unbounded :: < EpochTask < D > > ( ) ;
446+ // Seed the first epoch with the writer's current operation channel.
447+ let ( flushed_sender, flushed) = crossbeam_channel:: bounded ( 1 ) ;
448+ epoch_sender
449+ . send ( EpochTask {
450+ receiver : self . operation_receiver ( ) ?,
451+ flushed : flushed_sender,
452+ delete_cursor : self . delete_queue . cursor ( ) ,
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 {
460+ receiver,
461+ flushed,
462+ mut delete_cursor,
463+ } ) = epoch_receiver. recv ( )
464+ {
465+ let epoch_result: crate :: Result < ( ) > = loop {
466+ let mut document_iterator = receiver
467+ . clone ( )
468+ . into_iter ( )
469+ . filter ( |batch| !batch. is_empty ( ) )
470+ . peekable ( ) ;
471+
472+ // The peeking here is to avoid creating a new segment's files
473+ // if no document are available.
474+ //
475+ // This is a valid guarantee as the peeked document now belongs to
476+ // our local iterator.
477+ if let Some ( batch) = document_iterator. peek ( ) {
478+ assert ! ( !batch. is_empty( ) ) ;
479+ delete_cursor. skip_to ( batch[ 0 ] . opstamp ) ;
480+ } else {
481+ // Channel drained and closed: flush barrier reached.
482+ break Ok ( ( ) ) ;
483+ }
484+
485+ if let Err ( err) = index_documents (
486+ mem_budget,
487+ index. new_segment ( ) ,
488+ & mut document_iterator,
489+ & segment_updater,
490+ delete_cursor. clone ( ) ,
491+ ) {
492+ break Err ( err) ;
493+ }
494+ } ;
495+ let failed = epoch_result. is_err ( ) ;
496+ // Forward the result verbatim; harmless if nobody waits.
497+ let _ = flushed. send ( epoch_result) ;
498+ if failed {
499+ // Leaving the bomb armed kills the writer.
500+ return Err ( error_in_index_worker_thread (
501+ "An indexing worker thread stopped after an indexing error" ,
502+ ) ) ;
448503 }
449-
450- index_documents (
451- mem_budget,
452- index. new_segment ( ) ,
453- & mut document_iterator,
454- & segment_updater,
455- delete_cursor. clone ( ) ,
456- ) ?;
457504 }
505+ index_writer_bomb. defuse ( ) ;
506+ Ok ( ( ) )
458507 } ) ?;
459508 self . worker_id += 1 ;
509+ self . worker_epoch_senders . push ( epoch_sender) ;
510+ self . worker_flushes . push ( flushed) ;
460511 self . workers_join_handle . push ( join_handle) ;
461512 Ok ( ( ) )
462513 }
@@ -542,15 +593,18 @@ impl<D: Document> IndexWriter<D> {
542593 /// and replace all the channels by new ones.
543594 ///
544595 /// The current workers will keep on indexing
545- /// the pending document and stop
596+ /// the pending document and seal their segment
546597 /// when no documents are remaining.
547598 ///
548- /// Returns the former segment_ready channel.
549- fn recreate_document_channel ( & mut self ) {
599+ /// Returns the new receiver, which `prepare_commit` hands to the workers
600+ /// as the next [`EpochTask`] after the current epoch flushes.
601+ fn recreate_document_channel ( & mut self ) -> AddBatchReceiver < D > {
550602 let ( document_sender, document_receiver) =
551603 crossbeam_channel:: bounded ( PIPELINE_MAX_SIZE_IN_DOCS ) ;
552604 self . operation_sender = document_sender;
553- self . index_writer_status = IndexWriterStatus :: from ( document_receiver) ;
605+ self . index_writer_status
606+ . replace_receiver ( document_receiver. clone ( ) ) ;
607+ document_receiver
554608 }
555609
556610 /// Rollback to the last commit
@@ -616,9 +670,9 @@ impl<D: Document> IndexWriter<D> {
616670 /// using this API.
617671 /// See [`PreparedCommit::set_payload()`].
618672 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.
673+ // Here, because we wait for all of the worker threads to reach the
674+ // epoch's flush barrier, all of the segment updates for this commit
675+ // have been sent.
622676 //
623677 // No document belonging to the next commit have been
624678 // pushed too, because add_document can only happen
@@ -628,18 +682,29 @@ impl<D: Document> IndexWriter<D> {
628682 // committed segments.
629683 info ! ( "Preparing commit" ) ;
630684
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 ( ) ?;
685+ // Closing the current epoch channel makes workers drain it, seal their
686+ // segments, ack, and park — replacing the old join-then-respawn per
687+ // commit. Waiting on the acks is the barrier.
688+ let document_receiver = self . recreate_document_channel ( ) ;
689+ for flushed in std:: mem:: take ( & mut self . worker_flushes ) {
690+ // A disconnected channel means the worker panicked before acking.
691+ flushed
692+ . recv ( )
693+ . map_err ( |_| TantivyError :: SystemError ( WORKER_FLUSH_FAILED . to_string ( ) ) ) ??;
694+ }
695+ // Hand every (parked) worker the next epoch's channel.
696+ for epoch_sender in & self . worker_epoch_senders {
697+ let ( flushed_sender, flushed) = crossbeam_channel:: bounded ( 1 ) ;
698+ epoch_sender
699+ . send ( EpochTask {
700+ receiver : document_receiver. clone ( ) ,
701+ flushed : flushed_sender,
702+ delete_cursor : self . delete_queue . cursor ( ) ,
703+ } )
704+ . map_err ( |_| {
705+ error_in_index_worker_thread ( "An indexing worker thread is not running" )
706+ } ) ?;
707+ self . worker_flushes . push ( flushed) ;
643708 }
644709
645710 let commit_opstamp = self . stamper . stamp ( ) ;
@@ -808,6 +873,8 @@ impl<D: Document> Drop for IndexWriter<D> {
808873 fn drop ( & mut self ) {
809874 self . segment_updater . kill ( ) ;
810875 self . drop_sender ( ) ;
876+ // Close epoch channels so parked workers exit and can be joined.
877+ self . worker_epoch_senders . clear ( ) ;
811878 for work in self . workers_join_handle . drain ( ..) {
812879 let _ = work. join ( ) ;
813880 }
0 commit comments