1use std::path::PathBuf;
23use std::sync::Arc;
24use std::sync::{Condvar, Mutex};
25use std::time::{Duration, Instant};
26
27use indexmap::IndexMap;
28
29use super::catalog::EmbeddingModel;
30use super::fastembed::FastembedProvider;
31use super::types::text_preview;
32use super::types::{EmbeddingProvider, EmbeddingResult};
33use crate::error::{KernelError, Result};
34
35pub(crate) type LoadFn =
45 Arc<dyn Fn(EmbeddingModel, PathBuf) -> Result<FastembedProvider> + Send + Sync>;
46
47fn default_load(model: EmbeddingModel, cache_dir: PathBuf) -> Result<FastembedProvider> {
54 FastembedProvider::new(model, Some(cache_dir))
55}
56
57fn load_catching_panics(
74 load: &LoadFn,
75 model: EmbeddingModel,
76 cache_dir: PathBuf,
77) -> (Result<FastembedProvider>, bool) {
78 let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| load(model, cache_dir)));
79 match result {
80 Ok(inner) => (inner, false),
81 Err(payload) => {
82 let msg = panic_message(&payload);
83 (
84 Err(KernelError::Embedding(format!(
85 "model init panicked: {msg}"
86 ))),
87 true,
88 )
89 }
90 }
91}
92
93fn panic_message(payload: &Box<dyn std::any::Any + Send>) -> String {
95 if let Some(s) = payload.downcast_ref::<&'static str>() {
96 (*s).to_string()
97 } else if let Some(s) = payload.downcast_ref::<String>() {
98 s.clone()
99 } else {
100 "<non-string panic payload>".to_string()
101 }
102}
103#[derive(Debug, Clone, PartialEq, Eq)]
109pub enum ModelState {
110 NotLoaded,
112 Loading,
114 Cached,
116 Ready,
118 Disabled,
120 Failed {
130 message: String,
132 panicked: bool,
135 },
136}
137
138impl ModelState {
139 pub fn is_panic(&self) -> bool {
142 matches!(self, ModelState::Failed { panicked: true, .. })
143 }
144}
145
146#[derive(Debug, Clone)]
152pub struct LazyOpts {
153 pub idle_timeout_secs: u64,
156 pub load_timeout_secs: u64,
158 pub query_cache_capacity: usize,
161}
162
163impl Default for LazyOpts {
164 fn default() -> Self {
165 Self {
166 idle_timeout_secs: 600,
167 load_timeout_secs: 300,
168 query_cache_capacity: 64,
169 }
170 }
171}
172
173pub struct EmbeddingCache {
182 capacity: usize,
183 map: IndexMap<String, Vec<f32>>,
184}
185
186impl EmbeddingCache {
187 pub fn new(capacity: usize) -> Self {
189 Self {
190 capacity,
191 map: IndexMap::new(),
192 }
193 }
194
195 pub fn get(&mut self, key: &str) -> Option<&Vec<f32>> {
197 if let Some(idx) = self.map.get_index_of(key) {
198 self.map.move_index(idx, self.map.len() - 1);
199 Some(&self.map[key])
200 } else {
201 None
202 }
203 }
204
205 pub fn insert(&mut self, key: String, value: Vec<f32>) {
207 if self.capacity == 0 {
208 return;
209 }
210 if let Some(idx) = self.map.get_index_of(&key) {
211 self.map.move_index(idx, self.map.len() - 1);
212 *self.map.get_mut(&key).unwrap() = value;
213 } else {
214 if self.map.len() >= self.capacity {
215 self.map.shift_remove_index(0);
216 }
217 self.map.insert(key, value);
218 }
219 }
220}
221
222struct Inner {
227 model: EmbeddingModel,
228 cache_dir: PathBuf,
229 state: ModelState,
230 provider: Option<FastembedProvider>,
231 last_used: Option<Instant>,
232 load_fn: LoadFn,
235}
236
237pub struct LazyFastembedProvider {
249 inner: Mutex<Inner>,
250 cvar: Condvar,
251 opts: LazyOpts,
252 query_cache: Mutex<EmbeddingCache>,
253}
254
255impl LazyFastembedProvider {
256 pub fn new(model: EmbeddingModel, cache_dir: PathBuf, opts: LazyOpts) -> Self {
260 Self::new_with_loader(model, cache_dir, opts, Arc::new(default_load))
261 }
262
263 pub fn new_with_loader(
269 model: EmbeddingModel,
270 cache_dir: PathBuf,
271 opts: LazyOpts,
272 load_fn: LoadFn,
273 ) -> Self {
274 let initial_state = if is_model_cached(model, &cache_dir) {
275 ModelState::Cached
276 } else {
277 ModelState::NotLoaded
278 };
279 Self {
280 inner: Mutex::new(Inner {
281 model,
282 cache_dir,
283 state: initial_state,
284 provider: None,
285 last_used: None,
286 load_fn,
287 }),
288 cvar: Condvar::new(),
289 opts: opts.clone(),
290 query_cache: Mutex::new(EmbeddingCache::new(opts.query_cache_capacity)),
291 }
292 }
293
294 pub fn state(&self) -> ModelState {
296 let guard = self.inner.lock().unwrap_or_else(|e| e.into_inner());
297 guard.state.clone()
298 }
299
300 pub fn is_ready(&self) -> bool {
305 let guard = self.inner.lock().unwrap_or_else(|e| e.into_inner());
306 matches!(guard.state, ModelState::Ready)
307 }
308
309 pub fn reset(&self) {
321 let mut guard = self.inner.lock().unwrap_or_else(|e| e.into_inner());
322 if matches!(guard.state, ModelState::Failed { .. }) {
323 guard.state = if is_model_cached(guard.model, &guard.cache_dir) {
324 ModelState::Cached
325 } else {
326 ModelState::NotLoaded
327 };
328 }
329 }
330
331 pub fn ensure_model(&self) -> std::result::Result<(), String> {
339 let mut guard = self.inner.lock().unwrap_or_else(|e| e.into_inner());
340
341 if matches!(guard.state, ModelState::Ready)
343 && let Some(last) = guard.last_used
344 && self.opts.idle_timeout_secs > 0
345 && last.elapsed().as_secs() > self.opts.idle_timeout_secs
346 {
347 guard.provider = None;
348 guard.state = ModelState::Cached;
349 guard.last_used = None;
350 }
351
352 match &guard.state {
353 ModelState::Ready => Ok(()),
354 ModelState::Disabled => Err("model is disabled".into()),
355 ModelState::Failed { message, .. } => Err(message.clone()),
356 ModelState::Loading => {
357 let timeout = Duration::from_secs(self.opts.load_timeout_secs);
359 let result = self
360 .cvar
361 .wait_timeout_while(guard, timeout, |g| matches!(g.state, ModelState::Loading));
362 match result {
363 Ok((mut g, timeout_result)) => {
364 if timeout_result.timed_out() {
365 if matches!(g.state, ModelState::Loading) {
366 g.state = ModelState::Failed {
367 message: "model loading timed out".into(),
368 panicked: false,
369 };
370 self.cvar.notify_all();
371 }
372 return Err("model loading timed out".into());
373 }
374 guard = g;
375 }
376 Err(e) => {
377 (guard, _) = e.into_inner();
378 }
379 }
380 match &guard.state {
381 ModelState::Ready => Ok(()),
382 ModelState::Failed { message, .. } => Err(message.clone()),
383 other => Err(format!("unexpected state after wait: {other:?}")),
384 }
385 }
386 ModelState::NotLoaded | ModelState::Cached => {
387 guard.state = ModelState::Loading;
389 let model = guard.model;
390 let cache_dir = guard.cache_dir.clone();
391 let load = Arc::clone(&guard.load_fn);
394 self.cvar.notify_all();
396 drop(guard);
397
398 let (result, panicked) = load_catching_panics(&load, model, cache_dir);
402
403 let mut guard = self.inner.lock().unwrap_or_else(|e| e.into_inner());
404 match result {
405 Ok(provider) => {
406 guard.state = ModelState::Ready;
407 guard.provider = Some(provider);
408 guard.last_used = Some(Instant::now());
409 self.cvar.notify_all();
410 Ok(())
411 }
412 Err(e) => {
413 guard.state = ModelState::Failed {
414 message: e.to_string(),
415 panicked,
416 };
417 self.cvar.notify_all();
418 Err(e.to_string())
419 }
420 }
421 }
422 }
423 }
424}
425
426impl EmbeddingProvider for LazyFastembedProvider {
427 fn dim(&self) -> usize {
428 let guard = self.inner.lock().unwrap_or_else(|e| e.into_inner());
429 guard.model.dimension()
430 }
431
432 fn name(&self) -> &str {
433 let guard = self.inner.lock().unwrap_or_else(|e| e.into_inner());
434 guard.model.as_str()
435 }
436
437 fn embed(&self, text: &str) -> Result<EmbeddingResult> {
438 {
440 let mut cache = self.query_cache.lock().unwrap_or_else(|e| e.into_inner());
441 if let Some(cached) = cache.get(text) {
442 return Ok(EmbeddingResult {
443 vector: cached.clone(),
444 text_preview: text_preview(text),
445 });
446 }
447 }
448
449 self.ensure_model().map_err(KernelError::Embedding)?;
451
452 let result = {
454 let mut guard = self.inner.lock().unwrap_or_else(|e| e.into_inner());
455 guard.last_used = Some(Instant::now());
456 match &guard.provider {
457 Some(p) => p.embed(text),
458 None => Err(KernelError::Embedding("provider not available".into())),
459 }
460 }?;
461
462 {
464 let mut cache = self.query_cache.lock().unwrap_or_else(|e| e.into_inner());
465 cache.insert(text.to_string(), result.vector.clone());
466 }
467
468 Ok(result)
469 }
470
471 fn embed_batch(&self, texts: &[&str]) -> Result<Vec<EmbeddingResult>> {
472 if texts.is_empty() {
473 return Ok(vec![]);
474 }
475
476 let mut results: Vec<Option<Vec<f32>>> = vec![None; texts.len()];
478 let mut miss_indices: Vec<usize> = Vec::with_capacity(texts.len());
479 let mut miss_texts: Vec<&str> = Vec::with_capacity(texts.len());
480
481 {
482 let mut cache = self.query_cache.lock().unwrap_or_else(|e| e.into_inner());
483 for (i, text) in texts.iter().enumerate() {
484 if let Some(cached) = cache.get(text) {
485 results[i] = Some(cached.clone());
486 } else {
487 miss_indices.push(i);
488 miss_texts.push(*text);
489 }
490 }
491 }
492
493 if miss_indices.is_empty() {
495 return Ok(results
496 .into_iter()
497 .zip(texts.iter())
498 .map(|(opt, text)| EmbeddingResult {
499 vector: opt.unwrap(),
500 text_preview: text_preview(text),
501 })
502 .collect());
503 }
504
505 self.ensure_model().map_err(KernelError::Embedding)?;
507
508 let batch_results = {
510 let mut guard = self.inner.lock().unwrap_or_else(|e| e.into_inner());
511 guard.last_used = Some(Instant::now());
512 match &guard.provider {
513 Some(p) => p.embed_batch(&miss_texts),
514 None => Err(KernelError::Embedding("provider not available".into())),
515 }
516 }?;
517
518 if batch_results.len() != miss_texts.len() {
522 return Err(KernelError::Embedding(format!(
523 "provider returned {} embeddings for {} inputs",
524 batch_results.len(),
525 miss_texts.len()
526 )));
527 }
528
529 {
531 let mut cache = self.query_cache.lock().unwrap_or_else(|e| e.into_inner());
532 for (batch_idx, &result_idx) in miss_indices.iter().enumerate() {
533 let vector = batch_results[batch_idx].vector.clone();
534 cache.insert(texts[result_idx].to_string(), vector.clone());
535 results[result_idx] = Some(vector);
536 }
537 }
538
539 results
542 .into_iter()
543 .zip(texts.iter())
544 .map(|(opt, text)| {
545 opt.map(|vector| EmbeddingResult {
546 vector,
547 text_preview: text_preview(text),
548 })
549 .ok_or_else(|| KernelError::Embedding("internal: unfilled embedding slot".into()))
550 })
551 .collect()
552 }
553}
554
555pub fn is_model_cached(model: EmbeddingModel, cache_dir: &std::path::Path) -> bool {
564 let model_code = model.model_code();
565 let mut parts = model_code.splitn(2, '/');
566 let org = parts.next().unwrap_or("");
567 let repo = parts.next().unwrap_or("");
568 if org.is_empty() || repo.is_empty() {
569 return false;
570 }
571 let folder_name = format!("models--{org}--{repo}");
572 cache_dir.join(&folder_name).is_dir()
573}
574
575#[cfg(test)]
580mod tests {
581 use super::*;
582
583 #[test]
584 fn model_state_debug() {
585 assert_eq!(format!("{:?}", ModelState::NotLoaded), "NotLoaded");
586 assert_eq!(format!("{:?}", ModelState::Ready), "Ready");
587 }
588
589 #[test]
590 fn lazy_opts_default() {
591 let opts = LazyOpts::default();
592 assert_eq!(opts.idle_timeout_secs, 600);
593 assert_eq!(opts.load_timeout_secs, 300);
594 assert_eq!(opts.query_cache_capacity, 64);
595 }
596
597 #[test]
598 fn cache_hit_miss_eviction() {
599 let mut cache = EmbeddingCache::new(2);
600 assert!(cache.get("a").is_none());
601 cache.insert("a".into(), vec![1.0]);
602 assert!(cache.get("a").is_some());
603 assert!(cache.get("b").is_none());
604 cache.insert("b".into(), vec![2.0]);
605 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());
609 assert!(cache.get("c").is_some());
610 }
611
612 #[test]
613 fn cache_zero_capacity() {
614 let mut cache = EmbeddingCache::new(0);
615 cache.insert("a".into(), vec![1.0]);
616 assert!(cache.get("a").is_none());
617 }
618
619 #[test]
620 fn cache_update_existing() {
621 let mut cache = EmbeddingCache::new(2);
622 cache.insert("a".into(), vec![1.0]);
623 cache.insert("a".into(), vec![2.0]);
624 assert_eq!(cache.get("a").unwrap(), &vec![2.0]);
625 }
626
627 #[test]
628 fn is_model_cached_nonexistent() {
629 let dir = tempfile::tempdir().unwrap();
630 assert!(!is_model_cached(EmbeddingModel::BGESmallENV15, dir.path()));
631 }
632
633 #[test]
634 fn is_model_cached_existing() {
635 let dir = tempfile::tempdir().unwrap();
636 let model_code = EmbeddingModel::BGESmallENV15.model_code();
638 let parts: Vec<&str> = model_code.splitn(2, '/').collect();
639 let folder = dir
640 .path()
641 .join(format!("models--{}--{}", parts[0], parts[1]));
642 std::fs::create_dir_all(&folder).unwrap();
643 assert!(is_model_cached(EmbeddingModel::BGESmallENV15, dir.path()));
644 }
645
646 #[test]
647 fn constructor_is_instant_no_model() {
648 let dir = tempfile::tempdir().unwrap();
649 let provider = LazyFastembedProvider::new(
650 EmbeddingModel::BGESmallENV15,
651 dir.path().to_path_buf(),
652 LazyOpts::default(),
653 );
654 assert_eq!(provider.state(), ModelState::NotLoaded);
655 }
656
657 #[test]
658 fn constructor_detects_cached() {
659 let dir = tempfile::tempdir().unwrap();
660 let model_code = EmbeddingModel::BGESmallENV15.model_code();
662 let parts: Vec<&str> = model_code.splitn(2, '/').collect();
663 let folder = dir
664 .path()
665 .join(format!("models--{}--{}", parts[0], parts[1]));
666 std::fs::create_dir_all(&folder).unwrap();
667 let provider = LazyFastembedProvider::new(
668 EmbeddingModel::BGESmallENV15,
669 dir.path().to_path_buf(),
670 LazyOpts::default(),
671 );
672 assert_eq!(provider.state(), ModelState::Cached);
673 }
674
675 fn panicking_loader() -> LoadFn {
692 Arc::new(
693 |_model: EmbeddingModel, _cache: PathBuf| -> Result<FastembedProvider> {
694 panic!("simulated ort init failure (missing libonnxruntime.so)")
695 },
696 )
697 }
698
699 fn failing_loader(msg: &str) -> LoadFn {
700 let msg = msg.to_string();
701 Arc::new(
702 move |_model: EmbeddingModel, _cache: PathBuf| -> Result<FastembedProvider> {
703 Err(KernelError::Embedding(msg.clone()))
704 },
705 )
706 }
707
708 #[test]
709 fn panic_during_load_transitions_to_failed() {
710 let dir = tempfile::tempdir().unwrap();
711 let provider = LazyFastembedProvider::new_with_loader(
712 EmbeddingModel::BGESmallENV15,
713 dir.path().to_path_buf(),
714 LazyOpts::default(),
715 panicking_loader(),
716 );
717 assert_eq!(provider.state(), ModelState::NotLoaded);
718
719 let err = provider.ensure_model().unwrap_err();
720 assert!(
721 err.contains("model init panicked"),
722 "expected panic message, got: {err}"
723 );
724 match provider.state() {
725 ModelState::Failed { message, panicked } => {
726 assert!(
727 message.contains("model init panicked"),
728 "expected panic in Failed state, got: {message}"
729 );
730 assert!(
731 panicked,
732 "panic-originated failure should set panicked=true"
733 );
734 }
735 other => panic!("expected Failed state after panic, got {other:?}"),
736 }
737 assert!(provider.state().is_panic());
738 }
739
740 #[test]
741 fn failure_during_load_transitions_to_failed() {
742 let dir = tempfile::tempdir().unwrap();
743 let provider = LazyFastembedProvider::new_with_loader(
744 EmbeddingModel::BGESmallENV15,
745 dir.path().to_path_buf(),
746 LazyOpts::default(),
747 failing_loader("download denied"),
748 );
749 let err = provider.ensure_model().unwrap_err();
750 assert!(err.contains("download denied"), "got: {err}");
751 match provider.state() {
752 ModelState::Failed { panicked, .. } => {
753 assert!(!panicked, "ordinary Err should not set panicked=true");
754 }
755 other => panic!("expected Failed state, got {other:?}"),
756 }
757 assert!(!provider.state().is_panic());
758 }
759
760 #[test]
761 fn reset_after_failure_allows_retry() {
762 let dir = tempfile::tempdir().unwrap();
763 let attempts = Arc::new(std::sync::atomic::AtomicUsize::new(0));
764 let attempts_clone = Arc::clone(&attempts);
765 let loader: LoadFn = Arc::new(move |_m, _c| {
766 attempts_clone.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
767 Err(KernelError::Embedding("transient failure".into()))
768 });
769 let provider = LazyFastembedProvider::new_with_loader(
770 EmbeddingModel::BGESmallENV15,
771 dir.path().to_path_buf(),
772 LazyOpts::default(),
773 loader,
774 );
775
776 assert!(provider.ensure_model().is_err());
777 assert!(matches!(provider.state(), ModelState::Failed { .. }));
778 assert_eq!(attempts.load(std::sync::atomic::Ordering::SeqCst), 1);
779
780 assert!(provider.ensure_model().is_err());
782 assert_eq!(attempts.load(std::sync::atomic::Ordering::SeqCst), 1);
783
784 provider.reset();
786 assert_eq!(provider.state(), ModelState::NotLoaded);
787 assert!(provider.ensure_model().is_err());
788 assert_eq!(attempts.load(std::sync::atomic::Ordering::SeqCst), 2);
789 }
790
791 #[test]
792 fn reset_is_noop_when_not_failed() {
793 let dir = tempfile::tempdir().unwrap();
794 let provider = LazyFastembedProvider::new_with_loader(
795 EmbeddingModel::BGESmallENV15,
796 dir.path().to_path_buf(),
797 LazyOpts::default(),
798 failing_loader("irrelevant"),
799 );
800 assert_eq!(provider.state(), ModelState::NotLoaded);
801 provider.reset();
802 assert_eq!(provider.state(), ModelState::NotLoaded);
803 }
804
805 #[test]
806 fn concurrent_waiter_released_when_loader_panics() {
807 use std::sync::Barrier;
811 use std::thread;
812
813 let dir = tempfile::tempdir().unwrap();
814
815 let barrier = Arc::new(Barrier::new(2));
818 let loader_barrier = Arc::clone(&barrier);
819 let loader: LoadFn = Arc::new(move |_m, _c| {
820 loader_barrier.wait();
822 panic!("simulated ort init failure under contention");
823 });
824
825 let opts = LazyOpts {
826 load_timeout_secs: 30, ..LazyOpts::default()
828 };
829 let provider = Arc::new(LazyFastembedProvider::new_with_loader(
830 EmbeddingModel::BGESmallENV15,
831 dir.path().to_path_buf(),
832 opts,
833 loader,
834 ));
835
836 let waiter_provider = Arc::clone(&provider);
838 let waiter = thread::spawn(move || waiter_provider.ensure_model());
839
840 barrier.wait();
844
845 let result = waiter.join().expect("waiter thread should not hang");
847 assert!(result.is_err(), "waiter should receive Err, not Ok");
848 assert!(
849 matches!(provider.state(), ModelState::Failed { .. }),
850 "provider should be Failed after loader panic, got {:?}",
851 provider.state()
852 );
853 assert!(provider.state().is_panic());
854 }
855}