1use std::collections::HashMap;
2use std::sync::Mutex;
3
4use crate::types::{EtaConfidence, EtaEstimate, SizeBucket};
5
6#[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
31pub 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 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 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 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 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 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 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 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)); }
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 tracker.record("model-a", "tag", SizeBucket::Unknown, 500);
238
239 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 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}