Skip to content

Commit 039e02e

Browse files
committed
Reuse indexing worker threads across commits
prepare_commit() used to join every indexing worker thread and spawn replacements on each commit. The join + thread spawn is a fixed cost of a few milliseconds per commit, which dominates commit latency for near-real-time writers that commit small batches at a high rate (e.g. an in-RAM overlay index committing every few milliseconds); in profiles of such a workload, half the committing thread's time was spent in Thread::join. Workers are now persistent: they live for the IndexWriter's whole life, and a commit is an epoch barrier instead of a thread lifecycle event. Each worker owns a channel of EpochTask { receiver, flushed }. prepare_commit() swaps in a fresh operation channel (closing the current one), waits on every worker's "flushed" ack (a FutureResult, the same primitive the segment updater already uses for scheduling), then hands each worker the next epoch's receiver. Behavior is preserved: - a worker's indexing error is sent through its ack and returned from prepare_commit() verbatim, as the former join-based code did; - a worker panic drops the ack sender, which FutureResult reports as an error (and the existing bomb still kills the writer), so the committer errors instead of hanging; - Drop, wait_merging_threads() and rollback() drop the epoch channels, so parked workers exit and joins complete as before. IndexWriterStatus now swaps the receiver in place (replace_receiver) instead of being recreated per commit, so the bombs held by the persistent workers stay armed across commits.
1 parent 057458b commit 039e02e

2 files changed

Lines changed: 135 additions & 53 deletions

File tree

src/indexer/index_writer.rs

Lines changed: 120 additions & 53 deletions
Original file line numberDiff line numberDiff line change
@@ -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.
5061
pub 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
}

src/indexer/index_writer_status.rs

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,21 @@ impl<D: Document> IndexWriterStatus<D> {
3434
inner: Some(self.inner.clone()),
3535
}
3636
}
37+
38+
/// Replaces the operation receiver in place (a commit installs the next
39+
/// epoch's channel). Keeps the same `Inner`, so the bombs handed to the
40+
/// persistent indexing workers stay armed across commits. No-op if the
41+
/// writer was killed.
42+
pub(crate) fn replace_receiver(&self, receiver: AddBatchReceiver<D>) {
43+
let mut wlock = self
44+
.inner
45+
.receive_channel
46+
.write()
47+
.expect("This lock should never be poisoned");
48+
if self.inner.is_alive() {
49+
*wlock = Some(receiver);
50+
}
51+
}
3752
}
3853

3954
struct Inner<D: Document> {

0 commit comments

Comments
 (0)