use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use parking_lot::RwLock;
use sha2::{Digest, Sha256};
use thiserror::Error;
use tracing::{debug, info, instrument, warn};
use super::onnx_inference::OnnxInference;
use crate::domain::entities::{EmbeddingModel, ModelVersion};
#[derive(Debug, Error)]
pub enum ModelError {
#[error("Model not found: {0}")]
NotFound(String),
#[error("Failed to load model: {0}")]
LoadFailed(String),
#[error("Checksum mismatch for model {model}: expected {expected}, got {actual}")]
ChecksumMismatch {
model: String,
expected: String,
actual: String,
},
#[error("Model initialization failed: {0}")]
InitializationFailed(String),
#[error("IO error: {0}")]
Io(#[from] std::io::Error),
#[error("ONNX Runtime error: {0}")]
OnnxRuntime(String),
#[error("Model not ready: {0}")]
NotReady(String),
}
#[derive(Debug, Clone)]
pub struct ModelConfig {
pub model_dir: PathBuf,
pub intra_op_threads: usize,
pub inter_op_threads: usize,
pub verify_checksums: bool,
pub execution_providers: Vec<ExecutionProvider>,
pub max_cached_sessions: usize,
}
impl Default for ModelConfig {
fn default() -> Self {
Self {
model_dir: PathBuf::from("models"),
intra_op_threads: num_cpus::get().min(4),
inter_op_threads: 1,
verify_checksums: true,
execution_providers: vec![
ExecutionProvider::Cuda { device_id: 0 },
ExecutionProvider::CoreML,
ExecutionProvider::Cpu,
],
max_cached_sessions: 4,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ExecutionProvider {
Cpu,
Cuda {
device_id: i32,
},
CoreML,
DirectML {
device_id: i32,
},
}
pub struct ModelManager {
sessions: RwLock<HashMap<String, Arc<OnnxInference>>>,
models: RwLock<HashMap<String, EmbeddingModel>>,
active_version: RwLock<ModelVersion>,
config: ModelConfig,
}
impl ModelManager {
pub fn new(config: ModelConfig) -> Result<Self, ModelError> {
if !config.model_dir.exists() {
std::fs::create_dir_all(&config.model_dir)?;
debug!(path = ?config.model_dir, "Created model directory");
}
Ok(Self {
sessions: RwLock::new(HashMap::new()),
models: RwLock::new(HashMap::new()),
active_version: RwLock::new(ModelVersion::perch_v2_base()),
config,
})
}
pub fn with_defaults() -> Result<Self, ModelError> {
Self::new(ModelConfig::default())
}
#[instrument(skip(self), fields(model = %name))]
pub fn load_model(&self, name: &str) -> Result<Arc<OnnxInference>, ModelError> {
let version = self.active_version.read().clone();
let version_key = version.full_version();
{
let sessions = self.sessions.read();
if let Some(session) = sessions.get(&version_key) {
debug!("Using cached session for {}", version_key);
return Ok(Arc::clone(session));
}
}
let model_path = self.resolve_model_path(name, &version)?;
if self.config.verify_checksums {
if let Some(model) = self.models.read().get(&version_key) {
if !model.checksum.is_empty() {
self.verify_checksum(&model_path, &model.checksum)?;
}
}
}
info!(path = ?model_path, "Loading model");
let session = self.create_session(&model_path)?;
let session = Arc::new(session);
{
let mut sessions = self.sessions.write();
while sessions.len() >= self.config.max_cached_sessions {
if let Some(key) = sessions.keys().next().cloned() {
sessions.remove(&key);
debug!("Evicted cached session: {}", key);
}
}
sessions.insert(version_key.clone(), Arc::clone(&session));
}
{
let mut models = self.models.write();
if let Some(model) = models.get_mut(&version_key) {
model.mark_active();
}
}
info!(version = %version_key, "Model loaded successfully");
Ok(session)
}
pub fn verify_checksum(&self, path: &Path, expected: &str) -> Result<bool, ModelError> {
let actual = self.compute_checksum(path)?;
if actual != expected {
return Err(ModelError::ChecksumMismatch {
model: path.display().to_string(),
expected: expected.to_string(),
actual,
});
}
debug!(path = ?path, "Checksum verified");
Ok(true)
}
fn compute_checksum(&self, path: &Path) -> Result<String, ModelError> {
let mut file = std::fs::File::open(path)?;
let mut hasher = Sha256::new();
std::io::copy(&mut file, &mut hasher)?;
let hash = hasher.finalize();
Ok(hex::encode(hash))
}
#[instrument(skip(self, new_path), fields(model = %name, path = ?new_path))]
pub fn hot_swap(&self, name: &str, new_path: &Path) -> Result<(), ModelError> {
info!("Attempting hot-swap to new model");
let new_session = self.create_session(new_path)?;
let checksum = self.compute_checksum(new_path)?;
let old_version = self.active_version.read().clone();
let new_version = ModelVersion::new(
name,
&old_version.version, "hot-swap",
);
let version_key = new_version.full_version();
{
let mut sessions = self.sessions.write();
sessions.insert(version_key.clone(), Arc::new(new_session));
}
{
let mut models = self.models.write();
let mut model = EmbeddingModel::new(
name.to_string(),
new_version.clone(),
checksum,
);
model.model_path = Some(new_path.to_string_lossy().to_string());
model.mark_active();
models.insert(version_key, model);
}
*self.active_version.write() = new_version.clone();
info!(
old_version = %old_version,
new_version = %new_version,
"Hot-swap completed successfully"
);
Ok(())
}
pub async fn get_inference(&self) -> Result<Arc<OnnxInference>, ModelError> {
let version = self.active_version.read().clone();
self.load_model(&version.name)
}
#[must_use]
pub fn current_version(&self) -> ModelVersion {
self.active_version.read().clone()
}
pub fn set_active_version(&self, version: ModelVersion) {
*self.active_version.write() = version;
}
pub async fn is_ready(&self) -> bool {
let version_key = self.active_version.read().full_version();
self.sessions.read().contains_key(&version_key)
}
#[must_use]
pub fn get_model(&self, version_key: &str) -> Option<EmbeddingModel> {
self.models.read().get(version_key).cloned()
}
#[must_use]
pub fn list_models(&self) -> Vec<EmbeddingModel> {
self.models.read().values().cloned().collect()
}
pub fn clear_cache(&self) {
self.sessions.write().clear();
info!("Cleared model session cache");
}
fn resolve_model_path(&self, name: &str, version: &ModelVersion) -> Result<PathBuf, ModelError> {
let candidates = vec![
self.config.model_dir.join(format!("{}.onnx", version.full_version())),
self.config.model_dir.join(format!("{}_{}.onnx", name, version.version)),
self.config.model_dir.join(format!("{}.onnx", name)),
self.config.model_dir.join(format!("{}/{}.onnx", name, version.version)),
];
for path in &candidates {
if path.exists() {
return Ok(path.clone());
}
}
let version_key = version.full_version();
if let Some(model) = self.models.read().get(&version_key) {
if let Some(ref path_str) = model.model_path {
let path = PathBuf::from(path_str);
if path.exists() {
return Ok(path);
}
}
}
Err(ModelError::NotFound(format!(
"Model {} not found in {:?}. Tried: {:?}",
name, self.config.model_dir, candidates
)))
}
fn create_session(&self, path: &Path) -> Result<OnnxInference, ModelError> {
OnnxInference::new(
path,
self.config.intra_op_threads,
self.config.inter_op_threads,
&self.config.execution_providers,
)
.map_err(|e| ModelError::LoadFailed(e.to_string()))
}
pub fn register_model(&self, model: EmbeddingModel) {
let version_key = model.version.full_version();
self.models.write().insert(version_key, model);
}
pub fn unload_model(&self, version_key: &str) -> bool {
let removed = self.sessions.write().remove(version_key).is_some();
if removed {
info!(version = %version_key, "Unloaded model from cache");
}
removed
}
}
impl std::fmt::Debug for ModelManager {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("ModelManager")
.field("model_dir", &self.config.model_dir)
.field("active_version", &*self.active_version.read())
.field("cached_sessions", &self.sessions.read().len())
.finish()
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Write;
use tempfile::tempdir;
#[test]
fn test_model_config_default() {
let config = ModelConfig::default();
assert!(config.intra_op_threads > 0);
assert!(config.verify_checksums);
}
#[test]
fn test_model_manager_creation() {
let dir = tempdir().unwrap();
let config = ModelConfig {
model_dir: dir.path().to_path_buf(),
..Default::default()
};
let manager = ModelManager::new(config);
assert!(manager.is_ok());
}
#[test]
fn test_checksum_computation() {
let dir = tempdir().unwrap();
let file_path = dir.path().join("test.bin");
let mut file = std::fs::File::create(&file_path).unwrap();
file.write_all(b"test content").unwrap();
let config = ModelConfig {
model_dir: dir.path().to_path_buf(),
..Default::default()
};
let manager = ModelManager::new(config).unwrap();
let checksum = manager.compute_checksum(&file_path).unwrap();
assert!(!checksum.is_empty());
assert_eq!(checksum.len(), 64); }
#[test]
fn test_model_version_key() {
let version = ModelVersion::perch_v2_base();
assert_eq!(version.full_version(), "perch-v2-2.0.0-base");
}
#[test]
fn test_register_model() {
let dir = tempdir().unwrap();
let config = ModelConfig {
model_dir: dir.path().to_path_buf(),
..Default::default()
};
let manager = ModelManager::new(config).unwrap();
let model = EmbeddingModel::perch_v2_default();
let version_key = model.version.full_version();
manager.register_model(model);
let retrieved = manager.get_model(&version_key);
assert!(retrieved.is_some());
}
#[test]
fn test_clear_cache() {
let dir = tempdir().unwrap();
let config = ModelConfig {
model_dir: dir.path().to_path_buf(),
..Default::default()
};
let manager = ModelManager::new(config).unwrap();
manager.clear_cache();
}
}