Skip to main content

ai_batch_queue/
eta.rs

1use std::collections::HashMap;
2use std::sync::Mutex;
3
4use crate::types::{EtaConfidence, EtaEstimate, SizeBucket};
5
6/// Cache key for ETA estimation, combining resource + operation + size.
7#[derive(Debug, Clone, Hash, PartialEq, Eq)]
8pub struct EtaKey {
9    pub resource_key: String,
10    pub operation: String,
11    pub size_bucket: SizeBucket,
12}
13
14#[derive(Debug, Clone)]
15struct EtaStats {
16    total_ms: u64,
17    count: u64,
18}
19
20impl EtaStats {
21    #[allow(clippy::manual_checked_ops)]
22    fn avg_ms(&self) -> u64 {
23        if self.count == 0 {
24            0
25        } else {
26            self.total_ms / self.count
27        }
28    }
29}
30
31/// Tracks processing durations bucketed by (resource, operation, size)
32/// to provide increasingly accurate ETA estimates.
33pub struct EtaTracker {
34    data: Mutex<HashMap<EtaKey, EtaStats>>,
35}
36
37impl Default for EtaTracker {
38    fn default() -> Self {
39        Self::new()
40    }
41}
42
43impl EtaTracker {
44    pub fn new() -> Self {
45        Self {
46            data: Mutex::new(HashMap::new()),
47        }
48    }
49
50    /// Record a completed item's duration for future ETA estimates.
51    pub fn record(
52        &self,
53        resource_key: &str,
54        operation: &str,
55        size_bucket: SizeBucket,
56        duration_ms: u64,
57    ) {
58        let key = EtaKey {
59            resource_key: resource_key.to_string(),
60            operation: operation.to_string(),
61            size_bucket,
62        };
63
64        match self.data.lock() {
65            Ok(mut data) => {
66                let entry = data.entry(key).or_insert(EtaStats {
67                    total_ms: 0,
68                    count: 0,
69                });
70                entry.total_ms += duration_ms;
71                entry.count += 1;
72            }
73            Err(e) => {
74                tracing::warn!("ETA stats mutex poisoned, recovering: {}", e);
75            }
76        }
77    }
78
79    /// Estimate processing time for a single item based on historical data.
80    /// Returns `None` if no data is available for this combination.
81    pub fn estimate_one(
82        &self,
83        resource_key: &str,
84        operation: &str,
85        size_bucket: SizeBucket,
86    ) -> Option<u64> {
87        let data = self.data.lock().ok()?;
88
89        // Try exact match first
90        let key = EtaKey {
91            resource_key: resource_key.to_string(),
92            operation: operation.to_string(),
93            size_bucket,
94        };
95        if let Some(stats) = data.get(&key) {
96            return Some(stats.avg_ms());
97        }
98
99        // Fall back to Unknown bucket for this resource+operation
100        let fallback = EtaKey {
101            resource_key: resource_key.to_string(),
102            operation: operation.to_string(),
103            size_bucket: SizeBucket::Unknown,
104        };
105        data.get(&fallback).map(|stats| stats.avg_ms())
106    }
107
108    /// Estimate total remaining time for a set of items.
109    pub fn estimate_remaining(
110        &self,
111        resource_key: &str,
112        operation: &str,
113        remaining_buckets: &[SizeBucket],
114    ) -> Option<u64> {
115        let data = self.data.lock().ok()?;
116
117        let mut total_estimate: u64 = 0;
118        let mut has_data = false;
119
120        for &bucket in remaining_buckets {
121            let key = EtaKey {
122                resource_key: resource_key.to_string(),
123                operation: operation.to_string(),
124                size_bucket: bucket,
125            };
126
127            if let Some(stats) = data.get(&key) {
128                total_estimate += stats.avg_ms();
129                has_data = true;
130            } else {
131                let fallback = EtaKey {
132                    resource_key: resource_key.to_string(),
133                    operation: operation.to_string(),
134                    size_bucket: SizeBucket::Unknown,
135                };
136                if let Some(stats) = data.get(&fallback) {
137                    total_estimate += stats.avg_ms();
138                    has_data = true;
139                }
140            }
141        }
142
143        if has_data {
144            Some(total_estimate)
145        } else {
146            None
147        }
148    }
149
150    /// Estimate remaining time with structured confidence and sample counts.
151    pub fn estimate(
152        &self,
153        resource_key: &str,
154        operation: &str,
155        remaining_buckets: &[SizeBucket],
156    ) -> Option<EtaEstimate> {
157        let remaining_ms = self.estimate_remaining(resource_key, operation, remaining_buckets)?;
158        let mut total_avg_ms: u64 = 0;
159        let mut matched_items: usize = 0;
160        let mut sample_count: u64 = 0;
161
162        for &bucket in remaining_buckets {
163            if let Some(avg_ms) = self.estimate_one(resource_key, operation, bucket) {
164                total_avg_ms += avg_ms;
165                matched_items += 1;
166                sample_count += self.sample_count(resource_key, operation, bucket).max(1);
167            }
168        }
169
170        let avg_item_ms = if matched_items > 0 {
171            total_avg_ms / matched_items as u64
172        } else {
173            0
174        };
175        let confidence = match sample_count {
176            0..=2 => EtaConfidence::Low,
177            3..=9 => EtaConfidence::Medium,
178            _ => EtaConfidence::High,
179        };
180
181        Some(EtaEstimate {
182            remaining_ms,
183            items_remaining: remaining_buckets.len(),
184            avg_item_ms,
185            confidence,
186            sample_count,
187        })
188    }
189
190    /// Get the number of data points recorded for a specific key.
191    pub fn sample_count(
192        &self,
193        resource_key: &str,
194        operation: &str,
195        size_bucket: SizeBucket,
196    ) -> u64 {
197        let key = EtaKey {
198            resource_key: resource_key.to_string(),
199            operation: operation.to_string(),
200            size_bucket,
201        };
202        self.data
203            .lock()
204            .ok()
205            .and_then(|d| d.get(&key).map(|s| s.count))
206            .unwrap_or(0)
207    }
208}
209
210#[cfg(test)]
211mod tests {
212    use super::*;
213
214    #[test]
215    fn test_record_and_estimate() {
216        let tracker = EtaTracker::new();
217
218        tracker.record("model-a", "tag", SizeBucket::Medium, 1000);
219        tracker.record("model-a", "tag", SizeBucket::Medium, 2000);
220
221        let estimate = tracker.estimate_one("model-a", "tag", SizeBucket::Medium);
222        assert_eq!(estimate, Some(1500)); // (1000 + 2000) / 2
223    }
224
225    #[test]
226    fn test_no_data_returns_none() {
227        let tracker = EtaTracker::new();
228        let estimate = tracker.estimate_one("model-a", "tag", SizeBucket::Medium);
229        assert_eq!(estimate, None);
230    }
231
232    #[test]
233    fn test_fallback_to_unknown_bucket() {
234        let tracker = EtaTracker::new();
235
236        // Only record Unknown bucket
237        tracker.record("model-a", "tag", SizeBucket::Unknown, 500);
238
239        // Should fall back from Medium -> Unknown
240        let estimate = tracker.estimate_one("model-a", "tag", SizeBucket::Medium);
241        assert_eq!(estimate, Some(500));
242    }
243
244    #[test]
245    fn test_estimate_remaining_multiple() {
246        let tracker = EtaTracker::new();
247
248        tracker.record("model-a", "tag", SizeBucket::Small, 500);
249        tracker.record("model-a", "tag", SizeBucket::Large, 2000);
250
251        let remaining = vec![SizeBucket::Small, SizeBucket::Small, SizeBucket::Large];
252        let estimate = tracker.estimate_remaining("model-a", "tag", &remaining);
253        // 500 + 500 + 2000 = 3000
254        assert_eq!(estimate, Some(3000));
255    }
256
257    #[test]
258    fn test_sample_count() {
259        let tracker = EtaTracker::new();
260        assert_eq!(tracker.sample_count("m", "op", SizeBucket::Small), 0);
261
262        tracker.record("m", "op", SizeBucket::Small, 100);
263        tracker.record("m", "op", SizeBucket::Small, 200);
264        assert_eq!(tracker.sample_count("m", "op", SizeBucket::Small), 2);
265    }
266
267    #[test]
268    fn test_different_operations_isolated() {
269        let tracker = EtaTracker::new();
270
271        tracker.record("model", "tag", SizeBucket::Medium, 1000);
272        tracker.record("model", "caption", SizeBucket::Medium, 3000);
273
274        assert_eq!(
275            tracker.estimate_one("model", "tag", SizeBucket::Medium),
276            Some(1000)
277        );
278        assert_eq!(
279            tracker.estimate_one("model", "caption", SizeBucket::Medium),
280            Some(3000)
281        );
282    }
283}