1use std::collections::HashMap;
10use std::fs::File;
11use std::io::Read;
12use std::path::Path;
13use std::sync::Arc;
14
15use async_trait::async_trait;
16use chrono::Utc;
17use serde::{Deserialize, Serialize};
18use sha2::{Digest, Sha256};
19use tokio::sync::{RwLock, mpsc};
20use tokio::task::JoinHandle;
21
22use crate::domain::ModelFile;
23use crate::ports::{HfClientPort, ModelRepository, RepositoryError};
24
25#[derive(Debug, Clone, Serialize, Deserialize)]
31#[serde(tag = "status", rename_all = "snake_case")]
32pub enum ShardProgress {
33 Starting,
35 Hashing {
37 percent: u8,
39 bytes_processed: u64,
41 total_bytes: u64,
43 },
44 Completed {
46 health: ShardHealth,
48 },
49}
50
51#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
53#[serde(tag = "type", rename_all = "snake_case")]
54#[cfg_attr(feature = "ts-bindings", derive(ts_rs::TS), ts(export))]
55pub enum ShardHealth {
56 Healthy,
58 Corrupt {
60 expected: String,
62 actual: String,
64 },
65 Missing,
67 NoOid,
69}
70
71#[derive(Debug, Clone, Serialize, Deserialize)]
73pub struct VerificationProgress {
74 pub model_id: i64,
76 pub shard_index: usize,
78 pub total_shards: usize,
80 pub shard_progress: ShardProgress,
82}
83
84#[derive(Debug, Clone, Serialize, Deserialize)]
86#[cfg_attr(feature = "ts-bindings", derive(ts_rs::TS), ts(export))]
87pub struct VerificationReport {
88 #[cfg_attr(feature = "ts-bindings", ts(type = "number"))]
90 pub model_id: i64,
91 pub overall_health: OverallHealth,
93 pub shards: Vec<ShardHealthReport>,
95 pub verified_at: chrono::DateTime<Utc>,
97}
98
99#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
101#[cfg_attr(feature = "ts-bindings", derive(ts_rs::TS), ts(export))]
102#[serde(rename_all = "snake_case")]
103pub enum OverallHealth {
104 Healthy,
106 Unhealthy,
108 Unverifiable,
110}
111
112#[derive(Debug, Clone, Serialize, Deserialize)]
114#[cfg_attr(feature = "ts-bindings", derive(ts_rs::TS), ts(export))]
115pub struct ShardHealthReport {
116 pub index: usize,
118 pub file_path: String,
120 pub health: ShardHealth,
122}
123
124#[derive(Debug, Clone, Serialize, Deserialize)]
126#[cfg_attr(feature = "ts-bindings", derive(ts_rs::TS), ts(export))]
127pub struct UpdateCheckResult {
128 #[cfg_attr(feature = "ts-bindings", ts(type = "number"))]
130 pub model_id: i64,
131 pub update_available: bool,
133 pub details: Option<UpdateDetails>,
135}
136
137#[derive(Debug, Clone, Serialize, Deserialize)]
139#[cfg_attr(feature = "ts-bindings", derive(ts_rs::TS), ts(export))]
140pub struct UpdateDetails {
141 pub changed_shards: usize,
143 pub changes: Vec<ShardUpdate>,
145}
146
147#[derive(Debug, Clone, Serialize, Deserialize)]
149#[cfg_attr(feature = "ts-bindings", derive(ts_rs::TS), ts(export))]
150pub struct ShardUpdate {
151 pub index: usize,
153 pub file_path: String,
155 pub old_oid: String,
157 pub new_oid: String,
159}
160
161#[derive(Debug, Clone, Copy, PartialEq, Eq)]
163pub(super) enum OperationType {
164 Verifying,
166 Downloading,
168}
169
170pub(super) struct OperationGuard {
176 model_id: i64,
177 lock_map: Arc<RwLock<HashMap<i64, OperationType>>>,
178}
179
180impl Drop for OperationGuard {
181 fn drop(&mut self) {
182 let model_id = self.model_id;
183 let lock_map: Arc<RwLock<HashMap<i64, OperationType>>> = Arc::clone(&self.lock_map);
184
185 tokio::spawn(async move {
187 let mut map = lock_map.write().await;
188 map.remove(&model_id);
189 });
190 }
191}
192
193pub(super) struct ModelOperationLock {
197 locks: Arc<RwLock<HashMap<i64, OperationType>>>,
198}
199
200impl ModelOperationLock {
201 pub(super) fn new() -> Self {
203 Self {
204 locks: Arc::new(RwLock::new(HashMap::new())),
205 }
206 }
207
208 pub(super) async fn try_acquire(
213 &self,
214 model_id: i64,
215 operation: OperationType,
216 ) -> Result<OperationGuard, String> {
217 let mut map = self.locks.write().await;
218
219 if let Some(existing) = map.get(&model_id) {
220 return Err(format!(
221 "Model {model_id} is already locked for {existing:?} operation"
222 ));
223 }
224
225 map.insert(model_id, operation);
226 drop(map);
227
228 Ok(OperationGuard {
229 model_id,
230 lock_map: Arc::clone(&self.locks),
231 })
232 }
233}
234
235impl Default for ModelOperationLock {
236 fn default() -> Self {
237 Self::new()
238 }
239}
240
241#[async_trait]
250pub trait ModelFilesReaderPort: Send + Sync {
251 async fn get_by_model_id(&self, model_id: i64) -> anyhow::Result<Vec<ModelFile>>;
253
254 async fn update_verification_time(
256 &self,
257 id: i64,
258 verified_at: chrono::DateTime<Utc>,
259 ) -> anyhow::Result<()>;
260}
261
262#[async_trait]
266pub trait DownloadTriggerPort: Send + Sync {
267 async fn queue_download(
269 &self,
270 repo_id: String,
271 quantization: Option<String>,
272 ) -> anyhow::Result<String>;
273}
274
275pub struct ModelVerificationService {
277 model_repo: Arc<dyn ModelRepository>,
279 model_files_repo: Arc<dyn ModelFilesReaderPort>,
281 hf_client: Arc<dyn HfClientPort>,
283 download_trigger: Arc<dyn DownloadTriggerPort>,
285 operation_lock: ModelOperationLock,
287}
288
289impl ModelVerificationService {
290 pub fn new(
292 model_repo: Arc<dyn ModelRepository>,
293 model_files_repo: Arc<dyn ModelFilesReaderPort>,
294 hf_client: Arc<dyn HfClientPort>,
295 download_trigger: Arc<dyn DownloadTriggerPort>,
296 ) -> Self {
297 Self {
298 model_repo,
299 model_files_repo,
300 hf_client,
301 download_trigger,
302 operation_lock: ModelOperationLock::new(),
303 }
304 }
305
306 pub async fn verify_model_integrity(
319 &self,
320 model_id: i64,
321 ) -> Result<
322 (
323 mpsc::Receiver<VerificationProgress>,
324 JoinHandle<Result<VerificationReport, RepositoryError>>,
325 ),
326 String,
327 > {
328 let guard = self
330 .operation_lock
331 .try_acquire(model_id, OperationType::Verifying)
332 .await?;
333
334 let model = self
336 .model_repo
337 .get_by_id(model_id)
338 .await
339 .map_err(|e| format!("Failed to get model: {e}"))?;
340
341 let model_files = self
342 .model_files_repo
343 .get_by_model_id(model_id)
344 .await
345 .map_err(|e| format!("Failed to get model files: {e}"))?;
346
347 if model_files.is_empty() {
348 return Err("No model files found for verification".to_string());
349 }
350
351 let base_dir = model
353 .file_path
354 .parent()
355 .ok_or_else(|| "Failed to get model directory".to_string())?
356 .to_path_buf();
357
358 let total_shards = model_files.len();
359
360 let (tx, rx) = mpsc::channel(100);
362
363 let model_files_repo = Arc::clone(&self.model_files_repo);
365 let _model_repo = Arc::clone(&self.model_repo);
366
367 let handle = tokio::spawn(async move {
369 let _guard = guard;
371 let mut shard_reports = Vec::new();
372 let mut has_unhealthy = false;
373 let mut has_healthy_or_no_oid = false;
374
375 for (index, file) in model_files.iter().enumerate() {
376 let _ = tx
378 .send(VerificationProgress {
379 model_id,
380 shard_index: index,
381 total_shards,
382 shard_progress: ShardProgress::Starting,
383 })
384 .await;
385
386 let resolved_path = base_dir.join(&file.file_path);
388 let health =
389 Self::verify_shard(file, &resolved_path, model_id, index, total_shards, &tx)
390 .await;
391
392 if let Err(e) = model_files_repo
394 .update_verification_time(file.id, Utc::now())
395 .await
396 {
397 tracing::warn!(
398 model_id = model_id,
399 file_id = file.id,
400 error = %e,
401 "Failed to update verification timestamp"
402 );
403 }
404
405 match &health {
407 ShardHealth::Corrupt { .. } | ShardHealth::Missing => has_unhealthy = true,
408 ShardHealth::Healthy | ShardHealth::NoOid => has_healthy_or_no_oid = true,
409 }
410
411 shard_reports.push(ShardHealthReport {
412 index,
413 file_path: file.file_path.clone(),
414 health: health.clone(),
415 });
416
417 let _ = tx
419 .send(VerificationProgress {
420 model_id,
421 shard_index: index,
422 total_shards,
423 shard_progress: ShardProgress::Completed { health },
424 })
425 .await;
426 }
427
428 let overall_health = if has_unhealthy {
429 OverallHealth::Unhealthy
430 } else if !has_healthy_or_no_oid {
431 OverallHealth::Unverifiable
432 } else {
433 OverallHealth::Healthy
434 };
435
436 Ok(VerificationReport {
437 model_id,
438 overall_health,
439 shards: shard_reports,
440 verified_at: Utc::now(),
441 })
442 });
443
444 Ok((rx, handle))
445 }
446
447 #[allow(clippy::cognitive_complexity)]
449 async fn verify_shard(
450 file: &ModelFile,
451 resolved_path: &Path,
452 model_id: i64,
453 index: usize,
454 total_shards: usize,
455 tx: &mpsc::Sender<VerificationProgress>,
456 ) -> ShardHealth {
457 let Some(ref expected_oid) = file.hf_oid else {
459 return ShardHealth::NoOid;
460 };
461
462 if expected_oid.len() != 64 {
466 tracing::warn!(
467 model_id = model_id,
468 file_path = %file.file_path,
469 oid_len = expected_oid.len(),
470 "Stored OID is not a SHA256 hash (expected 64 hex chars). \
471 Re-download or update model metadata to fix."
472 );
473 return ShardHealth::NoOid;
474 }
475
476 let file_path = resolved_path;
477
478 if !file_path.exists() {
480 return ShardHealth::Missing;
481 }
482
483 let path_owned = file_path.to_path_buf();
485 let tx_clone = tx.clone();
486
487 let result = tokio::task::spawn_blocking(move || -> anyhow::Result<String> {
488 let mut file = File::open(&path_owned)?;
489 let total_bytes = file.metadata()?.len();
490
491 let mut hasher = Sha256::new();
492 let mut buffer = vec![0u8; 1024 * 1024]; let mut bytes_processed = 0u64;
494
495 let _ = tx_clone.blocking_send(VerificationProgress {
497 model_id,
498 shard_index: index,
499 total_shards,
500 shard_progress: ShardProgress::Hashing {
501 percent: 0,
502 bytes_processed: 0,
503 total_bytes,
504 },
505 });
506
507 loop {
508 let n = file.read(&mut buffer)?;
509 if n == 0 {
510 break;
511 }
512
513 hasher.update(&buffer[..n]);
514 bytes_processed += n as u64;
515
516 if bytes_processed % (100 * 1024 * 1024) < (1024 * 1024)
518 || bytes_processed == total_bytes
519 {
520 #[allow(
521 clippy::cast_possible_truncation,
522 clippy::cast_precision_loss,
523 clippy::cast_sign_loss
524 )]
525 let percent = ((bytes_processed as f64 / total_bytes as f64) * 100.0) as u8;
526
527 let _ = tx_clone.blocking_send(VerificationProgress {
528 model_id,
529 shard_index: index,
530 total_shards,
531 shard_progress: ShardProgress::Hashing {
532 percent,
533 bytes_processed,
534 total_bytes,
535 },
536 });
537 }
538 }
539
540 Ok(format!("{:x}", hasher.finalize()))
541 })
542 .await;
543
544 match result {
545 Ok(Ok(computed_hash)) => {
546 if computed_hash == *expected_oid {
547 ShardHealth::Healthy
548 } else {
549 ShardHealth::Corrupt {
550 expected: expected_oid.clone(),
551 actual: computed_hash,
552 }
553 }
554 }
555 Ok(Err(e)) => {
556 tracing::error!(
557 model_id = model_id,
558 file_path = %file.file_path,
559 error = %e,
560 "Failed to compute hash"
561 );
562 ShardHealth::Missing
563 }
564 Err(e) => {
565 tracing::error!(
566 model_id = model_id,
567 file_path = %file.file_path,
568 error = %e,
569 "Task panicked during hash computation"
570 );
571 ShardHealth::Missing
572 }
573 }
574 }
575
576 pub async fn check_for_updates(
580 &self,
581 model_id: i64,
582 ) -> Result<UpdateCheckResult, RepositoryError> {
583 let model = self.model_repo.get_by_id(model_id).await?;
585
586 let Some(ref repo_id) = model.hf_repo_id else {
587 return Ok(UpdateCheckResult {
588 model_id,
589 update_available: false,
590 details: None,
591 });
592 };
593
594 let Some(ref quantization) = model.quantization else {
595 return Ok(UpdateCheckResult {
596 model_id,
597 update_available: false,
598 details: None,
599 });
600 };
601
602 let local_files = self
604 .model_files_repo
605 .get_by_model_id(model_id)
606 .await
607 .map_err(|e| RepositoryError::Storage(e.to_string()))?;
608
609 if local_files.is_empty() {
610 return Ok(UpdateCheckResult {
611 model_id,
612 update_available: false,
613 details: None,
614 });
615 }
616
617 let remote_files = self
619 .hf_client
620 .get_quantization_files(repo_id, quantization)
621 .await
622 .map_err(|e| RepositoryError::Storage(format!("Failed to fetch remote files: {e}")))?;
623
624 let mut changes = Vec::new();
626
627 for local_file in &local_files {
628 let Some(ref local_oid) = local_file.hf_oid else {
629 continue;
630 };
631
632 if let Some(remote_file) = remote_files.iter().find(|f| f.path == local_file.file_path)
634 && let Some(ref remote_oid) = remote_file.oid
635 && local_oid != remote_oid
636 {
637 let old_oid_str: String = local_oid.clone();
638 let new_oid_str: String = remote_oid.clone();
639 #[allow(clippy::cast_sign_loss)]
640 let index = local_file.file_index as usize;
641 changes.push(ShardUpdate {
642 index,
643 file_path: local_file.file_path.clone(),
644 old_oid: old_oid_str,
645 new_oid: new_oid_str,
646 });
647 }
648 }
649
650 let update_available = !changes.is_empty();
651 let details = if update_available {
652 Some(UpdateDetails {
653 changed_shards: changes.len(),
654 changes,
655 })
656 } else {
657 None
658 };
659
660 Ok(UpdateCheckResult {
661 model_id,
662 update_available,
663 details,
664 })
665 }
666
667 pub async fn repair_model(
675 &self,
676 model_id: i64,
677 shard_indices: Option<Vec<usize>>,
678 ) -> Result<String, String> {
679 let _guard = self
681 .operation_lock
682 .try_acquire(model_id, OperationType::Downloading)
683 .await?;
684
685 let model = self
687 .model_repo
688 .get_by_id(model_id)
689 .await
690 .map_err(|e| format!("Failed to get model: {e}"))?;
691
692 let Some(ref repo_id) = model.hf_repo_id else {
693 return Err("Model does not have HuggingFace repository information".to_string());
694 };
695
696 let Some(ref quantization) = model.quantization else {
697 return Err("Model does not have quantization information".to_string());
698 };
699
700 let model_files = self
702 .model_files_repo
703 .get_by_model_id(model_id)
704 .await
705 .map_err(|e| format!("Failed to get model files: {e}"))?;
706
707 let base_dir = model
709 .file_path
710 .parent()
711 .ok_or_else(|| "Failed to get model directory".to_string())?
712 .to_path_buf();
713
714 let shards_to_repair: Vec<&ModelFile> = if let Some(indices) = shard_indices {
716 #[allow(clippy::cast_sign_loss)]
717 let filter_fn = |f: &&ModelFile| indices.contains(&(f.file_index as usize));
718 model_files.iter().filter(filter_fn).collect()
719 } else {
720 let mut unhealthy = Vec::new();
722 for file in &model_files {
723 let (tx, _rx) = mpsc::channel(1);
724 let resolved_path = base_dir.join(&file.file_path);
725 let health = Self::verify_shard(file, &resolved_path, model_id, 0, 1, &tx).await;
726 match health {
727 ShardHealth::Corrupt { .. } | ShardHealth::Missing => {
728 unhealthy.push(file);
729 }
730 _ => {}
731 }
732 }
733 unhealthy
734 };
735
736 if shards_to_repair.is_empty() {
737 return Err("No unhealthy shards found to repair".to_string());
738 }
739
740 for file in &shards_to_repair {
742 let resolved_path = base_dir.join(&file.file_path);
743 if resolved_path.exists()
744 && let Err(e) = tokio::fs::remove_file(&resolved_path).await
745 {
746 tracing::warn!(
747 model_id = model_id,
748 file_path = %file.file_path,
749 error = %e,
750 "Failed to delete corrupt file"
751 );
752 }
753 }
754
755 let download_id = self
757 .download_trigger
758 .queue_download(repo_id.clone(), Some(quantization.clone()))
759 .await
760 .map_err(|e| format!("Failed to queue download: {e}"))?;
761
762 Ok(download_id)
763 }
764}
765
766#[cfg(test)]
767mod tests {
768 use super::*;
769
770 #[tokio::test]
771 async fn test_operation_lock_single_acquire() {
772 let lock = ModelOperationLock::new();
773 let guard = lock.try_acquire(1, OperationType::Verifying).await;
774 assert!(guard.is_ok());
775 }
776
777 #[tokio::test]
778 async fn test_operation_lock_double_acquire_fails() {
779 let lock = ModelOperationLock::new();
780 let _guard1 = lock.try_acquire(1, OperationType::Verifying).await.unwrap();
781 let guard2 = lock.try_acquire(1, OperationType::Downloading).await;
782 assert!(guard2.is_err());
783 }
784
785 #[tokio::test]
786 async fn test_operation_lock_release_on_drop() {
787 let lock = ModelOperationLock::new();
788 {
789 let _guard = lock.try_acquire(1, OperationType::Verifying).await.unwrap();
790 }
791 tokio::time::sleep(tokio::time::Duration::from_millis(10)).await;
793
794 let guard2 = lock.try_acquire(1, OperationType::Downloading).await;
795 assert!(guard2.is_ok());
796 }
797
798 #[tokio::test]
799 async fn test_operation_lock_different_models() {
800 let lock = ModelOperationLock::new();
801 let guard1 = lock.try_acquire(1, OperationType::Verifying).await;
802 let guard2 = lock.try_acquire(2, OperationType::Verifying).await;
803 assert!(guard1.is_ok());
804 assert!(guard2.is_ok());
805 }
806}