1use std::collections::{HashMap, HashSet};
2use std::time::{Duration, Instant};
3
4use mold_inference::InferenceEngine;
5
6use crate::state::EngineSnapshot;
7
8#[derive(Debug, Clone, Copy, PartialEq, Eq)]
10pub enum ModelResidency {
11 Gpu,
13 Parked,
18}
19
20pub struct CachedEngine {
22 pub engine: Box<dyn InferenceEngine>,
23 pub model_name: String,
24 pub residency: ModelResidency,
25 pub last_used: Instant,
26 pub vram_bytes: u64,
28}
29
30pub struct ModelCache {
44 entries: HashMap<String, CachedEngine>,
45 lru_order: Vec<String>,
47 max_cached: usize,
49 in_flight: HashSet<String>,
55 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 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 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 ModelResidency::Parked
95 },
96 last_used: Instant::now(),
97 vram_bytes,
98 engine,
99 };
100
101 self.entries.insert(name.clone(), entry);
102 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 pub fn get(&self, model_name: &str) -> Option<&CachedEngine> {
116 self.entries.get(model_name)
117 }
118
119 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 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 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 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 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 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 ModelResidency::Parked
197 },
198 last_used: Instant::now(),
199 vram_bytes,
200 engine,
201 };
202
203 self.entries.insert(model_name.clone(), entry);
204 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 pub fn contains(&self, model_name: &str) -> bool {
219 self.entries.contains_key(model_name) || self.in_flight.contains(model_name)
220 }
221
222 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 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 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 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 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 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 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 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 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 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 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 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 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 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 fn report_size(&self) {
480 #[cfg(feature = "metrics")]
481 crate::metrics::set_cache_size(self.entries.len());
482 }
483
484 #[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 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 cache.insert(Box::new(MockEngine::parked("model-a")), 1000);
581 cache.insert(Box::new(MockEngine::parked("model-b")), 1000);
582 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 #[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 #[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 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")); assert!(!cache.contains("model-b")); 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 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 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 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 assert_eq!(unloaded, vec!["model-b".to_string()]);
708 assert!(cache.active_model().is_none());
709 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 assert_eq!(
721 cache.cached_model_names(),
722 vec!["model-a", "model-b", "model-c"]
723 );
724 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 #[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 let _engine = cache.take("model-a").expect("entry present before take");
765 assert!(cache.contains("model-a"));
768 assert!(cache.take("model-a").is_none());
770 assert_eq!(cache.in_flight_active.as_deref(), Some("model-a"));
772
773 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 #[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 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 assert!(cache.contains("model-a"));
818 }
819
820 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 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 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 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 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 drop(engine);
908 }
909
910 #[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 #[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 #[test]
953 fn evict_lru_parked_never_picks_gpu_resident() {
954 let mut cache = ModelCache::new(3);
955 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 #[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 assert!(cache.evict_lru_parked_except(Some("a")).is_none());
980 assert!(cache.contains("gpu"));
982 assert!(cache.contains("a"));
983 assert_eq!(cache.len(), 2);
984 }
985
986 #[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 #[test]
1008 fn invariants_hold_through_legal_op_sequence() {
1009 let mut cache = ModelCache::new(3);
1010
1011 cache.insert(Box::new(MockEngine::new("a")), 100);
1013
1014 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 let _ = cache.get_mut("a");
1024
1025 let taken = cache.take("b").expect("b is present");
1028 cache.restore(taken);
1029
1030 cache.unload_active();
1032
1033 cache.insert(Box::new(MockEngine::parked("a")), 200);
1036
1037 cache.unload_all();
1039
1040 cache.remove("c");
1042
1043 cache.insert(Box::new(MockEngine::parked("d")), 100);
1045 cache.insert(Box::new(MockEngine::parked("e")), 100);
1046 cache.insert(Box::new(MockEngine::parked("f")), 100);
1049
1050 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 let _all = cache.clear();
1058 assert!(cache.is_empty());
1059 }
1060}