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