Resolves the 142 findings from tidal/docs/reviews/CODE_REVIEW_m0-m10.md across the engine, server, net, and CLI surfaces: - WAL/session-journal durability, checkpoint format, and crash-recovery hardening - Replication shipper/receiver, tenant isolation, and migration paths - Cluster scatter-gather, router, standalone server + health/offload endpoints - tidalctl refactored into command modules with JSON output and WAL-state tooling - Cohort, governance, signal-ledger, and vector-registry correctness fixes - Expanded UAT/integration/durability test coverage across all milestones
231 lines
8.8 KiB
Rust
231 lines
8.8 KiB
Rust
//! Background text-index syncer infrastructure.
|
|
//!
|
|
//! Opening a text index and running its syncer is split into two phases so the
|
|
//! crash-recovery rebuild-from-store can run while NO thread holds the Tantivy
|
|
//! writer lock:
|
|
//!
|
|
//! 1. [`open_text_syncer`] opens the `TextIndex` and creates the channels, but
|
|
//! does NOT spawn the syncer thread. The caller now holds the only handle to
|
|
//! the index and the writer lock is free.
|
|
//! 2. The caller runs `rebuild_from_store` (see `state_rebuild`) on the open
|
|
//! index — it acquires and releases the writer lock for the rebuild.
|
|
//! 3. [`TextSyncerPending::spawn`] starts the syncer thread, which acquires the
|
|
//! writer lock for its entire lifetime (Tantivy single-writer design).
|
|
//!
|
|
//! Spawning the thread *before* the rebuild deadlocks: the syncer holds the
|
|
//! writer lock forever, so `rebuild_from`'s `writer_guard()` blocks forever and
|
|
//! the open hangs. [`shutdown_text_syncer`] joins the thread on shutdown.
|
|
|
|
use std::sync::Arc;
|
|
|
|
use super::config::Config;
|
|
use crate::text::{PendingWrite, TextIndex, TextIndexConfig, TextIndexSyncer};
|
|
|
|
/// Result from spawning a background text syncer thread.
|
|
pub struct TextSyncerBundle {
|
|
pub index: Option<Arc<TextIndex>>,
|
|
pub write_tx: Option<crossbeam::channel::Sender<PendingWrite>>,
|
|
pub flush_tx: Option<crossbeam::channel::Sender<crossbeam::channel::Sender<()>>>,
|
|
pub thread: std::sync::Mutex<Option<std::thread::JoinHandle<crate::Result<()>>>>,
|
|
}
|
|
|
|
/// Phase 1 of text-syncer setup: an opened [`TextIndex`] plus its channels, with
|
|
/// NO syncer thread yet running.
|
|
///
|
|
/// While this value is held, the Tantivy writer lock is free, so the caller can
|
|
/// safely run the crash-recovery rebuild-from-store on [`index`](Self::index)
|
|
/// before [`spawn`](Self::spawn)ing the thread that takes the lock for life.
|
|
///
|
|
/// An empty `text_fields` set (no text index) yields a [`TextSyncerPending`] with
|
|
/// `index: None`; calling [`spawn`](Self::spawn) on it produces an empty
|
|
/// [`TextSyncerBundle`] (no thread, no channels).
|
|
pub struct TextSyncerPending {
|
|
index: Option<Arc<TextIndex>>,
|
|
/// `(write_rx, flush_tx, flush_rx, commit_n, commit_secs, thread_name)`, all
|
|
/// captured at open time and consumed by [`spawn`](Self::spawn). `None` when
|
|
/// there is no index.
|
|
spawn_inputs: Option<SpawnInputs>,
|
|
write_tx: Option<crossbeam::channel::Sender<PendingWrite>>,
|
|
flush_tx: Option<crossbeam::channel::Sender<crossbeam::channel::Sender<()>>>,
|
|
}
|
|
|
|
/// Inputs captured at open time and consumed when the syncer thread is spawned.
|
|
struct SpawnInputs {
|
|
write_rx: crossbeam::channel::Receiver<PendingWrite>,
|
|
flush_rx: crossbeam::channel::Receiver<crossbeam::channel::Sender<()>>,
|
|
commit_n: usize,
|
|
commit_secs: u64,
|
|
thread_name: String,
|
|
}
|
|
|
|
impl TextSyncerPending {
|
|
/// Borrow the opened index so the caller can run the rebuild-from-store while
|
|
/// no syncer thread holds the writer lock. `None` when no text fields were
|
|
/// declared (no index exists).
|
|
#[must_use]
|
|
pub const fn index(&self) -> Option<&Arc<TextIndex>> {
|
|
self.index.as_ref()
|
|
}
|
|
|
|
/// Phase 2: spawn the syncer thread for the opened index and return the
|
|
/// running [`TextSyncerBundle`].
|
|
///
|
|
/// MUST be called only after any rebuild-from-store on [`index`](Self::index)
|
|
/// has completed: the spawned syncer acquires the Tantivy writer lock for its
|
|
/// entire lifetime, so a rebuild attempted afterward would deadlock.
|
|
#[must_use]
|
|
pub fn spawn(self) -> TextSyncerBundle {
|
|
let (Some(index), Some(inputs)) = (self.index, self.spawn_inputs) else {
|
|
// No text index — nothing to spawn.
|
|
return TextSyncerBundle {
|
|
index: None,
|
|
write_tx: None,
|
|
flush_tx: None,
|
|
thread: std::sync::Mutex::new(None),
|
|
};
|
|
};
|
|
|
|
let idx_clone = Arc::clone(&index);
|
|
let SpawnInputs {
|
|
write_rx,
|
|
flush_rx,
|
|
commit_n,
|
|
commit_secs,
|
|
thread_name,
|
|
} = inputs;
|
|
|
|
let handle = match std::thread::Builder::new()
|
|
.name(thread_name)
|
|
.spawn(move || {
|
|
TextIndexSyncer::new(idx_clone, write_rx, commit_n, commit_secs)
|
|
.with_flush_rx(flush_rx)
|
|
.run()
|
|
}) {
|
|
Ok(h) => Some(h),
|
|
Err(e) => {
|
|
// Thread spawn failed (e.g. EAGAIN / resource limits). The bundle
|
|
// still carries the open index and live channels, but with no
|
|
// syncer thread draining them, queued writes never reach Tantivy.
|
|
// `.ok()` discarded this silently; log it so the disabled syncer
|
|
// is observable rather than a mystery stale index.
|
|
tracing::error!(
|
|
error = %e,
|
|
"text syncer thread spawn failed; index writes will not be committed"
|
|
);
|
|
None
|
|
}
|
|
};
|
|
|
|
TextSyncerBundle {
|
|
index: Some(index),
|
|
write_tx: self.write_tx,
|
|
flush_tx: self.flush_tx,
|
|
thread: std::sync::Mutex::new(handle),
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Phase 1 of background text-syncer setup: open the index and create channels,
|
|
/// WITHOUT spawning the syncer thread.
|
|
///
|
|
/// Creates a `TextIndex` (on-disk if `config.data_dir` is set, ephemeral
|
|
/// otherwise), a crossbeam channel for pending writes, and an optional flush
|
|
/// channel for synchronous commit requests. The syncer thread is NOT started —
|
|
/// call [`TextSyncerPending::spawn`] after running the crash-recovery rebuild on
|
|
/// [`TextSyncerPending::index`].
|
|
///
|
|
/// On empty `text_fields` (no text index) or an index-open failure, returns a
|
|
/// pending handle with `index: None` whose `spawn()` is an empty no-op bundle.
|
|
pub fn open_text_syncer(
|
|
text_fields: &[crate::schema::TextFieldDef],
|
|
config: &Config,
|
|
index_name: &str,
|
|
thread_name: &str,
|
|
) -> TextSyncerPending {
|
|
let empty = || TextSyncerPending {
|
|
index: None,
|
|
spawn_inputs: None,
|
|
write_tx: None,
|
|
flush_tx: None,
|
|
};
|
|
|
|
if text_fields.is_empty() {
|
|
return empty();
|
|
}
|
|
|
|
let text_config = config
|
|
.data_dir
|
|
.as_ref()
|
|
.map_or_else(TextIndexConfig::default, |dir| TextIndexConfig {
|
|
index_dir: dir.join(index_name),
|
|
..TextIndexConfig::default()
|
|
});
|
|
|
|
let index = match &config.data_dir {
|
|
Some(_) => TextIndex::open(text_config.clone(), text_fields),
|
|
None => TextIndex::ephemeral(text_fields),
|
|
};
|
|
|
|
let index = match index {
|
|
Ok(idx) => Arc::new(idx),
|
|
Err(e) => {
|
|
// A corrupt Tantivy directory, a permission error, or a full disk all
|
|
// surface here. Swallowing the error returned an empty bundle
|
|
// (`index: None`) that is INDISTINGUISHABLE from "no text fields
|
|
// declared", so a hard text-index failure silently disabled BM25
|
|
// search with no operator signal. Log it loudly before degrading.
|
|
tracing::error!(
|
|
index = index_name,
|
|
error = %e,
|
|
"text index open failed; text search disabled for this index"
|
|
);
|
|
return empty();
|
|
}
|
|
};
|
|
|
|
let (tx, rx) = crossbeam::channel::unbounded();
|
|
let (flush_tx, flush_rx) = crossbeam::channel::bounded::<crossbeam::channel::Sender<()>>(4);
|
|
|
|
TextSyncerPending {
|
|
index: Some(index),
|
|
spawn_inputs: Some(SpawnInputs {
|
|
write_rx: rx,
|
|
flush_rx,
|
|
commit_n: text_config.commit_every_n_docs,
|
|
commit_secs: text_config.commit_every_secs,
|
|
thread_name: thread_name.to_owned(),
|
|
}),
|
|
write_tx: Some(tx),
|
|
flush_tx: Some(flush_tx),
|
|
}
|
|
}
|
|
|
|
/// Drop the write-channel sender then join the syncer thread.
|
|
///
|
|
/// Used by `shutdown_inner` for both item and creator text syncers.
|
|
pub fn shutdown_text_syncer(
|
|
tx_mutex: &std::sync::Mutex<Option<crossbeam::channel::Sender<PendingWrite>>>,
|
|
thread_mutex: &std::sync::Mutex<Option<std::thread::JoinHandle<crate::Result<()>>>>,
|
|
label: &str,
|
|
) {
|
|
// Drop the sender to disconnect the channel -- the syncer flushes and exits.
|
|
let sender = match tx_mutex.lock() {
|
|
Ok(mut g) => g.take(),
|
|
Err(poisoned) => poisoned.into_inner().take(),
|
|
};
|
|
drop(sender);
|
|
|
|
// Join the syncer thread.
|
|
let thread = match thread_mutex.lock() {
|
|
Ok(mut g) => g.take(),
|
|
Err(poisoned) => poisoned.into_inner().take(),
|
|
};
|
|
if let Some(handle) = thread {
|
|
match handle.join() {
|
|
Ok(Ok(())) => {}
|
|
Ok(Err(e)) => tracing::error!(error = %e, "{label} syncer thread returned an error"),
|
|
Err(_) => tracing::error!("{label} syncer thread panicked"),
|
|
}
|
|
}
|
|
}
|