Skip to main content

llm_kernel/embedding/
lazy.rs

1//! Lazy-loading embedding provider with idle eviction and LRU query cache.
2//!
3//! Wraps [`FastembedProvider`] behind a state machine that defers model
4//! download and ONNX session initialization until the first embed call.
5//! After a configurable idle timeout, the ONNX session is dropped to
6//! release memory while keeping model weights on disk for fast reload.
7//!
8//! ```ignore
9//! use llm_kernel::embedding::{
10//!     EmbeddingModel, LazyFastembedProvider, LazyOpts, EmbeddingProvider,
11//! };
12//!
13//! let provider = LazyFastembedProvider::new(
14//!     EmbeddingModel::BGESmallENV15,
15//!     "/path/to/cache".into(),
16//!     LazyOpts::default(),
17//! );
18//! // Constructor returns instantly — no download yet
19//! let result = provider.embed("hello world")?; // triggers lazy load
20//! ```
21
22use 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
35// ---------------------------------------------------------------------------
36// Load hook (panic-safe model instantiation)
37// ---------------------------------------------------------------------------
38
39/// Type of the model-load closure used by [`LazyFastembedProvider`].
40///
41/// Wrapped in `Arc` so the loader can be cheaply cloned out from under the
42/// `Inner` lock and invoked without holding the mutex across the
43/// (potentially minutes-long) model download/init.
44pub(crate) type LoadFn =
45    Arc<dyn Fn(EmbeddingModel, PathBuf) -> Result<FastembedProvider> + Send + Sync>;
46
47/// Default loader: instantiate [`FastembedProvider`] directly.
48///
49/// This is a free function (rather than an inline call) so that tests can swap
50/// it for an injectable panicking/failing loader via
51/// [`LazyFastembedProvider::new_with_loader`] without needing real ONNX weights
52/// on disk.
53fn default_load(model: EmbeddingModel, cache_dir: PathBuf) -> Result<FastembedProvider> {
54    FastembedProvider::new(model, Some(cache_dir))
55}
56
57/// Run `load` catching any panic, so a panicking ONNX init (e.g. a missing
58/// `libonnxruntime.so` under `ort-load-dynamic`) is surfaced as a clean `Err`
59/// instead of unwinding across the `Mutex`/`Condvar` boundary and wedging any
60/// waiter parked in [`LazyFastembedProvider::ensure_model`]'s `Loading` branch
61/// (see #50).
62///
63/// `AssertUnwindSafe` is sound here: on the `Err(panic)` path the closure's
64/// captures are discarded entirely, and the values read afterwards
65/// (`EmbeddingModel`/`PathBuf`) were copied in before the call — so no torn
66/// shared state escapes.
67///
68/// The returned bool is `true` iff the failure came from a caught panic
69/// (as opposed to an ordinary `Err` from `load`) — callers use this to mark
70/// [`ModelState::Failed`] as panic-originated, since a panic during ort/ONNX
71/// init may leave process-global state in a way that makes blind retries
72/// riskier than retrying after an ordinary (e.g. network) failure.
73fn 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
93/// Best-effort string extraction from a panic payload.
94fn 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// ---------------------------------------------------------------------------
104// ModelState
105// ---------------------------------------------------------------------------
106
107/// Lifecycle state of a lazy-loaded embedding model.
108#[derive(Debug, Clone, PartialEq, Eq)]
109pub enum ModelState {
110    /// No weights on disk, never attempted.
111    NotLoaded,
112    /// Background thread downloading + initializing.
113    Loading,
114    /// Weights on disk, not in memory.
115    Cached,
116    /// ONNX session active, ready for inference.
117    Ready,
118    /// Feature disabled by configuration.
119    Disabled,
120    /// Last attempt failed (preserves error).
121    ///
122    /// `panicked` is `true` when the failure came from a caught panic during
123    /// loader init (e.g. ort/ONNX init panicking on a missing dylib) rather
124    /// than an ordinary `Err` returned by the loader (e.g. a network error
125    /// during model download). Callers that want to retry after `Failed`
126    /// (via [`LazyFastembedProvider::reset`]) should treat a panic-origin
127    /// failure with more caution — process-global ort state may be corrupted
128    /// — whereas an ordinary failure is generally safe to retry blindly.
129    Failed {
130        /// Human-readable error description.
131        message: String,
132        /// `true` if the failure came from a caught panic during loader
133        /// init, `false` if the loader returned an ordinary `Err`.
134        panicked: bool,
135    },
136}
137
138impl ModelState {
139    /// Whether this is a `Failed` state that originated from a caught panic
140    /// (as opposed to an ordinary loader `Err`).
141    pub fn is_panic(&self) -> bool {
142        matches!(self, ModelState::Failed { panicked: true, .. })
143    }
144}
145
146// ---------------------------------------------------------------------------
147// LazyOpts
148// ---------------------------------------------------------------------------
149
150/// Configuration for [`LazyFastembedProvider`].
151#[derive(Debug, Clone)]
152pub struct LazyOpts {
153    /// Seconds of inactivity before the ONNX session is dropped.
154    /// `0` means never unload. Default: 600 (10 minutes).
155    pub idle_timeout_secs: u64,
156    /// Maximum seconds to wait for model initialisation. Default: 300.
157    pub load_timeout_secs: u64,
158    /// Maximum number of query embeddings to cache (LRU).
159    /// `0` disables caching. Default: 64.
160    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
173// ---------------------------------------------------------------------------
174// EmbeddingCache (LRU)
175// ---------------------------------------------------------------------------
176
177/// LRU cache for query embedding vectors.
178///
179/// Zero external dependencies beyond `indexmap`. Prevents re-encoding
180/// identical query strings in recurring MCP search workloads.
181pub struct EmbeddingCache {
182    capacity: usize,
183    map: IndexMap<String, Vec<f32>>,
184}
185
186impl EmbeddingCache {
187    /// Create a new cache with the given capacity. `0` disables caching.
188    pub fn new(capacity: usize) -> Self {
189        Self {
190            capacity,
191            map: IndexMap::new(),
192        }
193    }
194
195    /// Look up a cached embedding. A hit moves the entry to the MRU position.
196    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    /// Insert an embedding. If at capacity, evicts the least-recently-used entry.
206    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
222// ---------------------------------------------------------------------------
223// LazyFastembedProvider
224// ---------------------------------------------------------------------------
225
226struct Inner {
227    model: EmbeddingModel,
228    cache_dir: PathBuf,
229    state: ModelState,
230    provider: Option<FastembedProvider>,
231    last_used: Option<Instant>,
232    /// Injectable model loader. Defaults to [`default_load`] (which calls
233    /// `FastembedProvider::new`); tests substitute a controllable loader.
234    load_fn: LoadFn,
235}
236
237/// Lazy-loading embedding provider backed by [`FastembedProvider`].
238///
239/// The constructor is **instant** — it sets initial state to `Cached` if
240/// model weights already exist on disk, or `NotLoaded` otherwise. The
241/// first call to [`embed`](EmbeddingProvider::embed) triggers model
242/// download and ONNX session initialisation.
243///
244/// Thread-safe: concurrent callers block on a `Condvar` with a
245/// configurable timeout. After an idle period, the ONNX session is
246/// dropped to release memory while keeping weights on disk for fast
247/// reload.
248pub struct LazyFastembedProvider {
249    inner: Mutex<Inner>,
250    cvar: Condvar,
251    opts: LazyOpts,
252    query_cache: Mutex<EmbeddingCache>,
253}
254
255impl LazyFastembedProvider {
256    /// Create a new lazy provider.
257    ///
258    /// Returns instantly — no model download or ONNX initialisation.
259    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    /// Create a lazy provider with an injected model loader.
264    ///
265    /// Production callers should use [`new`](Self::new); this constructor exists
266    /// so tests can drive the panic/failure paths of [`ensure_model`](Self::ensure_model)
267    /// deterministically without real ONNX weights.
268    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    /// Current model lifecycle state.
295    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    /// Whether the model is loaded and ready for inference.
301    ///
302    /// Cheaper than [`state`](Self::state) for hot-path polling — avoids
303    /// cloning the `Failed { message, .. }` variant's string.
304    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    /// Clear a `Failed` state so the next [`ensure_model`](Self::ensure_model)
310    /// call retries the load, instead of returning the cached error forever.
311    ///
312    /// No-op if the provider isn't currently `Failed`. Resets to `Cached` if
313    /// weights are already on disk, `NotLoaded` otherwise — mirroring the
314    /// initial-state logic in [`new_with_loader`](Self::new_with_loader).
315    ///
316    /// Callers should check [`ModelState::is_panic`] before calling this:
317    /// retrying after a panic-origin failure re-enters the same loader (and
318    /// thus the same ort/ONNX init path) that just panicked, which may hit
319    /// corrupted process-global state rather than a fresh, safe retry.
320    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    /// Block until the model is ready for inference.
332    ///
333    /// If the model is idle beyond the configured timeout, the ONNX session
334    /// is evicted and reloaded from the on-disk cache. If the model is not
335    /// yet loaded, this triggers download and initialisation on the calling
336    /// thread. Concurrent callers wait on a `Condvar` for up to
337    /// `load_timeout_secs`.
338    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        // Idle eviction: drop ONNX session if quiet for too long.
342        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                // Wait for existing loading thread
358                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                // Start loading
388                guard.state = ModelState::Loading;
389                let model = guard.model;
390                let cache_dir = guard.cache_dir.clone();
391                // Clone the loader out from under the lock so we don't hold the
392                // mutex across the (potentially minutes-long) model load.
393                let load = Arc::clone(&guard.load_fn);
394                // Notify waiters that we've started loading (they'll keep waiting)
395                self.cvar.notify_all();
396                drop(guard);
397
398                // Do the actual loading (may download, takes seconds to minutes).
399                // Wrapped in `catch_unwind` so a panicking ONNX init surfaces as
400                // `Failed{..}` + `notify_all()` instead of wedging waiters (#50).
401                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        // Check query cache
439        {
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        // Ensure model is loaded (includes idle eviction check)
450        self.ensure_model().map_err(KernelError::Embedding)?;
451
452        // Embed
453        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        // Cache the result
463        {
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        // Phase 1: check cache for all texts
477        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        // Phase 2: if all cache hits, return immediately
494        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        // Phase 3: ensure model is loaded
506        self.ensure_model().map_err(KernelError::Embedding)?;
507
508        // Phase 4: batch embed the misses through the inner provider
509        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        // A well-behaved provider returns exactly one vector per input. Guard
519        // against a truncated/malformed response so the merge below indexes
520        // `batch_results` safely instead of panicking on an out-of-bounds slot.
521        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        // Phase 5: merge results and insert new entries into cache
530        {
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        // Phase 6: assemble final results in original order. Every slot is now
540        // populated (all cache-hit or filled in Phase 5), so unwrap is safe.
541        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
555// ---------------------------------------------------------------------------
556// Utility
557// ---------------------------------------------------------------------------
558
559/// Check if model weights exist in the HuggingFace cache directory.
560///
561/// Uses the `models--{org}--{repo}` folder naming convention that `hf-hub`
562/// employs when caching model artefacts on disk.
563pub 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// ---------------------------------------------------------------------------
576// Tests
577// ---------------------------------------------------------------------------
578
579#[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()); // a was accessed, not LRU
606        cache.insert("c".into(), vec![3.0]); // evicts "b" (LRU)
607        assert!(cache.get("b").is_none()); // evicted
608        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        // Use model_code() — the actual HF repo used by fastembed-rs.
637        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        // Use model_code() — matches the actual HF cache folder name.
661        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    // ----- panic-safety regression tests (#50) -----
676    //
677    // These exercise the contract that a panicking (or failing) model loader
678    // transitions the provider to `Failed` and releases any waiter parked in
679    // the `Loading` branch, instead of hanging forever. They use the injected
680    // loader (`new_with_loader`) so no real ONNX weights are required.
681    //
682    // NOTE: the production `[profile.release]` sets `panic = "abort"`, under
683    // which a panic terminates the process directly (no unwind to catch). The
684    // `catch_unwind` guard therefore matters for panic-unwinding builds
685    // (debug, and release builds that override `panic`), and these tests run
686    // under the default test harness which unwinds. The guard is still
687    // valuable under `panic = "abort"` for the *failure* (non-panic) path and
688    // documents intent; the abort case is acceptable (a hard crash is a clearer
689    // failure than the previous silent deadlock).
690
691    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        // Without reset, ensure_model just replays the cached error.
781        assert!(provider.ensure_model().is_err());
782        assert_eq!(attempts.load(std::sync::atomic::Ordering::SeqCst), 1);
783
784        // After reset, ensure_model retries the loader.
785        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        // AC4: a waiter blocked in the `Loading` branch must be released (with
808        // an `Err`) within a bounded time when the owning loader panics — not
809        // hang until load_timeout_secs, and not deadlock.
810        use std::sync::Barrier;
811        use std::thread;
812
813        let dir = tempfile::tempdir().unwrap();
814
815        // A loader that waits on a barrier until the waiter is parked, then
816        // panics — deterministically reproducing the #50 hang scenario.
817        let barrier = Arc::new(Barrier::new(2));
818        let loader_barrier = Arc::clone(&barrier);
819        let loader: LoadFn = Arc::new(move |_m, _c| {
820            // Let the waiter reach the `Loading` wait first.
821            loader_barrier.wait();
822            panic!("simulated ort init failure under contention");
823        });
824
825        let opts = LazyOpts {
826            load_timeout_secs: 30, // generous; we must release well before this
827            ..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        // Waiter thread: parks in the `Loading` branch.
837        let waiter_provider = Arc::clone(&provider);
838        let waiter = thread::spawn(move || waiter_provider.ensure_model());
839
840        // Give the loader the chance to run; it waits for the barrier, the
841        // waiter parks in Loading, then this wait returns and the loader
842        // panics. The provider's panic guard converts that to Failed + notify.
843        barrier.wait();
844
845        // The waiter must terminate (with Err) promptly rather than hang.
846        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}