Skip to main content

mold_server/
model_cache.rs

1use std::collections::{HashMap, HashSet};
2use std::time::{Duration, Instant};
3
4use mold_inference::InferenceEngine;
5
6use crate::state::EngineSnapshot;
7
8/// Where a cached model's weights currently reside.
9#[derive(Debug, Clone, Copy, PartialEq, Eq)]
10pub enum ModelResidency {
11    /// Fully loaded on GPU, ready for immediate inference.
12    Gpu,
13    /// Engine exists but weights are not on GPU. Retains paths, config,
14    /// tokenizers, and CPU-side caches (e.g. FLUX `lora_delta_cache`,
15    /// shared tokenizers) so the next reload can skip recomputation. The
16    /// engine struct can be reloaded in place without recreation.
17    Parked,
18}
19
20/// A model entry in the cache.
21pub struct CachedEngine {
22    pub engine: Box<dyn InferenceEngine>,
23    pub model_name: String,
24    pub residency: ModelResidency,
25    pub last_used: Instant,
26    /// Measured VRAM footprint (bytes). Set after loading by measuring delta.
27    pub vram_bytes: u64,
28}
29
30/// Multi-model cache with LRU eviction under VRAM pressure.
31///
32/// Invariants:
33/// - At most one engine has `residency == Gpu` at a time (single-GPU inference).
34/// - `lru_order` tracks all entries from least-recently-used (front) to
35///   most-recently-used (back).
36/// - `max_cached` limits total entries across both residency states (Gpu and Parked).
37/// - `in_flight` holds names temporarily checked out via `take()` until they
38///   are returned via `restore()`. Such names are physically absent from
39///   `entries`/`lru_order` but logically still part of the cache for the
40///   purposes of `contains()` and `snapshot()` — without this, a parallel
41///   reader during a take window would mistakenly conclude the model is
42///   uncached.
43pub struct ModelCache {
44    entries: HashMap<String, CachedEngine>,
45    /// Ordered from least-recently-used (index 0) to most-recently-used (last).
46    lru_order: Vec<String>,
47    /// Maximum number of models to keep cached (loaded + unloaded).
48    max_cached: usize,
49    /// Names checked out via `take()` and not yet `restore()`d. Treated as
50    /// logically cached by `contains()`, `snapshot()`, and
51    /// `cached_model_names()`. The single-GPU contract guarantees that at
52    /// most one of these was the GPU-resident engine at the time of the
53    /// take.
54    in_flight: HashSet<String>,
55    /// Name of the in-flight take that was the GPU-resident engine at the
56    /// time of `take()`, if any. Surfaced as `snapshot().model_name` so
57    /// readers can still see "this model is the active one" during the
58    /// take/restore window.
59    in_flight_active: Option<String>,
60}
61
62impl ModelCache {
63    pub fn new(max_cached: usize) -> Self {
64        Self {
65            entries: HashMap::new(),
66            lru_order: Vec::new(),
67            max_cached: max_cached.max(1),
68            in_flight: HashSet::new(),
69            in_flight_active: None,
70        }
71    }
72
73    /// Insert an engine into the cache. If the cache is full, the LRU entry
74    /// is dropped entirely. Returns the evicted engine (if any) for cleanup.
75    pub fn insert(
76        &mut self,
77        engine: Box<dyn InferenceEngine>,
78        vram_bytes: u64,
79    ) -> Option<Box<dyn InferenceEngine>> {
80        let name = engine.model_name().to_string();
81        let mut evicted = None;
82
83        // Evict LRU if at capacity (skip if the model is already in cache)
84        if self.entries.len() >= self.max_cached && !self.entries.contains_key(&name) {
85            evicted = self.evict_lru("capacity");
86        }
87
88        let entry = CachedEngine {
89            model_name: name.clone(),
90            residency: if engine.is_loaded() {
91                ModelResidency::Gpu
92            } else {
93                // Engine struct exists but weights are off-GPU.
94                ModelResidency::Parked
95            },
96            last_used: Instant::now(),
97            vram_bytes,
98            engine,
99        };
100
101        self.entries.insert(name.clone(), entry);
102        // The freshly inserted entry supersedes any take-window placeholder
103        // for this name (used by the reload path: take → load → insert).
104        self.in_flight.remove(&name);
105        if self.in_flight_active.as_deref() == Some(name.as_str()) {
106            self.in_flight_active = None;
107        }
108        self.touch_order(&name);
109        self.report_size();
110        self.debug_check_invariants();
111        evicted
112    }
113
114    /// Get a reference to a cached engine entry (does not update LRU order).
115    pub fn get(&self, model_name: &str) -> Option<&CachedEngine> {
116        self.entries.get(model_name)
117    }
118
119    /// Get a mutable reference to the engine for a model, if cached.
120    pub fn get_mut(&mut self, model_name: &str) -> Option<&mut CachedEngine> {
121        if self.entries.contains_key(model_name) {
122            self.touch_order(model_name);
123            self.entries.get_mut(model_name)
124        } else {
125            None
126        }
127    }
128
129    /// Remove an engine from the cache, returning the full entry.
130    /// Used by the take-and-restore pattern: remove before inference, re-insert after.
131    /// While the engine is checked out, the name remains in `in_flight` so
132    /// parallel readers (`contains`, `snapshot`, `cached_model_names`) still
133    /// see the model as logically cached.
134    pub fn take(&mut self, model_name: &str) -> Option<CachedEngine> {
135        self.lru_order.retain(|n| n != model_name);
136        let taken = self.entries.remove(model_name);
137        if let Some(ref entry) = taken {
138            self.in_flight.insert(model_name.to_string());
139            if entry.residency == ModelResidency::Gpu {
140                self.in_flight_active = Some(model_name.to_string());
141            }
142            self.report_size();
143        }
144        self.debug_check_invariants();
145        taken
146    }
147
148    /// Re-insert a taken engine after inference completes.
149    pub fn restore(&mut self, cached: CachedEngine) {
150        let name = cached.model_name.clone();
151        self.in_flight.remove(&name);
152        if self.in_flight_active.as_deref() == Some(name.as_str()) {
153            self.in_flight_active = None;
154        }
155        self.lru_order.push(name.clone());
156        self.entries.insert(name, cached);
157        self.report_size();
158        self.debug_check_invariants();
159    }
160
161    /// Clear an in-flight marker without restoring an engine. Called when the
162    /// take-and-restore window terminates abnormally (`JoinError`, panic that
163    /// escapes `catch_unwind`, etc.) and we have no engine to put back —
164    /// otherwise the name leaks into `in_flight` forever, making
165    /// `contains()` permanently lie to `ensure_model_ready` while
166    /// `take()`/`get()` keep returning `None`.
167    pub fn clear_in_flight(&mut self, model_name: &str) {
168        self.in_flight.remove(model_name);
169        if self.in_flight_active.as_deref() == Some(model_name) {
170            self.in_flight_active = None;
171        }
172        self.debug_check_invariants();
173    }
174
175    /// Insert a loaded engine with a known VRAM footprint.
176    /// Unlike `insert()`, this takes a name separately from the engine.
177    pub fn insert_loaded(
178        &mut self,
179        model_name: String,
180        engine: Box<dyn InferenceEngine>,
181        vram_bytes: u64,
182    ) -> Option<Box<dyn InferenceEngine>> {
183        let mut evicted = None;
184
185        // Evict LRU if at capacity (skip if the model is already in cache)
186        if self.entries.len() >= self.max_cached && !self.entries.contains_key(&model_name) {
187            evicted = self.evict_lru("capacity");
188        }
189
190        let entry = CachedEngine {
191            model_name: model_name.clone(),
192            residency: if engine.is_loaded() {
193                ModelResidency::Gpu
194            } else {
195                // Engine struct exists but weights are off-GPU.
196                ModelResidency::Parked
197            },
198            last_used: Instant::now(),
199            vram_bytes,
200            engine,
201        };
202
203        self.entries.insert(model_name.clone(), entry);
204        // Freshly inserted entry supersedes any in-flight placeholder.
205        self.in_flight.remove(&model_name);
206        if self.in_flight_active.as_deref() == Some(model_name.as_str()) {
207            self.in_flight_active = None;
208        }
209        self.touch_order(&model_name);
210        self.report_size();
211        self.debug_check_invariants();
212        evicted
213    }
214
215    /// Check if a model is in the cache. Treats names taken-but-not-restored
216    /// as still cached so concurrent readers don't see a transient hole
217    /// during a take/restore window.
218    pub fn contains(&self, model_name: &str) -> bool {
219        self.entries.contains_key(model_name) || self.in_flight.contains(model_name)
220    }
221
222    /// Remove a model from the cache entirely, returning its engine. Also
223    /// clears the name from `in_flight` so we never claim a model is cached
224    /// after explicit removal.
225    pub fn remove(&mut self, model_name: &str) -> Option<Box<dyn InferenceEngine>> {
226        self.lru_order.retain(|n| n != model_name);
227        self.in_flight.remove(model_name);
228        if self.in_flight_active.as_deref() == Some(model_name) {
229            self.in_flight_active = None;
230        }
231        let removed = self.entries.remove(model_name).map(|e| e.engine);
232        if removed.is_some() {
233            self.report_size();
234        }
235        self.debug_check_invariants();
236        removed
237    }
238
239    /// Unload all models from GPU. Returns names of models that were unloaded.
240    /// Each is transitioned to `Parked` (retain tokenizers/caches for faster
241    /// reload).
242    pub fn unload_all(&mut self) -> Vec<String> {
243        let mut unloaded = Vec::new();
244        for entry in self.entries.values_mut() {
245            if entry.residency == ModelResidency::Gpu {
246                entry.engine.unload();
247                entry.residency = ModelResidency::Parked;
248                entry.vram_bytes = 0;
249                unloaded.push(entry.model_name.clone());
250            }
251        }
252        self.debug_check_invariants();
253        unloaded
254    }
255
256    /// Unload the current GPU-resident model (if any) to make room for a new one.
257    /// The engine is parked (retains tokenizers/caches) for faster reload.
258    /// Returns the name of the unloaded model.
259    pub fn unload_active(&mut self) -> Option<String> {
260        let active_name = self
261            .entries
262            .values()
263            .find(|e| e.residency == ModelResidency::Gpu)
264            .map(|e| e.model_name.clone());
265
266        if let Some(ref name) = active_name {
267            if let Some(entry) = self.entries.get_mut(name) {
268                entry.engine.unload();
269                entry.residency = ModelResidency::Parked;
270                entry.vram_bytes = 0;
271            }
272        }
273        self.debug_check_invariants();
274        active_name
275    }
276
277    /// Drop all entries, returning all engines for cleanup. Also clears
278    /// `in_flight` — any caller still holding a checked-out engine must
279    /// drop it on their own (they own that `CachedEngine`); we just stop
280    /// claiming it's logically present.
281    pub fn clear(&mut self) -> Vec<Box<dyn InferenceEngine>> {
282        self.lru_order.clear();
283        self.in_flight.clear();
284        self.in_flight_active = None;
285        let drained: Vec<_> = self.entries.drain().map(|(_, e)| e.engine).collect();
286        self.report_size();
287        self.debug_check_invariants();
288        drained
289    }
290
291    /// VRAM footprint of the currently GPU-resident model (0 if none loaded).
292    pub fn active_vram_bytes(&self) -> u64 {
293        self.entries
294            .values()
295            .find(|e| e.residency == ModelResidency::Gpu)
296            .map(|e| e.vram_bytes)
297            .unwrap_or(0)
298    }
299
300    /// The currently GPU-loaded model name.
301    pub fn active_model(&self) -> Option<&str> {
302        self.entries
303            .values()
304            .find(|e| e.residency == ModelResidency::Gpu)
305            .map(|e| e.model_name.as_str())
306    }
307
308    /// All cached model names (any residency, including names temporarily
309    /// taken-out for in-flight inference).
310    pub fn cached_model_names(&self) -> Vec<String> {
311        let mut names = self.lru_order.clone();
312        for name in &self.in_flight {
313            if !names.iter().any(|n| n == name) {
314                names.push(name.clone());
315            }
316        }
317        names
318    }
319
320    /// Snapshot of the cache's current state — what /api/models and
321    /// /api/status report. Derived directly from the cache so there's no
322    /// parallel field that can drift. During a take/restore window the
323    /// engine that was GPU-resident is reflected via `in_flight_active`
324    /// so readers don't see a transient "no model loaded" hole.
325    pub fn snapshot(&self) -> EngineSnapshot {
326        let active = self
327            .active_model()
328            .map(|s| s.to_string())
329            .or_else(|| self.in_flight_active.clone());
330        let is_loaded = active.is_some();
331        EngineSnapshot {
332            model_name: active,
333            is_loaded,
334            cached_models: self.cached_model_names(),
335        }
336    }
337
338    /// Number of cached entries.
339    pub fn len(&self) -> usize {
340        self.entries.len()
341    }
342
343    pub fn is_empty(&self) -> bool {
344        self.entries.is_empty()
345    }
346
347    /// Evict the least-recently-used entry, returning its engine for cleanup.
348    /// `reason` is forwarded to the eviction log/metric (`"capacity"` from
349    /// the insert paths, `"idle-ttl"` from the background sweeper).
350    fn evict_lru(&mut self, reason: &'static str) -> Option<Box<dyn InferenceEngine>> {
351        let mut evicted = None;
352        if let Some(name) = self.lru_order.first().cloned() {
353            self.lru_order.remove(0);
354            if let Some(entry) = self.entries.remove(&name) {
355                let last_used_secs = entry.last_used.elapsed().as_secs();
356                tracing::info!(
357                    model = %name,
358                    last_used_secs,
359                    reason,
360                    "cache eviction"
361                );
362                #[cfg(feature = "metrics")]
363                crate::metrics::record_cache_eviction(reason);
364                evicted = Some(entry.engine);
365            }
366        }
367        self.debug_check_invariants();
368        evicted
369    }
370
371    /// Reclaim cache entries whose `last_used` is older than `ttl`. Only
372    /// entries that are not GPU-resident are eligible — the active model is
373    /// always preserved. Skipped entirely when the cache holds at most one
374    /// entry (so we never tear down the only warm engine after a quiet
375    /// period).
376    ///
377    /// Returns evicted `(name, engine)` pairs so callers can drop the
378    /// engines outside any cache mutex — `cuMemFree` and safetensor unmap on
379    /// drop can block other cache users for non-trivial time.
380    pub fn evict_idle(&mut self, ttl: Duration) -> Vec<(String, Box<dyn InferenceEngine>)> {
381        if self.entries.len() <= 1 {
382            return Vec::new();
383        }
384        let now = Instant::now();
385        // Collect (name, last_used) for every stale, non-GPU entry. Sort by
386        // `last_used` ascending (oldest first) so that when the "keep ≥1
387        // warm engine" guard fires mid-loop we evict the LRU and the MRU
388        // survives — without the sort, HashMap iteration order would pick
389        // the survivor at random.
390        let mut stale: Vec<(String, Instant)> = self
391            .entries
392            .iter()
393            .filter_map(|(name, entry)| {
394                if entry.residency == ModelResidency::Gpu {
395                    return None;
396                }
397                let age = now.saturating_duration_since(entry.last_used);
398                if age >= ttl {
399                    Some((name.clone(), entry.last_used))
400                } else {
401                    None
402                }
403            })
404            .collect();
405        stale.sort_by_key(|(_, last_used)| *last_used);
406
407        let mut out = Vec::with_capacity(stale.len());
408        for (name, _) in stale {
409            if self.entries.len() <= 1 {
410                break;
411            }
412            self.lru_order.retain(|n| n != &name);
413            if let Some(entry) = self.entries.remove(&name) {
414                let last_used_secs = entry.last_used.elapsed().as_secs();
415                tracing::info!(
416                    model = %name,
417                    last_used_secs,
418                    reason = "idle-ttl",
419                    "cache eviction"
420                );
421                #[cfg(feature = "metrics")]
422                crate::metrics::record_cache_eviction("idle-ttl");
423                out.push((name, entry.engine));
424            }
425        }
426        if !out.is_empty() {
427            self.report_size();
428        }
429        self.debug_check_invariants();
430        out
431    }
432
433    /// Evict the LRU entry that is *not* GPU-resident and (optionally) not the
434    /// named model. Returns `(name, engine)` so the caller can drop the engine
435    /// outside the cache lock and then issue any GPU reclamation.
436    ///
437    /// Used by the load-time evict-to-fit recovery path: when a fresh load's
438    /// preflight fails, we shrink the parked working set one entry at a time
439    /// and retry. The `skip` parameter exists because the parked-reload branch
440    /// must not evict the very entry it's about to reload.
441    pub fn evict_lru_parked_except(
442        &mut self,
443        skip: Option<&str>,
444    ) -> Option<(String, Box<dyn InferenceEngine>)> {
445        let victim = self.lru_order.iter().find(|name| {
446            if Some(name.as_str()) == skip {
447                return false;
448            }
449            self.entries
450                .get(name.as_str())
451                .map(|e| e.residency != ModelResidency::Gpu)
452                .unwrap_or(false)
453        })?;
454        let name = victim.clone();
455        self.lru_order.retain(|n| n != &name);
456        let entry = self.entries.remove(&name)?;
457        tracing::info!(
458            model = %name,
459            last_used_secs = entry.last_used.elapsed().as_secs(),
460            reason = "evict-to-fit",
461            "cache eviction"
462        );
463        #[cfg(feature = "metrics")]
464        crate::metrics::record_cache_eviction("evict-to-fit");
465        self.report_size();
466        self.debug_check_invariants();
467        Some((name, entry.engine))
468    }
469
470    /// Move a model name to the MRU position in the LRU order.
471    fn touch_order(&mut self, model_name: &str) {
472        self.lru_order.retain(|n| n != model_name);
473        self.lru_order.push(model_name.to_string());
474        self.debug_check_invariants();
475    }
476
477    /// Push the current entry count to the cache-size gauge. Cheap no-op
478    /// when the metrics feature is off.
479    fn report_size(&self) {
480        #[cfg(feature = "metrics")]
481        crate::metrics::set_cache_size(self.entries.len());
482    }
483
484    /// Debug-only invariant check. Called at the end of every state-mutating
485    /// method so a violation surfaces in tests rather than as a silent
486    /// "wrong model came back" bug in production.
487    ///
488    /// Invariants enforced:
489    /// 1. At most one entry has `residency == Gpu` (single-GPU contract).
490    /// 2. `entries.len() == lru_order.len()` (LRU mirrors entries 1:1).
491    #[cfg(debug_assertions)]
492    fn debug_check_invariants(&self) {
493        let gpu_count = self
494            .entries
495            .values()
496            .filter(|e| e.residency == ModelResidency::Gpu)
497            .count();
498        debug_assert!(
499            gpu_count <= 1,
500            "ModelCache invariant violated: {gpu_count} engines have residency=Gpu (must be ≤1)"
501        );
502        debug_assert_eq!(
503            self.entries.len(),
504            self.lru_order.len(),
505            "ModelCache invariant violated: entries len ({}) != lru_order len ({})",
506            self.entries.len(),
507            self.lru_order.len()
508        );
509    }
510
511    #[cfg(not(debug_assertions))]
512    fn debug_check_invariants(&self) {}
513}
514
515#[cfg(test)]
516mod tests {
517    use super::*;
518    use anyhow::Result;
519    use mold_core::GenerateRequest;
520
521    struct MockEngine {
522        name: String,
523        loaded: bool,
524    }
525
526    impl MockEngine {
527        fn new(name: &str) -> Self {
528            Self {
529                name: name.to_string(),
530                loaded: true,
531            }
532        }
533
534        /// Returns an engine that reports `is_loaded() == false` so the
535        /// cache classifies it as `Parked` on insert. Used by tests that
536        /// exercise LRU machinery and don't care which entry is GPU-resident
537        /// — letting both insert-as-loaded would violate the invariant
538        /// "at most one entry has residency == Gpu".
539        fn parked(name: &str) -> Self {
540            Self {
541                name: name.to_string(),
542                loaded: false,
543            }
544        }
545    }
546
547    impl InferenceEngine for MockEngine {
548        fn generate(&mut self, _req: &GenerateRequest) -> Result<mold_core::GenerateResponse> {
549            unimplemented!()
550        }
551        fn model_name(&self) -> &str {
552            &self.name
553        }
554        fn is_loaded(&self) -> bool {
555            self.loaded
556        }
557        fn load(&mut self) -> Result<()> {
558            self.loaded = true;
559            Ok(())
560        }
561        fn unload(&mut self) {
562            self.loaded = false;
563        }
564    }
565
566    #[test]
567    fn insert_and_get() {
568        let mut cache = ModelCache::new(3);
569        cache.insert(Box::new(MockEngine::new("model-a")), 1000);
570        assert!(cache.contains("model-a"));
571        assert_eq!(cache.len(), 1);
572        assert_eq!(cache.active_model(), Some("model-a"));
573    }
574
575    #[test]
576    fn lru_eviction() {
577        let mut cache = ModelCache::new(2);
578        // Only one engine may be GPU-resident at a time. Use parked() for
579        // the others so the cache invariant holds.
580        cache.insert(Box::new(MockEngine::parked("model-a")), 1000);
581        cache.insert(Box::new(MockEngine::parked("model-b")), 1000);
582        // Cache full (2), inserting model-c should evict model-a (LRU)
583        let evicted = cache.insert(Box::new(MockEngine::new("model-c")), 1000);
584        assert!(evicted.is_some());
585        assert!(!cache.contains("model-a"));
586        assert!(cache.contains("model-b"));
587        assert!(cache.contains("model-c"));
588    }
589
590    /// Stronger LRU guarantee: the evicted engine returned by `insert` must
591    /// be the LRU entry (model-a here), not any other entry. Callers in
592    /// `model_manager` and `gpu_worker` drop this engine outside the cache
593    /// lock — drop ordering depends on the right entry coming back.
594    #[test]
595    fn lru_eviction_returns_lru_engine() {
596        let mut cache = ModelCache::new(2);
597        cache.insert(Box::new(MockEngine::parked("model-a")), 1000);
598        cache.insert(Box::new(MockEngine::parked("model-b")), 1000);
599        let evicted = cache
600            .insert(Box::new(MockEngine::new("model-c")), 1000)
601            .expect("eviction must occur at capacity");
602        assert_eq!(
603            evicted.model_name(),
604            "model-a",
605            "evicted engine must be the LRU one (model-a), not any other"
606        );
607    }
608
609    /// Same guarantee for `insert_loaded` (the GPU-worker path). When the
610    /// cache is at capacity and a new load completes, the returned engine
611    /// must be the LRU entry that was bumped — otherwise the dropped engine
612    /// in `gpu_worker.rs` would be the wrong one and the cache would silently
613    /// retain a stale entry.
614    #[test]
615    fn insert_loaded_returns_lru_engine_on_eviction() {
616        let mut cache = ModelCache::new(2);
617        cache.insert(Box::new(MockEngine::parked("model-a")), 1000);
618        cache.insert(Box::new(MockEngine::parked("model-b")), 1000);
619        let evicted = cache
620            .insert_loaded(
621                "model-c".to_string(),
622                Box::new(MockEngine::new("model-c")),
623                1000,
624            )
625            .expect("eviction must occur at capacity");
626        assert_eq!(
627            evicted.model_name(),
628            "model-a",
629            "insert_loaded must return the LRU engine on eviction"
630        );
631    }
632
633    #[test]
634    fn touch_updates_lru_order() {
635        let mut cache = ModelCache::new(2);
636        cache.insert(Box::new(MockEngine::parked("model-a")), 1000);
637        cache.insert(Box::new(MockEngine::parked("model-b")), 1000);
638        // Touch model-a (makes model-b the LRU)
639        cache.get_mut("model-a");
640        let evicted = cache.insert(Box::new(MockEngine::new("model-c")), 1000);
641        assert!(evicted.is_some());
642        assert!(cache.contains("model-a")); // was touched, survived
643        assert!(!cache.contains("model-b")); // LRU, evicted
644        assert!(cache.contains("model-c"));
645    }
646
647    #[test]
648    fn unload_active() {
649        let mut cache = ModelCache::new(3);
650        cache.insert(Box::new(MockEngine::new("model-a")), 1000);
651        assert_eq!(cache.active_model(), Some("model-a"));
652
653        let unloaded = cache.unload_active();
654        assert_eq!(unloaded.as_deref(), Some("model-a"));
655        assert_eq!(cache.active_model(), None);
656        // Still in cache, just unloaded
657        assert!(cache.contains("model-a"));
658        let entry = cache.get_mut("model-a").unwrap();
659        assert_eq!(entry.residency, ModelResidency::Parked);
660    }
661
662    #[test]
663    fn remove_model() {
664        let mut cache = ModelCache::new(3);
665        cache.insert(Box::new(MockEngine::new("model-a")), 1000);
666        let removed = cache.remove("model-a");
667        assert!(removed.is_some());
668        assert!(!cache.contains("model-a"));
669        assert_eq!(cache.len(), 0);
670    }
671
672    #[test]
673    fn reinserting_same_model_does_not_evict() {
674        let mut cache = ModelCache::new(2);
675        cache.insert(Box::new(MockEngine::parked("model-a")), 1000);
676        cache.insert(Box::new(MockEngine::new("model-b")), 1000);
677        // Re-insert model-a as parked (should replace, not trigger eviction
678        // and not violate the at-most-one-Gpu invariant).
679        let evicted = cache.insert(Box::new(MockEngine::parked("model-a")), 2000);
680        assert!(evicted.is_none());
681        assert_eq!(cache.len(), 2);
682    }
683
684    #[test]
685    fn is_empty_and_clear() {
686        let mut cache = ModelCache::new(3);
687        assert!(cache.is_empty());
688        cache.insert(Box::new(MockEngine::new("model-a")), 100);
689        assert!(!cache.is_empty());
690        let cleared = cache.clear();
691        assert_eq!(cleared.len(), 1);
692        assert!(cache.is_empty());
693        assert_eq!(cache.len(), 0);
694    }
695
696    #[test]
697    fn unload_all_parks_all_models() {
698        let mut cache = ModelCache::new(3);
699        // Real prod inserts a single Gpu-resident engine at a time. Park
700        // model-a explicitly so we don't violate the at-most-one-Gpu
701        // invariant when model-b is also inserted.
702        cache.insert(Box::new(MockEngine::parked("model-a")), 100);
703        cache.insert(Box::new(MockEngine::new("model-b")), 200);
704
705        let unloaded = cache.unload_all();
706        // Only model-b is on GPU; unload_all parks it.
707        assert_eq!(unloaded, vec!["model-b".to_string()]);
708        assert!(cache.active_model().is_none());
709        // All entries still in cache
710        assert_eq!(cache.len(), 2);
711    }
712
713    #[test]
714    fn cached_model_names_reflects_lru_order() {
715        let mut cache = ModelCache::new(3);
716        cache.insert(Box::new(MockEngine::parked("model-a")), 100);
717        cache.insert(Box::new(MockEngine::parked("model-b")), 200);
718        cache.insert(Box::new(MockEngine::new("model-c")), 300);
719        // LRU order: a, b, c (a is oldest)
720        assert_eq!(
721            cache.cached_model_names(),
722            vec!["model-a", "model-b", "model-c"]
723        );
724        // Touch model-a, making it MRU
725        cache.get_mut("model-a");
726        assert_eq!(
727            cache.cached_model_names(),
728            vec!["model-b", "model-c", "model-a"]
729        );
730    }
731
732    #[test]
733    fn get_mut_nonexistent_returns_none() {
734        let mut cache = ModelCache::new(3);
735        assert!(cache.get_mut("nonexistent").is_none());
736    }
737
738    #[test]
739    fn remove_nonexistent_returns_none() {
740        let mut cache = ModelCache::new(3);
741        assert!(cache.remove("nonexistent").is_none());
742    }
743
744    #[test]
745    fn unload_active_when_empty_returns_none() {
746        let mut cache = ModelCache::new(3);
747        assert!(cache.unload_active().is_none());
748    }
749
750    /// `clear_in_flight` is the "abnormal exit" companion to `restore`:
751    /// when a `JoinError` (or other unrecoverable failure) breaks the
752    /// take-and-restore window, callers must call this so the name
753    /// doesn't leak into `in_flight` forever. After `take()` removes the
754    /// entry from `entries` and `clear_in_flight` clears the marker, the
755    /// cache must look fully clean — no entry, no in-flight residue,
756    /// `contains()` false.
757    #[test]
758    fn clear_in_flight_removes_marker_after_take() {
759        let mut cache = ModelCache::new(3);
760        cache.insert(Box::new(MockEngine::new("model-a")), 1000);
761
762        // Simulate a `take` that won't be `restore`d (e.g. spawn_blocking
763        // returned a JoinError).
764        let _engine = cache.take("model-a").expect("entry present before take");
765        // While in-flight, contains() lies to readers (intentional — the
766        // engine logically still belongs to the cache during the window).
767        assert!(cache.contains("model-a"));
768        // But the entry itself is physically gone, so a second take returns None.
769        assert!(cache.take("model-a").is_none());
770        // in_flight_active is set because we took the GPU-resident engine.
771        assert_eq!(cache.in_flight_active.as_deref(), Some("model-a"));
772
773        // Now simulate the abnormal-exit cleanup: caller has no engine
774        // to put back, but must clear the marker.
775        cache.clear_in_flight("model-a");
776
777        assert!(
778            !cache.contains("model-a"),
779            "after clear_in_flight, the cache must not claim the model is present"
780        );
781        assert!(
782            cache.in_flight.is_empty(),
783            "in_flight set must be empty after clearing the only marker"
784        );
785        assert!(
786            cache.in_flight_active.is_none(),
787            "in_flight_active must be cleared when its name is cleared"
788        );
789        assert_eq!(cache.len(), 0);
790        assert!(cache.is_empty());
791    }
792
793    /// `clear_in_flight` on a name that was never in flight is a no-op.
794    /// This matters because the abnormal-exit cleanup paths run
795    /// unconditionally (in the `Err(JoinError)` arm of a match), even
796    /// when the `take` succeeded but produced `None` — we never want
797    /// the cleanup itself to panic or poison the cache.
798    #[test]
799    fn clear_in_flight_is_noop_for_unknown_name() {
800        let mut cache = ModelCache::new(3);
801        cache.insert(Box::new(MockEngine::new("model-a")), 1000);
802
803        cache.clear_in_flight("never-taken");
804
805        // model-a is still fully cached.
806        assert!(cache.contains("model-a"));
807        assert_eq!(cache.len(), 1);
808        assert!(cache.in_flight.is_empty());
809    }
810
811    #[test]
812    fn max_cached_clamped_to_at_least_one() {
813        let mut cache = ModelCache::new(0);
814        cache.insert(Box::new(MockEngine::new("model-a")), 100);
815        assert_eq!(cache.len(), 1);
816        // Should still allow at least 1 entry
817        assert!(cache.contains("model-a"));
818    }
819
820    /// Backdate an entry's `last_used` so we don't have to actually sleep.
821    fn age_entry(cache: &mut ModelCache, name: &str, by: Duration) {
822        let entry = cache
823            .entries
824            .get_mut(name)
825            .expect("entry must exist for ageing");
826        entry.last_used -= by;
827    }
828
829    #[test]
830    fn evict_idle_drops_old_parked_entry_keeps_fresh_one() {
831        let mut cache = ModelCache::new(3);
832        cache.insert(Box::new(MockEngine::parked("old")), 100);
833        cache.insert(Box::new(MockEngine::new("fresh")), 100);
834        // Park both so neither is GPU-resident.
835        cache.unload_all();
836        age_entry(&mut cache, "old", Duration::from_secs(120));
837
838        let evicted = cache.evict_idle(Duration::from_secs(60));
839        let names: Vec<&str> = evicted.iter().map(|(n, _)| n.as_str()).collect();
840        assert_eq!(names, vec!["old"]);
841        assert!(!cache.contains("old"));
842        assert!(cache.contains("fresh"));
843    }
844
845    #[test]
846    fn evict_idle_skips_when_only_one_entry() {
847        let mut cache = ModelCache::new(3);
848        cache.insert(Box::new(MockEngine::new("solo")), 100);
849        cache.unload_all();
850        age_entry(&mut cache, "solo", Duration::from_secs(3600));
851
852        let evicted = cache.evict_idle(Duration::from_secs(60));
853        assert!(
854            evicted.is_empty(),
855            "must keep at least one warm engine even past the TTL"
856        );
857        assert!(cache.contains("solo"));
858    }
859
860    #[test]
861    fn evict_idle_never_evicts_gpu_resident_entry() {
862        let mut cache = ModelCache::new(3);
863        // Insert `parked` as Parked and `gpu-active` as Gpu so the cache's
864        // single-Gpu invariant holds without requiring a manual residency
865        // override after the fact.
866        cache.insert(Box::new(MockEngine::parked("parked")), 100);
867        cache.insert(Box::new(MockEngine::new("gpu-active")), 100);
868        age_entry(&mut cache, "gpu-active", Duration::from_secs(3600));
869        age_entry(&mut cache, "parked", Duration::from_secs(3600));
870
871        let evicted = cache.evict_idle(Duration::from_secs(60));
872        let names: Vec<&str> = evicted.iter().map(|(n, _)| n.as_str()).collect();
873        assert_eq!(
874            names,
875            vec!["parked"],
876            "Gpu-resident entries must be left alone regardless of age"
877        );
878        assert!(cache.contains("gpu-active"));
879    }
880
881    #[test]
882    fn evict_idle_returns_engines_for_caller_drop() {
883        let mut cache = ModelCache::new(3);
884        cache.insert(Box::new(MockEngine::parked("a")), 100);
885        cache.insert(Box::new(MockEngine::new("b")), 100);
886        cache.unload_all();
887        // `a` is older than `b` — eviction-oldest-first should drop `a` and
888        // leave the MRU (`b`) as the surviving warm engine when the
889        // "≥ 1 warm entry" guard fires.
890        age_entry(&mut cache, "a", Duration::from_secs(180));
891        age_entry(&mut cache, "b", Duration::from_secs(120));
892
893        let evicted = cache.evict_idle(Duration::from_secs(60));
894        // Only one of the two is evicted — the "≥ 1 warm entry" guard kicks
895        // in once the cache shrinks to a single entry. Determinism: the LRU
896        // is dropped, the MRU survives.
897        assert_eq!(evicted.len(), 1);
898        let (evicted_name, engine) = evicted.into_iter().next().unwrap();
899        assert_eq!(
900            evicted_name, "a",
901            "oldest-first sort must pick the LRU (`a`) for eviction"
902        );
903        assert_eq!(cache.len(), 1);
904        assert!(cache.contains("b"), "MRU (`b`) must survive the guard");
905        assert!(!cache.contains("a"), "LRU (`a`) must be gone");
906        // Caller receives the engine box so it can drop outside the cache lock.
907        drop(engine);
908    }
909
910    /// `evict_lru_parked_except` returns the LRU parked entry. With three
911    /// parked entries inserted in order a → b → c, the LRU is `a` and that
912    /// must come back. The cache shrinks by one and `a` is no longer
913    /// `contains()`.
914    #[test]
915    fn evict_lru_parked_returns_lru_parked_entry() {
916        let mut cache = ModelCache::new(3);
917        cache.insert(Box::new(MockEngine::parked("a")), 100);
918        cache.insert(Box::new(MockEngine::parked("b")), 100);
919        cache.insert(Box::new(MockEngine::parked("c")), 100);
920
921        let evicted = cache.evict_lru_parked_except(None).expect("must evict");
922        assert_eq!(evicted.0, "a");
923        assert!(!cache.contains("a"));
924        assert!(cache.contains("b"));
925        assert!(cache.contains("c"));
926        assert_eq!(cache.len(), 2);
927    }
928
929    /// The `skip` argument exists so the parked-reload branch doesn't evict
930    /// the very engine it's about to reload. With LRU order [a, b, c] and
931    /// skip=Some("a"), the eviction must pick `b` (next-LRU non-skipped) and
932    /// leave `a` untouched.
933    #[test]
934    fn evict_lru_parked_respects_skip() {
935        let mut cache = ModelCache::new(3);
936        cache.insert(Box::new(MockEngine::parked("a")), 100);
937        cache.insert(Box::new(MockEngine::parked("b")), 100);
938        cache.insert(Box::new(MockEngine::parked("c")), 100);
939
940        let evicted = cache
941            .evict_lru_parked_except(Some("a"))
942            .expect("must evict next-LRU when LRU is skipped");
943        assert_eq!(evicted.0, "b", "skip(a) must skip a and pick the next LRU");
944        assert!(cache.contains("a"));
945        assert!(!cache.contains("b"));
946        assert!(cache.contains("c"));
947    }
948
949    /// Never evict a GPU-resident entry — the eviction is a budget-recovery
950    /// path before a *new* load, and the active engine is still in use until
951    /// the explicit `unload_active()` step.
952    #[test]
953    fn evict_lru_parked_never_picks_gpu_resident() {
954        let mut cache = ModelCache::new(3);
955        // `gpu` is GPU-resident (loaded), `parked` is not. Even though `gpu`
956        // is the LRU here, it must not be the victim.
957        cache.insert(Box::new(MockEngine::new("gpu")), 100);
958        cache.insert(Box::new(MockEngine::parked("parked")), 100);
959
960        let evicted = cache.evict_lru_parked_except(None).expect("must evict");
961        assert_eq!(
962            evicted.0, "parked",
963            "Gpu-resident entries must be left alone — only parked are eligible"
964        );
965        assert!(cache.contains("gpu"));
966        assert!(!cache.contains("parked"));
967    }
968
969    /// When the only candidates are skipped or GPU-resident, return `None`.
970    /// The caller (preflight loop in gpu_worker) treats `None` as "nothing
971    /// left to surrender" and surfaces the original OOM error.
972    #[test]
973    fn evict_lru_parked_returns_none_when_only_skip_or_gpu_remain() {
974        let mut cache = ModelCache::new(3);
975        cache.insert(Box::new(MockEngine::new("gpu")), 100);
976        cache.insert(Box::new(MockEngine::parked("a")), 100);
977
978        // skip=Some("a") leaves only `gpu` (ineligible) and `a` (skipped).
979        assert!(cache.evict_lru_parked_except(Some("a")).is_none());
980        // Cache is unchanged.
981        assert!(cache.contains("gpu"));
982        assert!(cache.contains("a"));
983        assert_eq!(cache.len(), 2);
984    }
985
986    /// The returned engine is the actual engine box — caller-owned and
987    /// droppable outside the cache lock. Verify the box's model_name matches
988    /// the evicted name so caller-side drop ordering can't latch onto the
989    /// wrong engine.
990    #[test]
991    fn evict_lru_parked_returns_matching_engine_box() {
992        let mut cache = ModelCache::new(3);
993        cache.insert(Box::new(MockEngine::parked("alpha")), 100);
994        cache.insert(Box::new(MockEngine::parked("beta")), 100);
995
996        let (name, engine) = cache.evict_lru_parked_except(None).unwrap();
997        assert_eq!(name, "alpha");
998        assert_eq!(engine.model_name(), "alpha");
999    }
1000
1001    /// Run a battery of legal operations in sequence and confirm none of
1002    /// them tripped the debug invariant assertions in `debug_check_invariants`.
1003    /// We can't directly test the negative case (calling private internals
1004    /// to corrupt state) without `unsafe` access, so this is the strongest
1005    /// reachable form: every public mutator should preserve `entries.len() ==
1006    /// lru_order.len()` and `gpu_count <= 1` end-to-end.
1007    #[test]
1008    fn invariants_hold_through_legal_op_sequence() {
1009        let mut cache = ModelCache::new(3);
1010
1011        // insert one loaded engine → Gpu
1012        cache.insert(Box::new(MockEngine::new("a")), 100);
1013
1014        // unload_active before inserting another loaded engine — mirrors
1015        // production where the active model is parked before a new load.
1016        cache.unload_active();
1017        cache.insert(Box::new(MockEngine::new("b")), 100);
1018
1019        cache.unload_active();
1020        cache.insert_loaded("c".to_string(), Box::new(MockEngine::new("c")), 100);
1021
1022        // touch_order via get_mut
1023        let _ = cache.get_mut("a");
1024
1025        // take + restore round-trip on a parked entry — restore re-inserts
1026        // it as it was (parked).
1027        let taken = cache.take("b").expect("b is present");
1028        cache.restore(taken);
1029
1030        // unload_active turns Gpu → Parked
1031        cache.unload_active();
1032
1033        // reinsert same model as parked (no-op for entries length, no Gpu
1034        // promotion).
1035        cache.insert(Box::new(MockEngine::parked("a")), 200);
1036
1037        // unload_all parks every Gpu entry
1038        cache.unload_all();
1039
1040        // remove drops one entry
1041        cache.remove("c");
1042
1043        // capacity-driven eviction via insert when full
1044        cache.insert(Box::new(MockEngine::parked("d")), 100);
1045        cache.insert(Box::new(MockEngine::parked("e")), 100);
1046        // cache is at max_cached=3; one more push triggers eviction. The
1047        // invariant must hold across that path too.
1048        cache.insert(Box::new(MockEngine::parked("f")), 100);
1049
1050        // evict_idle (after backdating) with all parked entries
1051        if cache.contains("d") {
1052            age_entry(&mut cache, "d", Duration::from_secs(120));
1053        }
1054        let _drop = cache.evict_idle(Duration::from_secs(60));
1055
1056        // clear drains everything
1057        let _all = cache.clear();
1058        assert!(cache.is_empty());
1059    }
1060}