//! Shared admission for persistent inference sessions. This bounds admitted //! native worker threads, not all extraction/ANN/OS work. Already executing //! inference cannot be preempted; long audio windows remain a latency limit. use crate::EmbeddingProvider; use anyhow::{bail, Result}; use searchi_core::metrics::{SpanToken, TraceCollector}; use searchi_semantic::TextWindow; use std::{ collections::BTreeMap, path::Path, sync::{Arc, Condvar, Mutex}, time::{Duration, Instant}, }; #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum InferenceRole { Foreground, Background, } #[derive(Default)] struct State { limit: usize, used: usize, foreground: usize, } struct Shared { state: Mutex, changed: Condvar, } /// Clone this handle across query, setup and indexing sessions. #[derive(Clone)] pub struct InferenceBudget(Arc); impl InferenceBudget { pub fn new(requested: usize) -> Self { Self(Arc::new(Shared { state: Mutex::new(State { limit: clamp(requested), ..State::default() }), changed: Condvar::new(), })) } pub fn threads(&self) -> usize { self.0.state.lock().unwrap_or_else(|e| e.into_inner()).limit } /// Leave one admitted worker thread for background indexing. On a /// single-thread budget, already executing inference still cannot yield. pub fn worker_threads(&self, role: InferenceRole) -> usize { match role { InferenceRole::Foreground => self.threads().saturating_sub(1).max(1), InferenceRole::Background => 1, } } /// Update admission immediately; already admitted work is not cancelled. /// Recreate sessions whose worker thread count exceeds a reduced limit. pub fn configure(&self, requested: usize) -> usize { let limit = clamp(requested); self.0.state.lock().unwrap_or_else(|e| e.into_inner()).limit = limit; self.0.changed.notify_all(); limit } /// Register before waiting for a service mutex. This reserves priority, not /// threads, so the foreground does not deadlock with its indexing session. pub fn pending_foreground(&self) -> PendingForeground { self.0 .state .lock() .unwrap_or_else(|e| e.into_inner()) .foreground += 1; self.0.changed.notify_all(); PendingForeground(self.clone()) } pub fn acquire( &self, role: InferenceRole, actual_worker_threads: usize, ) -> Result { let weight = actual_worker_threads.max(1); let _pending = (role == InferenceRole::Foreground).then(|| self.pending_foreground()); let deadline = Instant::now() + Duration::from_secs(30); let mut state = self.0.state.lock().unwrap_or_else(|e| e.into_inner()); loop { if weight > state.limit { bail!("Inference session uses {weight} threads but shared budget is {}; recreate the session after changing its thread count", state.limit); } if state.used <= state.limit - weight && (role == InferenceRole::Foreground || state.foreground == 0) { state.used += weight; return Ok(InferencePermit { budget: self.clone(), weight, }); } let remaining = deadline.saturating_duration_since(Instant::now()); if remaining.is_zero() { bail!("Timed out waiting for shared inference budget"); } state = self .0 .changed .wait_timeout(state, remaining) .unwrap_or_else(|e| e.into_inner()) .0; } } } fn clamp(requested: usize) -> usize { requested .clamp(1, 8) .min(std::thread::available_parallelism().map_or(1, usize::from)) } pub struct PendingForeground(InferenceBudget); impl Drop for PendingForeground { fn drop(&mut self) { let mut s = self.0 .0.state.lock().unwrap_or_else(|e| e.into_inner()); s.foreground -= 1; self.0 .0.changed.notify_all(); } } pub struct InferencePermit { budget: InferenceBudget, weight: usize, } impl Drop for InferencePermit { fn drop(&mut self) { let mut s = self .budget .0 .state .lock() .unwrap_or_else(|e| e.into_inner()); s.used -= self.weight; self.budget.0.changed.notify_all(); } } /// Delegates genuine inference unchanged. Background documents yield between /// individual encodes even when the caller supplies a larger batch. pub struct BudgetedProvider { inner: Box, budget: InferenceBudget, role: InferenceRole, threads: usize, query_started: bool, } impl BudgetedProvider { pub fn new( inner: Box, budget: &InferenceBudget, role: InferenceRole, actual_worker_threads: usize, ) -> Self { Self { inner, budget: budget.clone(), role, threads: actual_worker_threads.max(1), query_started: false, } } fn permit(&self) -> Result { self.budget.acquire(self.role, self.threads) } } impl EmbeddingProvider for BudgetedProvider { fn document_batch_limit(&self) -> usize { if self.role == InferenceRole::Background { 1 } else { self.inner.document_batch_limit() } } fn fingerprint(&self) -> &str { self.inner.fingerprint() } fn dimensions(&self) -> usize { self.inner.dimensions() } fn split_windows(&mut self, text: &str, title: &str) -> Result> { let _p = self.permit()?; self.inner.split_windows(text, title) } fn embed_documents(&mut self, texts: &[&str], title: &str) -> Result>> { if self.role == InferenceRole::Background { let mut output = Vec::with_capacity(texts.len()); for text in texts { let _p = self.permit()?; output.extend(self.inner.embed_documents(&[*text], title)?); } Ok(output) } else { let _p = self.permit()?; self.inner.embed_documents(texts, title) } } fn embed_query(&mut self, query: &str) -> Result> { self.query_started = false; let _p = self.permit()?; self.query_started = true; self.inner.embed_query(query) } fn embed_query_debug(&mut self, query: &str) -> Result> { self.query_started = false; let _p = self.permit()?; self.query_started = true; self.inner.embed_query_debug(query) } fn embed_code_query(&mut self, query: &str) -> Result> { self.query_started = false; let _p = self.permit()?; self.query_started = true; self.inner.embed_code_query(query) } fn embed_code_query_debug(&mut self, query: &str) -> Result> { self.query_started = false; let _p = self.permit()?; self.query_started = true; self.inner.embed_code_query_debug(query) } fn query_diagnostics(&self) -> BTreeMap { if self.query_started { self.inner.query_diagnostics() } else { Default::default() } } fn embed_query_traced( &mut self, query: &str, debug: bool, trace: &mut TraceCollector, parent: Option, ) -> Result> { self.query_started = false; let span = trace.begin("semantic.inference.admission_wait", parent); let permit = self.permit(); trace.end( span, if trace.is_debug() { BTreeMap::from([ ("worker_threads".into(), self.threads as u64), ("failed".into(), u64::from(permit.is_err())), ]) } else { Default::default() }, ); let _permit = permit?; self.query_started = true; self.inner.embed_query_traced(query, debug, trace, parent) } fn embed_code_query_traced( &mut self, query: &str, debug: bool, trace: &mut TraceCollector, parent: Option, ) -> Result> { self.query_started = false; let span = trace.begin("semantic.inference.admission_wait", parent); let permit = self.permit(); trace.end( span, if trace.is_debug() { BTreeMap::from([ ("worker_threads".into(), self.threads as u64), ("failed".into(), u64::from(permit.is_err())), ]) } else { Default::default() }, ); let _permit = permit?; self.query_started = true; self.inner .embed_code_query_traced(query, debug, trace, parent) } fn embed_images(&mut self, paths: &[&Path], budget: u32) -> Result>> { if self.role == InferenceRole::Background { let mut output = Vec::with_capacity(paths.len()); for path in paths { let _p = self.permit()?; output.extend(self.inner.embed_images(&[*path], budget)?); } return Ok(output); } let _p = self.permit()?; self.inner.embed_images(paths, budget) } fn embed_audio(&mut self, pcm: &[f32]) -> Result> { let _p = self.permit()?; self.inner.embed_audio(pcm) } fn worker_info(&self) -> Option { self.inner.worker_info() } } #[cfg(test)] mod tests { use super::*; #[test] fn failed_admission_clears_prior_worker_diagnostics_without_calling_worker() { use searchi_core::metrics::MetricsMode; use std::sync::atomic::{AtomicUsize, Ordering}; struct ObservedInvocation(Arc); impl EmbeddingProvider for ObservedInvocation { fn fingerprint(&self) -> &str { "scheduler-test-only" } fn dimensions(&self) -> usize { 0 } fn split_windows(&mut self, _: &str, _: &str) -> Result> { unreachable!() } fn embed_documents(&mut self, _: &[&str], _: &str) -> Result>> { unreachable!() } fn embed_query(&mut self, _: &str) -> Result> { self.0.fetch_add(1, Ordering::SeqCst); Ok(Vec::new()) } fn embed_code_query(&mut self, query: &str) -> Result> { self.embed_query(query) } fn query_diagnostics(&self) -> BTreeMap { BTreeMap::from([("prior_worker_call".into(), 1)]) } } let budget = InferenceBudget::new(1); // Test worker weight two without requiring particular test host hardware. budget.0.state.lock().unwrap().limit = 2; let calls = Arc::new(AtomicUsize::new(0)); let mut provider = BudgetedProvider::new( Box::new(ObservedInvocation(calls.clone())), &budget, InferenceRole::Foreground, 2, ); provider .embed_query_debug("first admitted invocation") .unwrap(); assert_eq!(calls.load(Ordering::SeqCst), 1); assert_eq!(provider.query_diagnostics()["prior_worker_call"], 1); assert_eq!(budget.configure(1), 1); let mut trace = TraceCollector::new(MetricsMode::Debug); assert!(provider .embed_query_traced("rejected invocation", true, &mut trace, None) .is_err()); assert_eq!(calls.load(Ordering::SeqCst), 1); assert!(provider.query_diagnostics().is_empty()); let metrics = trace.finish(0); let stages = metrics.trace.unwrap(); assert_eq!(stages.len(), 1); assert_eq!(stages[0].name, "semantic.inference.admission_wait"); assert_eq!(stages[0].counters["failed"], 1); assert_eq!(stages[0].counters["worker_threads"], 2); assert!(stages[0].duration_ms >= 0.0); assert!(provider.embed_code_query_debug("also rejected").is_err()); assert!(provider.query_diagnostics().is_empty()); assert_eq!(calls.load(Ordering::SeqCst), 1); assert_eq!(budget.0.state.lock().unwrap().foreground, 0); assert_eq!(budget.0.state.lock().unwrap().used, 0); } #[test] fn reserved_query_capacity_admits_foreground_during_background() { let budget = InferenceBudget::new(1); // Deliberately exercise a two-thread scheduler independent of CI hardware. budget.0.state.lock().unwrap().limit = 2; assert_eq!(budget.worker_threads(InferenceRole::Foreground), 1); assert_eq!(budget.worker_threads(InferenceRole::Background), 1); let background = budget.acquire(InferenceRole::Background, 1).unwrap(); let foreground = budget.acquire(InferenceRole::Foreground, 1).unwrap(); assert_eq!(budget.0.state.lock().unwrap().used, 2); drop(foreground); drop(background); } #[test] fn pending_query_blocks_background_until_released() { let budget = InferenceBudget::new(1); let pending = budget.pending_foreground(); let other = budget.clone(); let (tx, rx) = std::sync::mpsc::channel(); let worker = std::thread::spawn(move || { let _permit = other.acquire(InferenceRole::Background, 1).unwrap(); tx.send(()).unwrap(); }); assert!(rx.recv_timeout(Duration::from_millis(50)).is_err()); let foreground = budget.acquire(InferenceRole::Foreground, 1).unwrap(); drop(pending); assert!(rx.recv_timeout(Duration::from_millis(50)).is_err()); drop(foreground); rx.recv_timeout(Duration::from_secs(2)).unwrap(); worker.join().unwrap(); } #[test] fn panic_releases_threads_and_priority() { let budget = InferenceBudget::new(1); let shared = budget.clone(); assert!(std::panic::catch_unwind(move || { let _pending = shared.pending_foreground(); let _permit = shared.acquire(InferenceRole::Foreground, 1).unwrap(); panic!("owned inference failure"); }) .is_err()); let _permit = budget.acquire(InferenceRole::Background, 1).unwrap(); } #[test] fn invalid_session_weight_fails_instead_of_oversubscribing() { let budget = InferenceBudget::new(1); assert!(budget.acquire(InferenceRole::Foreground, 2).is_err()); assert_eq!(budget.configure(0), 1); assert!(InferenceBudget::new(usize::MAX).threads() <= 8); } }