use std::path::PathBuf;
use std::sync::Arc;
use std::sync::{Condvar, Mutex};
use std::time::{Duration, Instant};
use indexmap::IndexMap;
use super::catalog::EmbeddingModel;
use super::fastembed::FastembedProvider;
use super::types::text_preview;
use super::types::{EmbeddingProvider, EmbeddingResult};
use crate::error::{KernelError, Result};
pub(crate) type LoadFn =
Arc<dyn Fn(EmbeddingModel, PathBuf) -> Result<FastembedProvider> + Send + Sync>;
fn default_load(model: EmbeddingModel, cache_dir: PathBuf) -> Result<FastembedProvider> {
FastembedProvider::new(model, Some(cache_dir))
}
fn load_catching_panics(
load: &LoadFn,
model: EmbeddingModel,
cache_dir: PathBuf,
) -> (Result<FastembedProvider>, bool) {
let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| load(model, cache_dir)));
match result {
Ok(inner) => (inner, false),
Err(payload) => {
let msg = panic_message(&payload);
(
Err(KernelError::Embedding(format!(
"model init panicked: {msg}"
))),
true,
)
}
}
}
fn panic_message(payload: &Box<dyn std::any::Any + Send>) -> String {
if let Some(s) = payload.downcast_ref::<&'static str>() {
(*s).to_string()
} else if let Some(s) = payload.downcast_ref::<String>() {
s.clone()
} else {
"<non-string panic payload>".to_string()
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ModelState {
NotLoaded,
Loading,
Cached,
Ready,
Disabled,
Failed {
message: String,
panicked: bool,
},
}
impl ModelState {
pub fn is_panic(&self) -> bool {
matches!(self, ModelState::Failed { panicked: true, .. })
}
}
#[derive(Debug, Clone)]
pub struct LazyOpts {
pub idle_timeout_secs: u64,
pub load_timeout_secs: u64,
pub query_cache_capacity: usize,
}
impl Default for LazyOpts {
fn default() -> Self {
Self {
idle_timeout_secs: 600,
load_timeout_secs: 300,
query_cache_capacity: 64,
}
}
}
pub struct EmbeddingCache {
capacity: usize,
map: IndexMap<String, Vec<f32>>,
}
impl EmbeddingCache {
pub fn new(capacity: usize) -> Self {
Self {
capacity,
map: IndexMap::new(),
}
}
pub fn get(&mut self, key: &str) -> Option<&Vec<f32>> {
if let Some(idx) = self.map.get_index_of(key) {
self.map.move_index(idx, self.map.len() - 1);
Some(&self.map[key])
} else {
None
}
}
pub fn insert(&mut self, key: String, value: Vec<f32>) {
if self.capacity == 0 {
return;
}
if let Some(idx) = self.map.get_index_of(&key) {
self.map.move_index(idx, self.map.len() - 1);
*self.map.get_mut(&key).unwrap() = value;
} else {
if self.map.len() >= self.capacity {
self.map.shift_remove_index(0);
}
self.map.insert(key, value);
}
}
}
struct Inner {
model: EmbeddingModel,
cache_dir: PathBuf,
state: ModelState,
provider: Option<FastembedProvider>,
last_used: Option<Instant>,
load_fn: LoadFn,
}
pub struct LazyFastembedProvider {
inner: Mutex<Inner>,
cvar: Condvar,
opts: LazyOpts,
query_cache: Mutex<EmbeddingCache>,
}
impl LazyFastembedProvider {
pub fn new(model: EmbeddingModel, cache_dir: PathBuf, opts: LazyOpts) -> Self {
Self::new_with_loader(model, cache_dir, opts, Arc::new(default_load))
}
pub fn new_with_loader(
model: EmbeddingModel,
cache_dir: PathBuf,
opts: LazyOpts,
load_fn: LoadFn,
) -> Self {
let initial_state = if is_model_cached(model, &cache_dir) {
ModelState::Cached
} else {
ModelState::NotLoaded
};
Self {
inner: Mutex::new(Inner {
model,
cache_dir,
state: initial_state,
provider: None,
last_used: None,
load_fn,
}),
cvar: Condvar::new(),
opts: opts.clone(),
query_cache: Mutex::new(EmbeddingCache::new(opts.query_cache_capacity)),
}
}
pub fn state(&self) -> ModelState {
let guard = self.inner.lock().unwrap_or_else(|e| e.into_inner());
guard.state.clone()
}
pub fn is_ready(&self) -> bool {
let guard = self.inner.lock().unwrap_or_else(|e| e.into_inner());
matches!(guard.state, ModelState::Ready)
}
pub fn reset(&self) {
let mut guard = self.inner.lock().unwrap_or_else(|e| e.into_inner());
if matches!(guard.state, ModelState::Failed { .. }) {
guard.state = if is_model_cached(guard.model, &guard.cache_dir) {
ModelState::Cached
} else {
ModelState::NotLoaded
};
}
}
pub fn ensure_model(&self) -> std::result::Result<(), String> {
let mut guard = self.inner.lock().unwrap_or_else(|e| e.into_inner());
if matches!(guard.state, ModelState::Ready)
&& let Some(last) = guard.last_used
&& self.opts.idle_timeout_secs > 0
&& last.elapsed().as_secs() > self.opts.idle_timeout_secs
{
guard.provider = None;
guard.state = ModelState::Cached;
guard.last_used = None;
}
match &guard.state {
ModelState::Ready => Ok(()),
ModelState::Disabled => Err("model is disabled".into()),
ModelState::Failed { message, .. } => Err(message.clone()),
ModelState::Loading => {
let timeout = Duration::from_secs(self.opts.load_timeout_secs);
let result = self
.cvar
.wait_timeout_while(guard, timeout, |g| matches!(g.state, ModelState::Loading));
match result {
Ok((mut g, timeout_result)) => {
if timeout_result.timed_out() {
if matches!(g.state, ModelState::Loading) {
g.state = ModelState::Failed {
message: "model loading timed out".into(),
panicked: false,
};
self.cvar.notify_all();
}
return Err("model loading timed out".into());
}
guard = g;
}
Err(e) => {
(guard, _) = e.into_inner();
}
}
match &guard.state {
ModelState::Ready => Ok(()),
ModelState::Failed { message, .. } => Err(message.clone()),
other => Err(format!("unexpected state after wait: {other:?}")),
}
}
ModelState::NotLoaded | ModelState::Cached => {
guard.state = ModelState::Loading;
let model = guard.model;
let cache_dir = guard.cache_dir.clone();
let load = Arc::clone(&guard.load_fn);
self.cvar.notify_all();
drop(guard);
let (result, panicked) = load_catching_panics(&load, model, cache_dir);
let mut guard = self.inner.lock().unwrap_or_else(|e| e.into_inner());
match result {
Ok(provider) => {
guard.state = ModelState::Ready;
guard.provider = Some(provider);
guard.last_used = Some(Instant::now());
self.cvar.notify_all();
Ok(())
}
Err(e) => {
guard.state = ModelState::Failed {
message: e.to_string(),
panicked,
};
self.cvar.notify_all();
Err(e.to_string())
}
}
}
}
}
}
impl EmbeddingProvider for LazyFastembedProvider {
fn dim(&self) -> usize {
let guard = self.inner.lock().unwrap_or_else(|e| e.into_inner());
guard.model.dimension()
}
fn name(&self) -> &str {
let guard = self.inner.lock().unwrap_or_else(|e| e.into_inner());
guard.model.as_str()
}
fn embed(&self, text: &str) -> Result<EmbeddingResult> {
{
let mut cache = self.query_cache.lock().unwrap_or_else(|e| e.into_inner());
if let Some(cached) = cache.get(text) {
return Ok(EmbeddingResult {
vector: cached.clone(),
text_preview: text_preview(text),
});
}
}
self.ensure_model().map_err(KernelError::Embedding)?;
let result = {
let mut guard = self.inner.lock().unwrap_or_else(|e| e.into_inner());
guard.last_used = Some(Instant::now());
match &guard.provider {
Some(p) => p.embed(text),
None => Err(KernelError::Embedding("provider not available".into())),
}
}?;
{
let mut cache = self.query_cache.lock().unwrap_or_else(|e| e.into_inner());
cache.insert(text.to_string(), result.vector.clone());
}
Ok(result)
}
fn embed_batch(&self, texts: &[&str]) -> Result<Vec<EmbeddingResult>> {
if texts.is_empty() {
return Ok(vec![]);
}
let mut results: Vec<Option<Vec<f32>>> = vec![None; texts.len()];
let mut miss_indices: Vec<usize> = Vec::with_capacity(texts.len());
let mut miss_texts: Vec<&str> = Vec::with_capacity(texts.len());
{
let mut cache = self.query_cache.lock().unwrap_or_else(|e| e.into_inner());
for (i, text) in texts.iter().enumerate() {
if let Some(cached) = cache.get(text) {
results[i] = Some(cached.clone());
} else {
miss_indices.push(i);
miss_texts.push(*text);
}
}
}
if miss_indices.is_empty() {
return Ok(results
.into_iter()
.zip(texts.iter())
.map(|(opt, text)| EmbeddingResult {
vector: opt.unwrap(),
text_preview: text_preview(text),
})
.collect());
}
self.ensure_model().map_err(KernelError::Embedding)?;
let batch_results = {
let mut guard = self.inner.lock().unwrap_or_else(|e| e.into_inner());
guard.last_used = Some(Instant::now());
match &guard.provider {
Some(p) => p.embed_batch(&miss_texts),
None => Err(KernelError::Embedding("provider not available".into())),
}
}?;
if batch_results.len() != miss_texts.len() {
return Err(KernelError::Embedding(format!(
"provider returned {} embeddings for {} inputs",
batch_results.len(),
miss_texts.len()
)));
}
{
let mut cache = self.query_cache.lock().unwrap_or_else(|e| e.into_inner());
for (batch_idx, &result_idx) in miss_indices.iter().enumerate() {
let vector = batch_results[batch_idx].vector.clone();
cache.insert(texts[result_idx].to_string(), vector.clone());
results[result_idx] = Some(vector);
}
}
results
.into_iter()
.zip(texts.iter())
.map(|(opt, text)| {
opt.map(|vector| EmbeddingResult {
vector,
text_preview: text_preview(text),
})
.ok_or_else(|| KernelError::Embedding("internal: unfilled embedding slot".into()))
})
.collect()
}
}
pub fn is_model_cached(model: EmbeddingModel, cache_dir: &std::path::Path) -> bool {
let model_code = model.model_code();
let mut parts = model_code.splitn(2, '/');
let org = parts.next().unwrap_or("");
let repo = parts.next().unwrap_or("");
if org.is_empty() || repo.is_empty() {
return false;
}
let folder_name = format!("models--{org}--{repo}");
cache_dir.join(&folder_name).is_dir()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn model_state_debug() {
assert_eq!(format!("{:?}", ModelState::NotLoaded), "NotLoaded");
assert_eq!(format!("{:?}", ModelState::Ready), "Ready");
}
#[test]
fn lazy_opts_default() {
let opts = LazyOpts::default();
assert_eq!(opts.idle_timeout_secs, 600);
assert_eq!(opts.load_timeout_secs, 300);
assert_eq!(opts.query_cache_capacity, 64);
}
#[test]
fn cache_hit_miss_eviction() {
let mut cache = EmbeddingCache::new(2);
assert!(cache.get("a").is_none());
cache.insert("a".into(), vec![1.0]);
assert!(cache.get("a").is_some());
assert!(cache.get("b").is_none());
cache.insert("b".into(), vec![2.0]);
assert!(cache.get("a").is_some()); cache.insert("c".into(), vec![3.0]); assert!(cache.get("b").is_none()); assert!(cache.get("a").is_some());
assert!(cache.get("c").is_some());
}
#[test]
fn cache_zero_capacity() {
let mut cache = EmbeddingCache::new(0);
cache.insert("a".into(), vec![1.0]);
assert!(cache.get("a").is_none());
}
#[test]
fn cache_update_existing() {
let mut cache = EmbeddingCache::new(2);
cache.insert("a".into(), vec![1.0]);
cache.insert("a".into(), vec![2.0]);
assert_eq!(cache.get("a").unwrap(), &vec![2.0]);
}
#[test]
fn is_model_cached_nonexistent() {
let dir = tempfile::tempdir().unwrap();
assert!(!is_model_cached(EmbeddingModel::BGESmallENV15, dir.path()));
}
#[test]
fn is_model_cached_existing() {
let dir = tempfile::tempdir().unwrap();
let model_code = EmbeddingModel::BGESmallENV15.model_code();
let parts: Vec<&str> = model_code.splitn(2, '/').collect();
let folder = dir
.path()
.join(format!("models--{}--{}", parts[0], parts[1]));
std::fs::create_dir_all(&folder).unwrap();
assert!(is_model_cached(EmbeddingModel::BGESmallENV15, dir.path()));
}
#[test]
fn constructor_is_instant_no_model() {
let dir = tempfile::tempdir().unwrap();
let provider = LazyFastembedProvider::new(
EmbeddingModel::BGESmallENV15,
dir.path().to_path_buf(),
LazyOpts::default(),
);
assert_eq!(provider.state(), ModelState::NotLoaded);
}
#[test]
fn constructor_detects_cached() {
let dir = tempfile::tempdir().unwrap();
let model_code = EmbeddingModel::BGESmallENV15.model_code();
let parts: Vec<&str> = model_code.splitn(2, '/').collect();
let folder = dir
.path()
.join(format!("models--{}--{}", parts[0], parts[1]));
std::fs::create_dir_all(&folder).unwrap();
let provider = LazyFastembedProvider::new(
EmbeddingModel::BGESmallENV15,
dir.path().to_path_buf(),
LazyOpts::default(),
);
assert_eq!(provider.state(), ModelState::Cached);
}
fn panicking_loader() -> LoadFn {
Arc::new(
|_model: EmbeddingModel, _cache: PathBuf| -> Result<FastembedProvider> {
panic!("simulated ort init failure (missing libonnxruntime.so)")
},
)
}
fn failing_loader(msg: &str) -> LoadFn {
let msg = msg.to_string();
Arc::new(
move |_model: EmbeddingModel, _cache: PathBuf| -> Result<FastembedProvider> {
Err(KernelError::Embedding(msg.clone()))
},
)
}
#[test]
fn panic_during_load_transitions_to_failed() {
let dir = tempfile::tempdir().unwrap();
let provider = LazyFastembedProvider::new_with_loader(
EmbeddingModel::BGESmallENV15,
dir.path().to_path_buf(),
LazyOpts::default(),
panicking_loader(),
);
assert_eq!(provider.state(), ModelState::NotLoaded);
let err = provider.ensure_model().unwrap_err();
assert!(
err.contains("model init panicked"),
"expected panic message, got: {err}"
);
match provider.state() {
ModelState::Failed { message, panicked } => {
assert!(
message.contains("model init panicked"),
"expected panic in Failed state, got: {message}"
);
assert!(
panicked,
"panic-originated failure should set panicked=true"
);
}
other => panic!("expected Failed state after panic, got {other:?}"),
}
assert!(provider.state().is_panic());
}
#[test]
fn failure_during_load_transitions_to_failed() {
let dir = tempfile::tempdir().unwrap();
let provider = LazyFastembedProvider::new_with_loader(
EmbeddingModel::BGESmallENV15,
dir.path().to_path_buf(),
LazyOpts::default(),
failing_loader("download denied"),
);
let err = provider.ensure_model().unwrap_err();
assert!(err.contains("download denied"), "got: {err}");
match provider.state() {
ModelState::Failed { panicked, .. } => {
assert!(!panicked, "ordinary Err should not set panicked=true");
}
other => panic!("expected Failed state, got {other:?}"),
}
assert!(!provider.state().is_panic());
}
#[test]
fn reset_after_failure_allows_retry() {
let dir = tempfile::tempdir().unwrap();
let attempts = Arc::new(std::sync::atomic::AtomicUsize::new(0));
let attempts_clone = Arc::clone(&attempts);
let loader: LoadFn = Arc::new(move |_m, _c| {
attempts_clone.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
Err(KernelError::Embedding("transient failure".into()))
});
let provider = LazyFastembedProvider::new_with_loader(
EmbeddingModel::BGESmallENV15,
dir.path().to_path_buf(),
LazyOpts::default(),
loader,
);
assert!(provider.ensure_model().is_err());
assert!(matches!(provider.state(), ModelState::Failed { .. }));
assert_eq!(attempts.load(std::sync::atomic::Ordering::SeqCst), 1);
assert!(provider.ensure_model().is_err());
assert_eq!(attempts.load(std::sync::atomic::Ordering::SeqCst), 1);
provider.reset();
assert_eq!(provider.state(), ModelState::NotLoaded);
assert!(provider.ensure_model().is_err());
assert_eq!(attempts.load(std::sync::atomic::Ordering::SeqCst), 2);
}
#[test]
fn reset_is_noop_when_not_failed() {
let dir = tempfile::tempdir().unwrap();
let provider = LazyFastembedProvider::new_with_loader(
EmbeddingModel::BGESmallENV15,
dir.path().to_path_buf(),
LazyOpts::default(),
failing_loader("irrelevant"),
);
assert_eq!(provider.state(), ModelState::NotLoaded);
provider.reset();
assert_eq!(provider.state(), ModelState::NotLoaded);
}
#[test]
fn concurrent_waiter_released_when_loader_panics() {
use std::sync::Barrier;
use std::thread;
let dir = tempfile::tempdir().unwrap();
let barrier = Arc::new(Barrier::new(2));
let loader_barrier = Arc::clone(&barrier);
let loader: LoadFn = Arc::new(move |_m, _c| {
loader_barrier.wait();
panic!("simulated ort init failure under contention");
});
let opts = LazyOpts {
load_timeout_secs: 30, ..LazyOpts::default()
};
let provider = Arc::new(LazyFastembedProvider::new_with_loader(
EmbeddingModel::BGESmallENV15,
dir.path().to_path_buf(),
opts,
loader,
));
let waiter_provider = Arc::clone(&provider);
let waiter = thread::spawn(move || waiter_provider.ensure_model());
barrier.wait();
let result = waiter.join().expect("waiter thread should not hang");
assert!(result.is_err(), "waiter should receive Err, not Ok");
assert!(
matches!(provider.state(), ModelState::Failed { .. }),
"provider should be Failed after loader panic, got {:?}",
provider.state()
);
assert!(provider.state().is_panic());
}
}