Skip to main content

gglib_core/download/
completion.rs

1//! Queue run completion tracking types.
2//!
3//! These types represent the completion of an entire queue run, distinct from
4//! individual download completion events. A queue run accumulates all downloads
5//! that complete between idle→busy and busy→idle transitions.
6
7use serde::{Deserialize, Serialize};
8use std::fmt;
9use uuid::Uuid;
10
11use super::types::DownloadId;
12
13/// Stable artifact identity for completion tracking.
14///
15/// This key is computed at enqueue time (before download starts) and remains
16/// stable across retries, failures, and sharded downloads. It represents "what
17/// the user thinks they downloaded" from an artifact perspective, not a request
18/// perspective.
19///
20/// # Identity Semantics
21///
22/// - Same artifact downloaded twice → same key (deduplication)
23/// - All shards in a group → same key (one entry)
24/// - Failures before metadata available → key still valid
25/// - Survives cancellations and retries
26#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
27#[serde(tag = "kind", rename_all = "snake_case")]
28pub enum CompletionKey {
29    /// `HuggingFace` model file.
30    HfFile {
31        /// Repository ID (e.g., "unsloth/Llama-3-GGUF").
32        repo_id: String,
33        /// Git revision (branch, tag, or commit SHA).
34        /// Stores exactly what the user requested (e.g., "main", "v1.0", or a SHA).
35        /// Use "unspecified" if no revision was provided.
36        revision: String,
37        /// Canonical filename (normalized for sharded models).
38        /// Shard suffixes are stripped: "model-00001-of-00008.gguf" → "model.gguf"
39        filename_canon: String,
40        /// Quantization type (e.g., "`Q4_K_M`").
41        /// Optional since some downloads may not have a meaningful quantization.
42        #[serde(skip_serializing_if = "Option::is_none")]
43        quantization: Option<String>,
44    },
45
46    /// File downloaded from URL.
47    UrlFile {
48        /// Source URL.
49        url: String,
50        /// Target filename.
51        filename: String,
52    },
53
54    /// Local file operation.
55    LocalFile {
56        /// Absolute path to the file.
57        path: String,
58    },
59}
60
61impl fmt::Display for CompletionKey {
62    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
63        match self {
64            Self::HfFile {
65                repo_id,
66                quantization,
67                ..
68            } => {
69                if let Some(quant) = quantization {
70                    write!(f, "{repo_id} ({quant})")
71                } else {
72                    write!(f, "{repo_id}")
73                }
74            }
75            Self::UrlFile { filename, .. } => write!(f, "{filename}"),
76            Self::LocalFile { path } => {
77                // Show only filename for local files
78                if let Some(name) = path.rsplit('/').next() {
79                    write!(f, "{name}")
80                } else {
81                    write!(f, "{path}")
82                }
83            }
84        }
85    }
86}
87
88/// Result kind for a completion attempt.
89#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
90#[serde(rename_all = "snake_case")]
91pub enum CompletionKind {
92    /// Successfully downloaded and registered.
93    Downloaded,
94    /// Download failed.
95    Failed,
96    /// Download was cancelled by user.
97    Cancelled,
98    /// File already existed and was validated (not re-downloaded).
99    AlreadyPresent,
100}
101
102/// Counts of attempts by result kind.
103#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
104pub struct AttemptCounts {
105    /// Number of successful downloads.
106    pub downloaded: u32,
107    /// Number of failed attempts.
108    pub failed: u32,
109    /// Number of cancelled attempts.
110    pub cancelled: u32,
111}
112
113impl AttemptCounts {
114    /// Create counts with a single attempt of the given kind.
115    #[must_use]
116    pub const fn from_kind(kind: CompletionKind) -> Self {
117        match kind {
118            CompletionKind::Downloaded => Self {
119                downloaded: 1,
120                failed: 0,
121                cancelled: 0,
122            },
123            CompletionKind::Failed => Self {
124                downloaded: 0,
125                failed: 1,
126                cancelled: 0,
127            },
128            CompletionKind::Cancelled => Self {
129                downloaded: 0,
130                failed: 0,
131                cancelled: 1,
132            },
133            CompletionKind::AlreadyPresent => Self {
134                downloaded: 0,
135                failed: 0,
136                cancelled: 0,
137            },
138        }
139    }
140
141    /// Increment the count for the given kind.
142    pub const fn increment(&mut self, kind: CompletionKind) {
143        match kind {
144            CompletionKind::Downloaded => self.downloaded += 1,
145            CompletionKind::Failed => self.failed += 1,
146            CompletionKind::Cancelled => self.cancelled += 1,
147            CompletionKind::AlreadyPresent => {
148                // AlreadyPresent doesn't increment attempt counts
149                // (it's informational, not a retry)
150            }
151        }
152    }
153
154    /// Total number of attempts across all kinds.
155    #[must_use]
156    pub const fn total(&self) -> u32 {
157        self.downloaded + self.failed + self.cancelled
158    }
159
160    /// Check if there were any retry attempts (more than one total attempt).
161    #[must_use]
162    pub const fn has_retries(&self) -> bool {
163        self.total() > 1
164    }
165}
166
167/// Details for a single completed artifact in a queue run.
168#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
169pub struct CompletionDetail {
170    /// Stable artifact identity key.
171    pub key: CompletionKey,
172    /// Human-readable display name for UI.
173    pub display_name: String,
174    /// Most recent result for this artifact.
175    pub last_result: CompletionKind,
176    /// Unix timestamp (milliseconds since epoch) of last completion.
177    pub last_completed_at_ms: u64,
178    /// All download IDs that contributed to this completion.
179    /// Multiple IDs indicate retries or re-queues.
180    pub download_ids: Vec<DownloadId>,
181    /// Breakdown of attempts by result kind.
182    pub attempt_counts: AttemptCounts,
183}
184
185/// Summary of an entire queue run from start to drain.
186///
187/// Emitted when the queue transitions from busy → idle, capturing all
188/// completions that occurred during the run regardless of timing.
189#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
190pub struct QueueRunSummary {
191    /// Unique identifier for this queue run.
192    pub run_id: Uuid,
193    /// Unix timestamp (milliseconds since epoch) when the run started.
194    pub started_at_ms: u64,
195    /// Unix timestamp (milliseconds since epoch) when the run completed.
196    pub completed_at_ms: u64,
197
198    // Attempt-based totals (diagnostics)
199    /// Total download attempts that succeeded.
200    pub total_attempts_downloaded: u32,
201    /// Total download attempts that failed.
202    pub total_attempts_failed: u32,
203    /// Total download attempts that were cancelled.
204    pub total_attempts_cancelled: u32,
205
206    // Unique key-based totals (UX)
207    /// Number of unique models successfully downloaded.
208    pub unique_models_downloaded: u32,
209    /// Number of unique models that failed.
210    pub unique_models_failed: u32,
211    /// Number of unique models that were cancelled.
212    pub unique_models_cancelled: u32,
213
214    /// True if there are more items than shown in `items`.
215    pub truncated: bool,
216
217    /// Detailed completion records, sorted by `last_completed_at_ms` (newest first).
218    /// Capped at 20 items for payload size management.
219    pub items: Vec<CompletionDetail>,
220}
221
222impl QueueRunSummary {
223    /// Total number of unique models across all result kinds.
224    #[must_use]
225    pub const fn total_unique_models(&self) -> u32 {
226        self.unique_models_downloaded + self.unique_models_failed + self.unique_models_cancelled
227    }
228
229    /// Total number of attempts across all result kinds.
230    #[must_use]
231    pub const fn total_attempts(&self) -> u32 {
232        self.total_attempts_downloaded + self.total_attempts_failed + self.total_attempts_cancelled
233    }
234
235    /// Check if any models had retry attempts.
236    #[must_use]
237    pub fn has_retries(&self) -> bool {
238        self.items
239            .iter()
240            .any(|item| item.attempt_counts.has_retries())
241    }
242}
243
244#[cfg(test)]
245mod tests {
246    use super::*;
247    use crate::download::types::DownloadId;
248
249    #[test]
250    fn test_completion_key_display() {
251        let key = CompletionKey::HfFile {
252            repo_id: "unsloth/Llama-3-GGUF".to_string(),
253            revision: "main".to_string(),
254            filename_canon: "model.gguf".to_string(),
255            quantization: Some("Q4_K_M".to_string()),
256        };
257        assert_eq!(key.to_string(), "unsloth/Llama-3-GGUF (Q4_K_M)");
258
259        let key_no_quant = CompletionKey::HfFile {
260            repo_id: "unsloth/Llama-3-GGUF".to_string(),
261            revision: "main".to_string(),
262            filename_canon: "model.gguf".to_string(),
263            quantization: None,
264        };
265        assert_eq!(key_no_quant.to_string(), "unsloth/Llama-3-GGUF");
266    }
267
268    #[test]
269    fn test_completion_key_display_variants() {
270        // UrlFile displays the filename
271        let url_key = CompletionKey::UrlFile {
272            url: "https://example.com/model.gguf".to_string(),
273            filename: "model.gguf".to_string(),
274        };
275        assert_eq!(url_key.to_string(), "model.gguf");
276
277        // LocalFile with slash — shows only the basename
278        let local_key = CompletionKey::LocalFile {
279            path: "/home/user/models/llama-3.Q4_K_M.gguf".to_string(),
280        };
281        assert_eq!(local_key.to_string(), "llama-3.Q4_K_M.gguf");
282
283        // LocalFile without slash — falls back to full path
284        let local_no_slash = CompletionKey::LocalFile {
285            path: "model.gguf".to_string(),
286        };
287        assert_eq!(local_no_slash.to_string(), "model.gguf");
288    }
289
290    #[test]
291    fn test_completion_key_serde_roundtrip() {
292        // HfFile with quantization — round-trip
293        let hf_with_quant = CompletionKey::HfFile {
294            repo_id: "unsloth/Llama-3-GGUF".to_string(),
295            revision: "main".to_string(),
296            filename_canon: "model.gguf".to_string(),
297            quantization: Some("Q4_K_M".to_string()),
298        };
299        let json = serde_json::to_string(&hf_with_quant).unwrap();
300        let parsed: CompletionKey = serde_json::from_str(&json).unwrap();
301        assert_eq!(parsed, hf_with_quant);
302
303        // HfFile without quantization — round-trip AND verify skip_serializing_if
304        let hf_no_quant = CompletionKey::HfFile {
305            repo_id: "unsloth/Llama-3-GGUF".to_string(),
306            revision: "main".to_string(),
307            filename_canon: "model.gguf".to_string(),
308            quantization: None,
309        };
310        let json_no_quant = serde_json::to_string(&hf_no_quant).unwrap();
311        let parsed_no_quant: CompletionKey = serde_json::from_str(&json_no_quant).unwrap();
312        assert_eq!(parsed_no_quant, hf_no_quant);
313
314        // Verify "quantization" key is ABSENT when None (not null)
315        let value: serde_json::Value = serde_json::from_str(&json_no_quant).unwrap();
316        assert!(
317            value.get("quantization").is_none(),
318            "quantization key should be absent when None, not present as null"
319        );
320
321        // Verify "quantization" key IS present when Some
322        let value_with: serde_json::Value = serde_json::from_str(&json).unwrap();
323        assert!(
324            value_with.get("quantization").is_some(),
325            "quantization key should be present when Some"
326        );
327
328        // UrlFile round-trip
329        let url_key = CompletionKey::UrlFile {
330            url: "https://example.com/model.gguf".to_string(),
331            filename: "model.gguf".to_string(),
332        };
333        let json_url = serde_json::to_string(&url_key).unwrap();
334        let parsed_url: CompletionKey = serde_json::from_str(&json_url).unwrap();
335        assert_eq!(parsed_url, url_key);
336
337        // LocalFile round-trip
338        let local_key = CompletionKey::LocalFile {
339            path: "/home/user/models/llama.gguf".to_string(),
340        };
341        let json_local = serde_json::to_string(&local_key).unwrap();
342        let parsed_local: CompletionKey = serde_json::from_str(&json_local).unwrap();
343        assert_eq!(parsed_local, local_key);
344
345        // CompletionKind snake_case wire format
346        for (kind, expected_wire) in [
347            (CompletionKind::Downloaded, "downloaded"),
348            (CompletionKind::Failed, "failed"),
349            (CompletionKind::Cancelled, "cancelled"),
350            (CompletionKind::AlreadyPresent, "already_present"),
351        ] {
352            let json = serde_json::to_string(&kind).unwrap();
353            assert!(
354                json.contains(expected_wire),
355                "Expected CompletionKind {kind:?} to serialize to snake_case '{expected_wire}', got: {json}"
356            );
357            let parsed: CompletionKind = serde_json::from_str(&json).unwrap();
358            assert_eq!(parsed, kind);
359        }
360    }
361
362    #[test]
363    fn test_completion_key_hash_dedup() {
364        use std::collections::HashSet;
365        use std::hash::{Hash, Hasher};
366
367        let key1 = CompletionKey::HfFile {
368            repo_id: "unsloth/Llama-3-GGUF".to_string(),
369            revision: "main".to_string(),
370            filename_canon: "model.gguf".to_string(),
371            quantization: Some("Q4_K_M".to_string()),
372        };
373        let key2 = CompletionKey::HfFile {
374            repo_id: "unsloth/Llama-3-GGUF".to_string(),
375            revision: "main".to_string(),
376            filename_canon: "model.gguf".to_string(),
377            quantization: Some("Q4_K_M".to_string()),
378        };
379
380        // Identical keys must be equal
381        assert_eq!(key1, key2);
382
383        // Identical keys must produce the same hash
384        let mut h1 = std::collections::hash_map::DefaultHasher::new();
385        let mut h2 = std::collections::hash_map::DefaultHasher::new();
386        key1.hash(&mut h1);
387        key2.hash(&mut h2);
388        assert_eq!(h1.finish(), h2.finish());
389
390        // Insert duplicates into HashSet — should collapse to 1
391        let mut set = HashSet::new();
392        set.insert(key1);
393        set.insert(key2.clone());
394        assert_eq!(set.len(), 1);
395
396        // Keys differing in revision are NOT equal (different revisions = distinct artifacts)
397        let key_diff_revision = CompletionKey::HfFile {
398            repo_id: "unsloth/Llama-3-GGUF".to_string(),
399            revision: "v2.0".to_string(),
400            filename_canon: "model.gguf".to_string(),
401            quantization: Some("Q4_K_M".to_string()),
402        };
403        assert_ne!(key2, key_diff_revision);
404
405        // Keys differing in filename_canon are NOT equal (different files = distinct)
406        let key_diff_filename = CompletionKey::HfFile {
407            repo_id: "unsloth/Llama-3-GGUF".to_string(),
408            revision: "main".to_string(),
409            filename_canon: "model-q8.gguf".to_string(),
410            quantization: Some("Q4_K_M".to_string()),
411        };
412        assert_ne!(key2, key_diff_filename);
413    }
414
415    #[test]
416    fn test_attempt_counts() {
417        let mut counts = AttemptCounts::from_kind(CompletionKind::Downloaded);
418        assert_eq!(counts.downloaded, 1);
419        assert_eq!(counts.total(), 1);
420        assert!(!counts.has_retries());
421
422        counts.increment(CompletionKind::Failed);
423        assert_eq!(counts.failed, 1);
424        assert_eq!(counts.total(), 2);
425        assert!(counts.has_retries());
426
427        counts.increment(CompletionKind::Downloaded);
428        assert_eq!(counts.downloaded, 2);
429        assert_eq!(counts.total(), 3);
430    }
431
432    #[test]
433    fn test_attempt_counts_all_kinds() {
434        // from_kind(Failed) — single failed attempt
435        let failed = AttemptCounts::from_kind(CompletionKind::Failed);
436        assert_eq!(failed.failed, 1);
437        assert_eq!(failed.downloaded, 0);
438        assert_eq!(failed.cancelled, 0);
439        assert_eq!(failed.total(), 1);
440
441        // from_kind(Cancelled) — single cancelled attempt
442        let cancelled = AttemptCounts::from_kind(CompletionKind::Cancelled);
443        assert_eq!(cancelled.cancelled, 1);
444        assert_eq!(cancelled.downloaded, 0);
445        assert_eq!(cancelled.failed, 0);
446        assert_eq!(cancelled.total(), 1);
447
448        // from_kind(AlreadyPresent) — produces all zeros, identical to Default
449        let already = AttemptCounts::from_kind(CompletionKind::AlreadyPresent);
450        let default_counts = AttemptCounts::default();
451        assert_eq!(already, default_counts);
452        assert_eq!(already.downloaded, 0);
453        assert_eq!(already.failed, 0);
454        assert_eq!(already.cancelled, 0);
455        assert_eq!(already.total(), 0);
456
457        // increment(Cancelled) — properly increments the cancelled counter
458        let mut counts = AttemptCounts::default();
459        counts.increment(CompletionKind::Cancelled);
460        assert_eq!(counts.cancelled, 1);
461        assert_eq!(counts.total(), 1);
462
463        // increment(AlreadyPresent) — no-op: counts before == counts after
464        let before = AttemptCounts {
465            downloaded: 2,
466            failed: 1,
467            cancelled: 0,
468        };
469        let mut counts = before;
470        counts.increment(CompletionKind::AlreadyPresent);
471        assert_eq!(
472            counts, before,
473            "increment(AlreadyPresent) should be a no-op"
474        );
475    }
476
477    #[test]
478    fn test_completion_detail_serde_roundtrip() {
479        // Construct a retry scenario: same model downloaded via two different DownloadIds
480        // (first attempt failed, second succeeded).
481        let id1 = DownloadId::from_model("llama-3");
482        let id2 = DownloadId::new("unsloth/llama-3-gguf", Some("Q4_K_M"));
483
484        let key = CompletionKey::HfFile {
485            repo_id: "unsloth/llama-3-gguf".to_string(),
486            revision: "main".to_string(),
487            filename_canon: "model.gguf".to_string(),
488            quantization: Some("Q4_K_M".to_string()),
489        };
490
491        let mut counts = AttemptCounts::default();
492        counts.increment(CompletionKind::Failed);
493        counts.increment(CompletionKind::Downloaded);
494
495        let detail = CompletionDetail {
496            key,
497            display_name: "unsloth/llama-3-gguf (Q4_K_M)".to_string(),
498            last_result: CompletionKind::Downloaded,
499            last_completed_at_ms: 1_700_000_000_000,
500            download_ids: vec![id1.clone(), id2.clone()],
501            attempt_counts: counts,
502        };
503
504        // Serialize to JSON string
505        let json = serde_json::to_string(&detail).expect("should serialize");
506
507        // Deserialize back
508        let restored: CompletionDetail = serde_json::from_str(&json).expect("should deserialize");
509
510        // Full equality — all fields must match exactly
511        assert_eq!(detail, restored);
512
513        // Verify the retry scenario is preserved through the round-trip
514        assert_eq!(restored.download_ids.len(), 2);
515        assert_eq!(restored.download_ids[0], id1);
516        assert_eq!(restored.download_ids[1], id2);
517        assert_eq!(restored.attempt_counts.failed, 1);
518        assert_eq!(restored.attempt_counts.downloaded, 1);
519        assert_eq!(restored.attempt_counts.total(), 2);
520
521        // Verify the JSON contains expected keys (wire-shape sanity)
522        let value: serde_json::Value = serde_json::from_str(&json).expect("should parse as Value");
523        assert!(value.get("download_ids").is_some());
524        assert!(value.get("attempt_counts").is_some());
525        assert!(value.get("key").is_some());
526        assert!(value.get("display_name").is_some());
527    }
528
529    #[test]
530    fn test_queue_run_summary_totals() {
531        let summary = QueueRunSummary {
532            run_id: Uuid::nil(),
533            started_at_ms: 0,
534            completed_at_ms: 100_000,
535            total_attempts_downloaded: 5,
536            total_attempts_failed: 1,
537            total_attempts_cancelled: 0,
538            unique_models_downloaded: 3,
539            unique_models_failed: 1,
540            unique_models_cancelled: 0,
541            items: vec![],
542            truncated: false,
543        };
544
545        assert_eq!(summary.total_attempts(), 6);
546        assert_eq!(summary.total_unique_models(), 4);
547    }
548
549    #[test]
550    fn test_queue_run_summary_has_retries() {
551        // FALSE scenario: all items have exactly 1 attempt — no retries
552        let single_attempt_detail = CompletionDetail {
553            key: CompletionKey::HfFile {
554                repo_id: "model-a".to_string(),
555                revision: "main".to_string(),
556                filename_canon: "a.gguf".to_string(),
557                quantization: None,
558            },
559            display_name: "model-a".to_string(),
560            last_result: CompletionKind::Downloaded,
561            last_completed_at_ms: 1000,
562            download_ids: vec![DownloadId::from_model("model-a")],
563            attempt_counts: AttemptCounts::from_kind(CompletionKind::Downloaded),
564        };
565
566        let no_retry_summary = QueueRunSummary {
567            run_id: Uuid::nil(),
568            started_at_ms: 0,
569            completed_at_ms: 1000,
570            total_attempts_downloaded: 3,
571            total_attempts_failed: 0,
572            total_attempts_cancelled: 0,
573            unique_models_downloaded: 3,
574            unique_models_failed: 0,
575            unique_models_cancelled: 0,
576            items: vec![
577                single_attempt_detail.clone(),
578                single_attempt_detail.clone(),
579                single_attempt_detail.clone(),
580            ],
581            truncated: false,
582        };
583        assert!(
584            !no_retry_summary.has_retries(),
585            "should be false when all items have exactly 1 attempt"
586        );
587
588        // TRUE scenario: one item has 3 attempts (2 failures + 1 success)
589        let mut retried_counts = AttemptCounts::default();
590        retried_counts.increment(CompletionKind::Failed);
591        retried_counts.increment(CompletionKind::Failed);
592        retried_counts.increment(CompletionKind::Downloaded);
593
594        let retried_detail = CompletionDetail {
595            key: CompletionKey::HfFile {
596                repo_id: "model-b".to_string(),
597                revision: "main".to_string(),
598                filename_canon: "b.gguf".to_string(),
599                quantization: None,
600            },
601            display_name: "model-b".to_string(),
602            last_result: CompletionKind::Downloaded,
603            last_completed_at_ms: 2000,
604            download_ids: vec![
605                DownloadId::from_model("model-b"),
606                DownloadId::from_model("model-b"),
607                DownloadId::from_model("model-b"),
608            ],
609            attempt_counts: retried_counts,
610        };
611
612        let retry_summary = QueueRunSummary {
613            run_id: Uuid::nil(),
614            started_at_ms: 0,
615            completed_at_ms: 2000,
616            // 3 unique models downloaded, but 5 total attempts (one was retried)
617            total_attempts_downloaded: 4,
618            total_attempts_failed: 2,
619            total_attempts_cancelled: 0,
620            unique_models_downloaded: 3,
621            unique_models_failed: 0,
622            unique_models_cancelled: 0,
623            items: vec![
624                single_attempt_detail.clone(),
625                retried_detail.clone(),
626                single_attempt_detail,
627            ],
628            truncated: false,
629        };
630        assert!(
631            retry_summary.has_retries(),
632            "should be true when at least one item has >1 attempt"
633        );
634
635        // Verify unique-vs-attempts distinction: 3 unique models but 6 total attempts
636        assert_eq!(retry_summary.total_unique_models(), 3);
637        assert_eq!(retry_summary.total_attempts(), 6);
638        assert_ne!(
639            retry_summary.total_unique_models(),
640            retry_summary.total_attempts(),
641            "unique models should differ from total attempts when retries occurred"
642        );
643
644        // Verify the retried item specifically
645        assert_eq!(retried_detail.attempt_counts.failed, 2);
646        assert_eq!(retried_detail.attempt_counts.downloaded, 1);
647        assert_eq!(retried_detail.attempt_counts.total(), 3);
648        assert!(retried_detail.attempt_counts.has_retries());
649    }
650}