1use crate::admission::{self, FreeMemory, MemoryProbe, Refused};
10use crate::catalog::{Catalog, CatalogModel};
11use crate::lifecycle::{Command, Lifecycle, ModelState};
12use crate::residency::Residency;
13use chrono::{DateTime, Utc};
14use parking_lot::{Condvar, Mutex, MutexGuard};
15use std::any::Any;
16use std::collections::HashMap;
17use std::sync::atomic::{AtomicBool, Ordering};
18use std::sync::{mpsc, Arc};
19use std::time::{Duration, Instant};
20
21const TRACE_TARGET: &str = "studio_worker::lifecycle";
22
23pub const DRAIN_TIMEOUT: Duration = Duration::from_secs(10);
28
29pub const SWAP_TIMEOUT: Duration = Duration::from_secs(30);
33
34pub trait LoadedModel: Send + Sync {
37 fn as_any(&self) -> &dyn Any;
38
39 fn as_chat(&self) -> Option<&dyn ChatModel> {
41 None
42 }
43
44 fn as_stream(&self) -> Option<&dyn StreamingModel> {
46 None
47 }
48}
49
50pub trait StreamingModel {
53 fn open(
54 &self,
55 ) -> anyhow::Result<Box<dyn crate::stt_stream::session::StreamingTranscriber + '_>>;
56}
57
58pub trait ChatModel {
60 fn chat(
65 &self,
66 params: crate::types::LlmParams,
67 cancelled: &dyn Fn() -> bool,
68 on_piece: &mut dyn FnMut(&str),
69 ) -> anyhow::Result<serde_json::Value>;
70
71 fn tokenize(&self, text: &str, add_special: bool) -> anyhow::Result<Vec<i32>>;
74}
75
76pub trait ModelRuntime: Send + Sync {
78 fn load(&self, model: &CatalogModel) -> anyhow::Result<Arc<dyn LoadedModel>>;
79
80 fn can_load(&self, _model: &CatalogModel) -> bool {
83 true
84 }
85}
86
87#[derive(Debug, Clone, PartialEq)]
89pub struct ModelStatus {
90 pub id: String,
91 pub state: ModelState,
92 pub resident: bool,
93 pub since: DateTime<Utc>,
94}
95
96#[derive(Debug, thiserror::Error)]
97pub enum HostError {
98 #[error("unknown model: {0}")]
99 UnknownModel(String),
100 #[error("model is disabled: {0}")]
101 Disabled(String),
102 #[error(transparent)]
103 Refused(#[from] Refused),
104 #[error("model {id} is not loaded ({state})")]
105 NotLoaded { id: String, state: &'static str },
106 #[error("model {0} is busy serving another request")]
107 LaneBusy(String),
108 #[error("could not persist residency: {0}")]
109 Persist(#[from] std::io::Error),
110}
111
112pub struct Lane {
115 busy: Mutex<()>,
116 cancel: AtomicBool,
117}
118
119impl Lane {
120 fn new() -> Self {
121 Self {
122 busy: Mutex::new(()),
123 cancel: AtomicBool::new(false),
124 }
125 }
126
127 pub fn cancelled(&self) -> bool {
129 self.cancel.load(Ordering::SeqCst)
130 }
131}
132
133struct Entry {
134 lifecycle: Lifecycle,
135 since: DateTime<Utc>,
136 loaded: Option<(Arc<dyn LoadedModel>, Arc<Lane>)>,
137}
138
139impl Entry {
140 fn new() -> Self {
141 Self {
142 lifecycle: Lifecycle::new(),
143 since: Utc::now(),
144 loaded: None,
145 }
146 }
147}
148
149struct Inner {
150 catalog: Arc<Mutex<Catalog>>,
151 runtime: Arc<dyn ModelRuntime>,
152 probe: Arc<dyn MemoryProbe + Send + Sync>,
153 residency: Mutex<Residency>,
154 entries: Mutex<HashMap<String, Entry>>,
155 changed: Condvar,
156 subscribers: Mutex<Vec<mpsc::Sender<ModelStatus>>>,
157}
158
159#[derive(Clone)]
161pub struct ModelHost {
162 inner: Arc<Inner>,
163}
164
165impl ModelHost {
166 pub fn new(
167 catalog: Arc<Mutex<Catalog>>,
168 runtime: Arc<dyn ModelRuntime>,
169 probe: Arc<dyn MemoryProbe + Send + Sync>,
170 residency: Residency,
171 ) -> Self {
172 Self {
173 inner: Arc::new(Inner {
174 catalog,
175 runtime,
176 probe,
177 residency: Mutex::new(residency),
178 entries: Mutex::new(HashMap::new()),
179 changed: Condvar::new(),
180 subscribers: Mutex::new(Vec::new()),
181 }),
182 }
183 }
184
185 pub fn status(&self, id: &str) -> Result<ModelStatus, HostError> {
187 self.catalogue_model(id)?;
188 let mut entries = self.inner.entries.lock();
189 Ok(self.status_locked(&mut entries, id))
190 }
191
192 pub fn statuses(&self) -> Vec<ModelStatus> {
194 let ids: Vec<String> = self
195 .inner
196 .catalog
197 .lock()
198 .list()
199 .iter()
200 .map(|m| m.id.clone())
201 .collect();
202 let mut entries = self.inner.entries.lock();
203 ids.iter()
204 .map(|id| self.status_locked(&mut entries, id))
205 .collect()
206 }
207
208 pub fn subscribe(&self) -> mpsc::Receiver<ModelStatus> {
210 let (tx, rx) = mpsc::channel();
211 self.inner.subscribers.lock().push(tx);
212 rx
213 }
214
215 pub fn loaded_gib(&self) -> f32 {
217 let catalog = self.inner.catalog.lock().list().to_vec();
218 let entries = self.inner.entries.lock();
219 loaded_gib(&catalog, &entries)
220 }
221
222 pub fn load(&self, id: &str) -> Result<ModelStatus, HostError> {
225 let model = self.catalogue_model(id)?;
226 if !model.enabled {
227 return Err(HostError::Disabled(id.to_string()));
228 }
229 let catalog = self.inner.catalog.lock().list().to_vec();
230 let mut entries = self.inner.entries.lock();
231 let needs_load = matches!(
232 entry(&mut entries, id).lifecycle.state(),
233 ModelState::Unloaded | ModelState::Failed { .. }
234 );
235 let swap_out: Vec<String> = match &model.exclusive_group {
236 Some(group) => catalog
237 .iter()
238 .filter(|m| m.id != id && m.exclusive_group.as_ref() == Some(group))
239 .filter(|m| {
240 entries.get(&m.id).is_some_and(|e| {
241 matches!(
242 e.lifecycle.state(),
243 ModelState::Loading | ModelState::Loaded
244 )
245 })
246 })
247 .map(|m| m.id.clone())
248 .collect(),
249 None => Vec::new(),
250 };
251 if needs_load {
252 let freed: f32 = catalog
253 .iter()
254 .filter(|m| swap_out.contains(&m.id))
255 .map(|m| m.vram_gb_estimate)
256 .sum();
257 let free =
258 admission::free_now(self.inner.probe.as_ref(), loaded_gib(&catalog, &entries));
259 let free = credit(free, freed);
260 if let Err(refused) = admission::admit(model.vram_gb_estimate, &free) {
261 tracing::warn!(
262 target: TRACE_TARGET,
263 op = "admit",
264 model = id,
265 error = %refused,
266 "load refused"
267 );
268 return Err(refused.into());
269 }
270 }
271 self.inner.residency.lock().set(id, true)?;
272 for other in &swap_out {
273 self.inner.residency.lock().set(other, false)?;
274 self.request_unload_locked(&mut entries, other, "swap");
275 }
276 let from = entry(&mut entries, id).lifecycle.state().clone();
277 let command = entry(&mut entries, id).lifecycle.request_load();
278 self.after_transition(&mut entries, id, "load", &from, None);
279 if command == Command::BeginLoad {
280 self.spawn_load(model, swap_out);
281 }
282 Ok(self.status_locked(&mut entries, id))
283 }
284
285 pub fn unload(&self, id: &str) -> Result<ModelStatus, HostError> {
287 self.catalogue_model(id)?;
288 let mut entries = self.inner.entries.lock();
289 self.inner.residency.lock().set(id, false)?;
290 self.request_unload_locked(&mut entries, id, "unload");
291 Ok(self.status_locked(&mut entries, id))
292 }
293
294 pub fn restore_residents(&self) {
297 let resident: Vec<String> = self
298 .inner
299 .residency
300 .lock()
301 .ids()
302 .map(String::from)
303 .collect();
304 let catalog_ids: Vec<String> = self
305 .inner
306 .catalog
307 .lock()
308 .list()
309 .iter()
310 .map(|m| m.id.clone())
311 .collect();
312 for id in resident.iter().filter(|id| !catalog_ids.contains(id)) {
313 tracing::warn!(
314 target: TRACE_TARGET,
315 op = "restore",
316 model = %id,
317 "resident model is not in the catalogue; skipped"
318 );
319 }
320 for id in catalog_ids.iter().filter(|id| resident.contains(id)) {
321 match self.load(id) {
322 Ok(_) => tracing::info!(
323 target: TRACE_TARGET,
324 op = "restore",
325 model = %id,
326 "restoring resident model"
327 ),
328 Err(err) => tracing::warn!(
329 target: TRACE_TARGET,
330 op = "restore",
331 model = %id,
332 error = %err,
333 "resident model not restored; stays resident for the next start"
334 ),
335 }
336 }
337 }
338
339 pub fn with_lane<R>(
341 &self,
342 id: &str,
343 f: impl FnOnce(&dyn LoadedModel, &Lane) -> R,
344 ) -> Result<R, HostError> {
345 let (model, lane) = self.lane_of(id)?;
346 let _busy = lane.busy.lock();
347 Self::serve(id, model.as_ref(), &lane, f)
348 }
349
350 pub fn try_with_lane<R>(
353 &self,
354 id: &str,
355 f: impl FnOnce(&dyn LoadedModel, &Lane) -> R,
356 ) -> Result<R, HostError> {
357 let (model, lane) = self.lane_of(id)?;
358 let Some(_busy) = lane.busy.try_lock() else {
359 return Err(HostError::LaneBusy(id.to_string()));
360 };
361 Self::serve(id, model.as_ref(), &lane, f)
362 }
363
364 fn serve<R>(
365 id: &str,
366 model: &dyn LoadedModel,
367 lane: &Lane,
368 f: impl FnOnce(&dyn LoadedModel, &Lane) -> R,
369 ) -> Result<R, HostError> {
370 if lane.cancelled() {
371 return Err(HostError::NotLoaded {
372 id: id.to_string(),
373 state: ModelState::Unloading.name(),
374 });
375 }
376 Ok(f(model, lane))
377 }
378
379 fn lane_of(&self, id: &str) -> Result<(Arc<dyn LoadedModel>, Arc<Lane>), HostError> {
380 let mut entries = self.inner.entries.lock();
381 let e = entry(&mut entries, id);
382 match (&e.loaded, e.lifecycle.state().serves()) {
383 (Some((m, l)), true) => Ok((m.clone(), l.clone())),
384 _ => Err(HostError::NotLoaded {
385 id: id.to_string(),
386 state: e.lifecycle.state().name(),
387 }),
388 }
389 }
390
391 pub fn can_load(&self, model: &CatalogModel) -> bool {
393 self.inner.runtime.can_load(model)
394 }
395
396 pub fn wait_for(
398 &self,
399 id: &str,
400 pred: impl Fn(&ModelState) -> bool,
401 timeout: Duration,
402 ) -> Option<ModelStatus> {
403 let deadline = Instant::now() + timeout;
404 let mut entries = self.inner.entries.lock();
405 loop {
406 if pred(entry(&mut entries, id).lifecycle.state()) {
407 return Some(self.status_locked(&mut entries, id));
408 }
409 if self
410 .inner
411 .changed
412 .wait_until(&mut entries, deadline)
413 .timed_out()
414 {
415 return None;
416 }
417 }
418 }
419
420 fn catalogue_model(&self, id: &str) -> Result<CatalogModel, HostError> {
421 self.inner
422 .catalog
423 .lock()
424 .get(id)
425 .cloned()
426 .ok_or_else(|| HostError::UnknownModel(id.to_string()))
427 }
428
429 fn status_locked(&self, entries: &mut HashMap<String, Entry>, id: &str) -> ModelStatus {
430 let e = entry(entries, id);
431 ModelStatus {
432 id: id.to_string(),
433 state: e.lifecycle.state().clone(),
434 resident: self.inner.residency.lock().is_resident(id),
435 since: e.since,
436 }
437 }
438
439 fn request_unload_locked(
440 &self,
441 entries: &mut HashMap<String, Entry>,
442 id: &str,
443 op: &'static str,
444 ) {
445 let from = entry(entries, id).lifecycle.state().clone();
446 let command = entry(entries, id).lifecycle.request_unload();
447 self.after_transition(entries, id, op, &from, None);
448 if command == Command::BeginUnload {
449 self.spawn_unload(entries, id);
450 }
451 }
452
453 fn after_transition(
455 &self,
456 entries: &mut HashMap<String, Entry>,
457 id: &str,
458 op: &'static str,
459 from: &ModelState,
460 error: Option<&str>,
461 ) {
462 let e = entry(entries, id);
463 let to = e.lifecycle.state().clone();
464 if &to == from {
465 return;
466 }
467 e.since = Utc::now();
468 match error {
469 None => tracing::info!(
470 target: TRACE_TARGET,
471 op,
472 model = id,
473 from = from.name(),
474 to = to.name(),
475 "model state changed"
476 ),
477 Some(error) => tracing::warn!(
478 target: TRACE_TARGET,
479 op,
480 model = id,
481 from = from.name(),
482 to = to.name(),
483 error,
484 "model state changed"
485 ),
486 }
487 let status = self.status_locked(entries, id);
488 self.inner
489 .subscribers
490 .lock()
491 .retain(|tx| tx.send(status.clone()).is_ok());
492 self.inner.changed.notify_all();
493 }
494
495 fn spawn_load(&self, model: CatalogModel, wait_for_unloaded: Vec<String>) {
496 let host = self.clone();
497 std::thread::spawn(move || {
498 for other in &wait_for_unloaded {
499 if host
500 .wait_for(
501 other,
502 |s| matches!(s, ModelState::Unloaded | ModelState::Failed { .. }),
503 SWAP_TIMEOUT,
504 )
505 .is_none()
506 {
507 tracing::warn!(
508 target: TRACE_TARGET,
509 op = "swap",
510 model = %model.id,
511 outgoing = %other,
512 "outgoing model did not unload in time; loading anyway"
513 );
514 }
515 }
516 let result = host.inner.runtime.load(&model);
517 host.finish_load(&model.id, result);
518 });
519 }
520
521 fn finish_load(&self, id: &str, result: anyhow::Result<Arc<dyn LoadedModel>>) {
522 let mut entries = self.inner.entries.lock();
523 let from = entry(&mut entries, id).lifecycle.state().clone();
524 let (outcome, loaded) = match result {
525 Ok(m) => (Ok(()), Some((m, Arc::new(Lane::new())))),
526 Err(err) => (Err(format!("{err:#}")), None),
527 };
528 let error = outcome.as_ref().err().cloned();
529 let e = entry(&mut entries, id);
530 match e.lifecycle.load_finished(outcome) {
531 Ok(command) => {
532 e.loaded = loaded;
533 self.after_transition(&mut entries, id, "load", &from, error.as_deref());
534 if command == Command::BeginUnload {
535 self.spawn_unload(&mut entries, id);
536 }
537 }
538 Err(unexpected) => tracing::error!(
539 target: TRACE_TARGET,
540 op = "load",
541 model = id,
542 error = %unexpected,
543 "load finished in a state that never started it; result dropped"
544 ),
545 }
546 }
547
548 fn spawn_unload(&self, entries: &mut HashMap<String, Entry>, id: &str) {
549 let lane = entry(entries, id).loaded.as_ref().map(|(_, l)| l.clone());
550 let host = self.clone();
551 let id = id.to_string();
552 std::thread::spawn(move || {
553 if let Some(lane) = lane {
554 lane.cancel.store(true, Ordering::SeqCst);
555 if lane.busy.try_lock_for(DRAIN_TIMEOUT).is_none() {
556 tracing::warn!(
557 target: TRACE_TARGET,
558 op = "unload",
559 model = %id,
560 "request in flight did not end in time; memory frees when it returns"
561 );
562 }
563 }
564 let mut entries = host.inner.entries.lock();
565 let from = entry(&mut entries, &id).lifecycle.state().clone();
566 let e = entry(&mut entries, &id);
567 e.loaded = None;
568 match e.lifecycle.unload_finished(Ok(())) {
569 Ok(_) => host.after_transition(&mut entries, &id, "unload", &from, None),
570 Err(unexpected) => tracing::error!(
571 target: TRACE_TARGET,
572 op = "unload",
573 model = %id,
574 error = %unexpected,
575 "unload finished in a state that never started it"
576 ),
577 }
578 });
579 }
580}
581
582fn entry<'a>(entries: &'a mut HashMap<String, Entry>, id: &str) -> &'a mut Entry {
583 entries.entry(id.to_string()).or_insert_with(Entry::new)
584}
585
586fn loaded_gib(catalog: &[CatalogModel], entries: &MutexGuard<'_, HashMap<String, Entry>>) -> f32 {
587 catalog
588 .iter()
589 .filter(|m| {
590 entries.get(&m.id).is_some_and(|e| {
591 matches!(
592 e.lifecycle.state(),
593 ModelState::Loading | ModelState::Loaded | ModelState::Unloading
594 )
595 })
596 })
597 .map(|m| m.vram_gb_estimate)
598 .sum()
599}
600
601fn credit(free: FreeMemory, freed_gib: f32) -> FreeMemory {
603 match free {
604 FreeMemory::Unknown => FreeMemory::Unknown,
605 other if freed_gib == 0.0 => other,
606 other => FreeMemory::Probed {
607 gib: other.gib() + freed_gib,
608 },
609 }
610}
611
612#[cfg(test)]
613mod tests {
614 use super::*;
615 use crate::catalog::{Catalog, CatalogModel};
616 use crate::lifecycle::ModelState;
617 use crate::test_support::FixedProbe;
618 use crate::types::{ModelEngine, ModelSource, TaskKind};
619 use std::sync::atomic::AtomicUsize;
620 use std::time::Duration;
621
622 const WAIT: Duration = Duration::from_secs(5);
623
624 struct FakeLoaded {
625 id: String,
626 drops: Arc<AtomicUsize>,
627 }
628 impl LoadedModel for FakeLoaded {
629 fn as_any(&self) -> &dyn std::any::Any {
630 self
631 }
632 }
633 impl Drop for FakeLoaded {
634 fn drop(&mut self) {
635 self.drops.fetch_add(1, Ordering::SeqCst);
636 }
637 }
638
639 #[derive(Default)]
641 struct FakeRuntime {
642 fail: Mutex<Vec<String>>,
643 gate: Mutex<bool>,
644 gate_cv: Condvar,
645 loads: Mutex<Vec<String>>,
646 drops: Arc<AtomicUsize>,
647 }
648 impl FakeRuntime {
649 fn open() -> Arc<Self> {
650 let r = Self::default();
651 *r.gate.lock() = true;
652 Arc::new(r)
653 }
654 fn held() -> Arc<Self> {
655 Arc::new(Self::default())
656 }
657 fn release(&self) {
658 *self.gate.lock() = true;
659 self.gate_cv.notify_all();
660 }
661 }
662 impl ModelRuntime for FakeRuntime {
663 fn load(&self, model: &CatalogModel) -> anyhow::Result<Arc<dyn LoadedModel>> {
664 let mut open = self.gate.lock();
665 while !*open {
666 self.gate_cv.wait(&mut open);
667 }
668 drop(open);
669 self.loads.lock().push(model.id.clone());
670 if self.fail.lock().contains(&model.id) {
671 anyhow::bail!("cannot load {}", model.id);
672 }
673 Ok(Arc::new(FakeLoaded {
674 id: model.id.clone(),
675 drops: self.drops.clone(),
676 }))
677 }
678 }
679
680 fn model(id: &str, gib: f32, group: Option<&str>) -> CatalogModel {
681 CatalogModel {
682 id: id.into(),
683 display_name: id.into(),
684 kind: TaskKind::AudioStt,
685 vram_gb_estimate: gib,
686 description: None,
687 source: ModelSource {
688 engine: ModelEngine::Synthetic,
689 files: vec![],
690 cli_defaults: Default::default(),
691 },
692 enabled: true,
693 origin: "local".into(),
694 exclusive_group: group.map(Into::into),
695 }
696 }
697
698 struct Fixture {
699 host: ModelHost,
700 _dir: tempfile::TempDir,
701 residency_path: std::path::PathBuf,
702 }
703
704 fn fixture(models: Vec<CatalogModel>, free_gib: f32, runtime: Arc<FakeRuntime>) -> Fixture {
705 let dir = tempfile::tempdir().unwrap();
706 let residency_path = dir.path().join("residency.json");
707 fixture_in(dir, residency_path, models, free_gib, runtime)
708 }
709
710 fn fixture_in(
711 dir: tempfile::TempDir,
712 residency_path: std::path::PathBuf,
713 models: Vec<CatalogModel>,
714 free_gib: f32,
715 runtime: Arc<FakeRuntime>,
716 ) -> Fixture {
717 let catalog = Arc::new(Mutex::new(Catalog {
718 models,
719 ..Default::default()
720 }));
721 let residency = Residency::load_for_serving(Some(residency_path.clone()));
722 let host = ModelHost::new(catalog, runtime, Arc::new(FixedProbe(free_gib)), residency);
723 Fixture {
724 host,
725 _dir: dir,
726 residency_path,
727 }
728 }
729
730 fn state_of(host: &ModelHost, id: &str) -> ModelState {
731 host.status(id).unwrap().state
732 }
733
734 #[test]
735 fn every_catalogue_model_starts_unloaded_and_not_resident() {
736 let f = fixture(vec![model("a", 1.0, None)], 20.0, FakeRuntime::open());
737 let s = f.host.status("a").unwrap();
738 assert_eq!(s.state, ModelState::Unloaded);
739 assert!(!s.resident);
740 assert_eq!(f.host.statuses().len(), 1);
741 }
742
743 #[test]
744 fn load_reaches_loaded_and_marks_resident() {
745 let f = fixture(vec![model("a", 1.0, None)], 20.0, FakeRuntime::open());
746 let s = f.host.load("a").unwrap();
747 assert!(matches!(s.state, ModelState::Loading | ModelState::Loaded));
748 assert!(s.resident);
749 let s = f.host.wait_for("a", ModelState::serves, WAIT).unwrap();
750 assert_eq!(s.state, ModelState::Loaded);
751 assert!(std::fs::read_to_string(&f.residency_path)
752 .unwrap()
753 .contains("\"a\""));
754 }
755
756 #[test]
757 fn a_held_load_shows_loading() {
758 let rt = FakeRuntime::held();
759 let f = fixture(vec![model("a", 1.0, None)], 20.0, rt.clone());
760 assert_eq!(f.host.load("a").unwrap().state, ModelState::Loading);
761 assert_eq!(state_of(&f.host, "a"), ModelState::Loading);
762 rt.release();
763 f.host.wait_for("a", ModelState::serves, WAIT).unwrap();
764 }
765
766 #[test]
767 fn a_second_load_while_loading_starts_nothing_new() {
768 let rt = FakeRuntime::held();
769 let f = fixture(vec![model("a", 1.0, None)], 20.0, rt.clone());
770 f.host.load("a").unwrap();
771 f.host.load("a").unwrap();
772 rt.release();
773 f.host.wait_for("a", ModelState::serves, WAIT).unwrap();
774 assert_eq!(rt.loads.lock().len(), 1);
775 }
776
777 #[test]
778 fn a_refused_load_changes_nothing() {
779 let f = fixture(vec![model("a", 8.0, None)], 5.0, FakeRuntime::open());
780 let err = f.host.load("a").unwrap_err();
781 assert!(matches!(err, HostError::Refused(_)), "{err}");
782 let s = f.host.status("a").unwrap();
783 assert_eq!(s.state, ModelState::Unloaded);
784 assert!(!s.resident);
785 assert!(!f.residency_path.exists());
786 }
787
788 #[test]
789 fn unknown_and_disabled_models_are_rejected_by_name() {
790 let mut off = model("off", 1.0, None);
791 off.enabled = false;
792 let f = fixture(vec![off], 20.0, FakeRuntime::open());
793 assert!(matches!(f.host.load("nope"), Err(HostError::UnknownModel(id)) if id == "nope"));
794 assert!(matches!(
795 f.host.status("nope"),
796 Err(HostError::UnknownModel(_))
797 ));
798 assert!(matches!(
799 f.host.unload("nope"),
800 Err(HostError::UnknownModel(_))
801 ));
802 assert!(matches!(f.host.load("off"), Err(HostError::Disabled(id)) if id == "off"));
803 }
804
805 #[test]
806 fn a_failed_load_shows_failed_with_the_reason_and_stays_resident() {
807 let rt = FakeRuntime::open();
808 rt.fail.lock().push("a".into());
809 let f = fixture(vec![model("a", 1.0, None)], 20.0, rt);
810 f.host.load("a").unwrap();
811 let s = f
812 .host
813 .wait_for("a", |s| matches!(s, ModelState::Failed { .. }), WAIT)
814 .unwrap();
815 match s.state {
816 ModelState::Failed { reason } => assert!(reason.contains("cannot load a"), "{reason}"),
817 other => panic!("{other:?}"),
818 }
819 assert!(s.resident, "the wish survives so the next start retries");
820 }
821
822 #[test]
823 fn unload_frees_the_model_and_clears_residency() {
824 let rt = FakeRuntime::open();
825 let f = fixture(vec![model("a", 1.0, None)], 20.0, rt.clone());
826 f.host.load("a").unwrap();
827 f.host.wait_for("a", ModelState::serves, WAIT).unwrap();
828 let s = f.host.unload("a").unwrap();
829 assert!(!s.resident);
830 f.host
831 .wait_for("a", |s| *s == ModelState::Unloaded, WAIT)
832 .unwrap();
833 assert_eq!(rt.drops.load(Ordering::SeqCst), 1, "weights dropped");
834 assert!(!std::fs::read_to_string(&f.residency_path)
835 .unwrap()
836 .contains("\"a\""));
837 }
838
839 #[test]
840 fn unload_of_an_unloaded_model_is_a_noop() {
841 let f = fixture(vec![model("a", 1.0, None)], 20.0, FakeRuntime::open());
842 assert_eq!(f.host.unload("a").unwrap().state, ModelState::Unloaded);
843 }
844
845 #[test]
846 fn unload_waits_for_the_request_in_flight_and_signals_it() {
847 let rt = FakeRuntime::open();
848 let f = fixture(vec![model("a", 1.0, None)], 20.0, rt.clone());
849 f.host.load("a").unwrap();
850 f.host.wait_for("a", ModelState::serves, WAIT).unwrap();
851 let host = f.host.clone();
852 let (started_tx, started_rx) = std::sync::mpsc::channel();
853 let serving = std::thread::spawn(move || {
854 host.with_lane("a", |_m, lane| {
855 started_tx.send(()).unwrap();
856 while !lane.cancelled() {
857 std::thread::sleep(Duration::from_millis(5));
858 }
859 "stopped"
860 })
861 });
862 started_rx.recv_timeout(WAIT).unwrap();
863 f.host.unload("a").unwrap();
864 assert_eq!(serving.join().unwrap().unwrap(), "stopped");
865 f.host
866 .wait_for("a", |s| *s == ModelState::Unloaded, WAIT)
867 .unwrap();
868 }
869
870 #[test]
871 fn serving_needs_a_loaded_model() {
872 let f = fixture(vec![model("a", 1.0, None)], 20.0, FakeRuntime::open());
873 let err = f.host.with_lane("a", |_m, _l| ()).unwrap_err();
874 assert!(
875 matches!(&err, HostError::NotLoaded { id, state } if id == "a" && *state == "unloaded"),
876 "{err}"
877 );
878 }
879
880 #[test]
881 fn try_with_lane_refuses_a_busy_lane_instead_of_waiting() {
882 let f = fixture(vec![model("a", 1.0, None)], 20.0, FakeRuntime::open());
883 f.host.load("a").unwrap();
884 f.host.wait_for("a", ModelState::serves, WAIT).unwrap();
885 let host = f.host.clone();
886 let (held_tx, held_rx) = std::sync::mpsc::channel();
887 let (release_tx, release_rx) = std::sync::mpsc::channel::<()>();
888 let holder = std::thread::spawn(move || {
889 host.with_lane("a", |_m, _l| {
890 held_tx.send(()).unwrap();
891 release_rx.recv().unwrap();
892 })
893 .unwrap()
894 });
895 held_rx.recv_timeout(WAIT).unwrap();
896 let err = f.host.try_with_lane("a", |_m, _l| ()).unwrap_err();
897 assert!(
898 matches!(&err, HostError::LaneBusy(id) if id == "a"),
899 "{err}"
900 );
901 assert_eq!(err.to_string(), "model a is busy serving another request");
902 release_tx.send(()).unwrap();
903 holder.join().unwrap();
904 assert!(f.host.try_with_lane("a", |_m, _l| ()).is_ok());
905 }
906
907 #[test]
908 fn try_with_lane_needs_a_loaded_model() {
909 let f = fixture(vec![model("a", 1.0, None)], 20.0, FakeRuntime::open());
910 assert!(matches!(
911 f.host.try_with_lane("a", |_m, _l| ()),
912 Err(HostError::NotLoaded { .. })
913 ));
914 }
915
916 #[test]
917 fn serving_hands_out_the_loaded_model() {
918 let f = fixture(vec![model("a", 1.0, None)], 20.0, FakeRuntime::open());
919 f.host.load("a").unwrap();
920 f.host.wait_for("a", ModelState::serves, WAIT).unwrap();
921 let id = f
922 .host
923 .with_lane("a", |m, _l| {
924 m.as_any().downcast_ref::<FakeLoaded>().unwrap().id.clone()
925 })
926 .unwrap();
927 assert_eq!(id, "a");
928 }
929
930 #[test]
931 fn a_lane_serves_one_request_at_a_time() {
932 let f = fixture(vec![model("a", 1.0, None)], 20.0, FakeRuntime::open());
933 f.host.load("a").unwrap();
934 f.host.wait_for("a", ModelState::serves, WAIT).unwrap();
935 let active = Arc::new(AtomicUsize::new(0));
936 let peak = Arc::new(AtomicUsize::new(0));
937 let threads: Vec<_> = (0..4)
938 .map(|_| {
939 let (host, active, peak) = (f.host.clone(), active.clone(), peak.clone());
940 std::thread::spawn(move || {
941 host.with_lane("a", |_m, _l| {
942 let now = active.fetch_add(1, Ordering::SeqCst) + 1;
943 peak.fetch_max(now, Ordering::SeqCst);
944 std::thread::sleep(Duration::from_millis(10));
945 active.fetch_sub(1, Ordering::SeqCst);
946 })
947 .unwrap()
948 })
949 })
950 .collect();
951 threads.into_iter().for_each(|t| t.join().unwrap());
952 assert_eq!(peak.load(Ordering::SeqCst), 1);
953 }
954
955 #[test]
956 fn loading_a_group_member_swaps_out_the_other() {
957 let rt = FakeRuntime::open();
958 let f = fixture(
959 vec![model("a", 3.0, Some("stt")), model("b", 3.0, Some("stt"))],
960 20.0,
961 rt.clone(),
962 );
963 f.host.load("a").unwrap();
964 f.host.wait_for("a", ModelState::serves, WAIT).unwrap();
965 f.host.load("b").unwrap();
966 f.host.wait_for("b", ModelState::serves, WAIT).unwrap();
967 let a = f.host.status("a").unwrap();
968 assert_eq!(a.state, ModelState::Unloaded);
969 assert!(!a.resident, "swapped out means no longer wished");
970 assert!(f.host.status("b").unwrap().resident);
971 assert_eq!(rt.drops.load(Ordering::SeqCst), 1);
972 }
973
974 #[test]
975 fn a_swap_is_admitted_against_the_memory_it_frees() {
976 let f = fixture(
978 vec![model("a", 3.0, Some("stt")), model("b", 5.0, Some("stt"))],
979 4.0,
980 FakeRuntime::open(),
981 );
982 f.host.load("a").unwrap();
984 f.host.wait_for("a", ModelState::serves, WAIT).unwrap();
985 f.host.load("b").unwrap();
986 f.host.wait_for("b", ModelState::serves, WAIT).unwrap();
987 }
988
989 #[test]
990 fn models_outside_a_group_are_left_alone() {
991 let f = fixture(
992 vec![model("a", 1.0, Some("stt")), model("llm", 1.0, None)],
993 20.0,
994 FakeRuntime::open(),
995 );
996 f.host.load("llm").unwrap();
997 f.host.load("a").unwrap();
998 f.host.wait_for("a", ModelState::serves, WAIT).unwrap();
999 f.host.wait_for("llm", ModelState::serves, WAIT).unwrap();
1000 }
1001
1002 #[test]
1003 fn restore_loads_residents_in_catalogue_order_and_skips_unknown_ids() {
1004 let dir = tempfile::tempdir().unwrap();
1005 let path = dir.path().join("residency.json");
1006 std::fs::write(&path, r#"{"version":1,"resident":["b","gone","a"]}"#).unwrap();
1007 let rt = FakeRuntime::open();
1008 let f = fixture_in(
1009 dir,
1010 path,
1011 vec![
1012 model("a", 1.0, None),
1013 model("b", 1.0, None),
1014 model("c", 1.0, None),
1015 ],
1016 20.0,
1017 rt.clone(),
1018 );
1019 let logs = crate::test_support::capture({
1020 let host = f.host.clone();
1021 move || host.restore_residents()
1022 });
1023 f.host.wait_for("a", ModelState::serves, WAIT).unwrap();
1024 f.host.wait_for("b", ModelState::serves, WAIT).unwrap();
1025 assert_eq!(state_of(&f.host, "c"), ModelState::Unloaded);
1026 assert!(
1027 logs.contains("resident model is not in the catalogue"),
1028 "{logs}"
1029 );
1030 assert!(logs.contains("gone"), "{logs}");
1031 }
1032
1033 #[test]
1034 fn transitions_are_published_to_subscribers() {
1035 let f = fixture(vec![model("a", 1.0, None)], 20.0, FakeRuntime::open());
1036 let rx = f.host.subscribe();
1037 f.host.load("a").unwrap();
1038 let seen: Vec<String> = (0..2)
1039 .map(|_| rx.recv_timeout(WAIT).unwrap().state.name().to_string())
1040 .collect();
1041 assert_eq!(seen, ["loading", "loaded"]);
1042 }
1043
1044 #[test]
1045 fn transitions_leave_a_lifecycle_breadcrumb() {
1046 let f = fixture(vec![model("a", 1.0, None)], 20.0, FakeRuntime::open());
1047 let logs = crate::test_support::capture({
1048 let host = f.host.clone();
1049 move || {
1050 host.load("a").unwrap();
1051 host.wait_for("a", ModelState::serves, WAIT).unwrap();
1052 }
1053 });
1054 assert!(
1055 logs.contains("op=\"load\"") || logs.contains("op=load"),
1056 "{logs}"
1057 );
1058 assert!(
1059 logs.contains("from=\"unloaded\"") || logs.contains("from=unloaded"),
1060 "{logs}"
1061 );
1062 }
1063
1064 #[test]
1065 fn loaded_estimates_are_summed_for_accounting() {
1066 let f = fixture(
1067 vec![model("a", 1.5, None), model("b", 2.0, None)],
1068 20.0,
1069 FakeRuntime::open(),
1070 );
1071 f.host.load("a").unwrap();
1072 f.host.load("b").unwrap();
1073 f.host.wait_for("a", ModelState::serves, WAIT).unwrap();
1074 f.host.wait_for("b", ModelState::serves, WAIT).unwrap();
1075 assert_eq!(f.host.loaded_gib(), 3.5);
1076 }
1077
1078 #[test]
1079 fn host_errors_read_well() {
1080 assert_eq!(
1081 HostError::UnknownModel("x".into()).to_string(),
1082 "unknown model: x"
1083 );
1084 assert_eq!(
1085 HostError::Disabled("x".into()).to_string(),
1086 "model is disabled: x"
1087 );
1088 assert_eq!(
1089 HostError::NotLoaded {
1090 id: "x".into(),
1091 state: "loading"
1092 }
1093 .to_string(),
1094 "model x is not loaded (loading)"
1095 );
1096 }
1097}