Skip to content

Commit 35f073f

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 35f073f

2 files changed

Lines changed: 127 additions & 51 deletions

File tree

src/indexer/index_writer.rs

Lines changed: 112 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -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.
5060
pub 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
}

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)