1#[derive(Debug, Clone)]
9pub struct AccessPattern {
10 pub key: String,
12 pub access_times: Vec<u64>,
14 pub size_bytes: usize,
16}
17
18impl AccessPattern {
19 pub fn frequency_per_hour(&self) -> f64 {
24 if self.access_times.len() < 2 {
25 return 0.0;
26 }
27 let first = *self.access_times.first().unwrap_or(&0);
28 let last = *self.access_times.last().unwrap_or(&0);
29 let span_secs = last.saturating_sub(first);
30 if span_secs == 0 {
31 return 0.0;
32 }
33 let span_hours = span_secs as f64 / 3600.0;
34 self.access_times.len() as f64 / span_hours
35 }
36
37 pub fn predict_next_access(&self) -> Option<u64> {
42 if self.access_times.len() < 2 {
43 return None;
44 }
45 let intervals: Vec<f64> = self
47 .access_times
48 .windows(2)
49 .map(|w| w[1].saturating_sub(w[0]) as f64)
50 .collect();
51
52 const ALPHA: f64 = 0.3;
54 let mut ema = intervals[0];
55 for &interval in intervals.iter().skip(1) {
56 ema = ALPHA * interval + (1.0 - ALPHA) * ema;
57 }
58
59 let last = *self.access_times.last()?;
60 let predicted = last.saturating_add(ema.round().max(0.0) as u64);
62 Some(predicted)
63 }
64
65 pub fn periodicity_secs(&self) -> Option<f64> {
72 if self.access_times.len() < 4 {
73 return None;
74 }
75 let intervals: Vec<f64> = self
76 .access_times
77 .windows(2)
78 .map(|w| w[1].saturating_sub(w[0]) as f64)
79 .collect();
80
81 let n = intervals.len();
82 if n < 3 {
83 return None;
84 }
85
86 let mean = intervals.iter().sum::<f64>() / n as f64;
88 let variance: f64 = intervals.iter().map(|x| (x - mean).powi(2)).sum::<f64>() / n as f64;
90 if variance < 1e-10 {
91 return Some(mean);
93 }
94
95 let max_lag = (n / 2).max(1);
97 let mut best_lag = 0usize;
98 let mut best_corr: f64 = 0.0;
99
100 for lag in 1..=max_lag {
101 let pairs = n - lag;
102 if pairs == 0 {
103 break;
104 }
105 let corr: f64 = (0..pairs)
106 .map(|i| (intervals[i] - mean) * (intervals[i + lag] - mean))
107 .sum::<f64>()
108 / (pairs as f64 * variance);
109
110 if corr > best_corr {
111 best_corr = corr;
112 best_lag = lag;
113 }
114 }
115
116 if best_corr > 0.3 && best_lag > 0 {
117 let period_sum: f64 = (0..(n - best_lag)).map(|i| intervals[i + best_lag]).sum();
119 let period = period_sum / (n - best_lag) as f64;
120 Some(period)
121 } else {
122 None
123 }
124 }
125}
126
127#[derive(Debug, Clone)]
131pub struct WarmupPlan {
132 pub entries_to_warm: Vec<String>,
134 pub estimated_bytes: usize,
136 pub estimated_hit_improvement: f64,
138}
139
140pub struct CacheWarmer {
142 pub patterns: Vec<AccessPattern>,
144 pub look_ahead_secs: u64,
147 pub min_frequency: f64,
149}
150
151impl CacheWarmer {
152 pub fn new() -> Self {
154 Self {
155 patterns: Vec::new(),
156 look_ahead_secs: 300, min_frequency: 1.0,
158 }
159 }
160
161 pub fn record_access(&mut self, key: &str, size_bytes: usize, time: u64) {
165 if let Some(p) = self.patterns.iter_mut().find(|p| p.key == key) {
166 p.size_bytes = size_bytes;
167 p.access_times.push(time);
168 } else {
169 self.patterns.push(AccessPattern {
170 key: key.to_string(),
171 access_times: vec![time],
172 size_bytes,
173 });
174 }
175 }
176
177 pub fn plan_warmup(&self, current_time: u64, available_bytes: usize) -> WarmupPlan {
192 let mut scored: Vec<(&AccessPattern, f64)> = self
194 .patterns
195 .iter()
196 .filter_map(|p| {
197 let freq = p.frequency_per_hour();
198 if freq < self.min_frequency {
199 return None;
200 }
201 let next = p.predict_next_access()?;
203 let deadline = current_time.saturating_add(self.look_ahead_secs);
204 if next > deadline {
205 return None;
206 }
207 let last_access = p.access_times.last().copied().unwrap_or(0);
209 let age_secs = current_time.saturating_sub(last_access);
210 let age_hours = age_secs as f64 / 3600.0;
211 let recency = (-age_hours).exp(); let size_eff = 1.0 / (1.0 + p.size_bytes as f64 / 1024.0);
214 let score = freq * recency * size_eff;
215 Some((p, score))
216 })
217 .collect();
218
219 scored.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
221
222 let mut entries_to_warm = Vec::new();
223 let mut estimated_bytes = 0usize;
224
225 for (pattern, _score) in &scored {
226 if estimated_bytes + pattern.size_bytes > available_bytes {
227 break;
228 }
229 estimated_bytes += pattern.size_bytes;
230 entries_to_warm.push(pattern.key.clone());
231 }
232
233 let total_qualifying = scored.len();
235 let included = entries_to_warm.len();
236 let estimated_hit_improvement = if total_qualifying == 0 {
237 0.0
238 } else {
239 included as f64 / total_qualifying as f64
240 };
241
242 WarmupPlan {
243 entries_to_warm,
244 estimated_bytes,
245 estimated_hit_improvement,
246 }
247 }
248
249 pub fn top_hot_keys(&self, n: usize) -> Vec<(&str, f64)> {
251 let mut scored: Vec<(&str, f64)> = self
252 .patterns
253 .iter()
254 .map(|p| (p.key.as_str(), p.frequency_per_hour()))
255 .collect();
256 scored.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
257 scored.truncate(n);
258 scored
259 }
260}
261
262#[cfg(test)]
265mod tests {
266 use super::*;
267
268 #[test]
270 fn test_record_access_creates_pattern() {
271 let mut warmer = CacheWarmer::new();
272 warmer.record_access("key1", 512, 1_000_000);
273 assert_eq!(warmer.patterns.len(), 1);
274 assert_eq!(warmer.patterns[0].key, "key1");
275 }
276
277 #[test]
279 fn test_record_access_accumulates() {
280 let mut warmer = CacheWarmer::new();
281 warmer.record_access("k", 100, 1000);
282 warmer.record_access("k", 100, 2000);
283 warmer.record_access("k", 100, 3000);
284 assert_eq!(warmer.patterns[0].access_times.len(), 3);
285 }
286
287 #[test]
289 fn test_frequency_per_hour() {
290 let p = AccessPattern {
291 key: "k".into(),
292 access_times: vec![0, 720, 1440, 2160, 2880, 3600],
294 size_bytes: 128,
295 };
296 let freq = p.frequency_per_hour();
297 assert!((freq - 6.0).abs() < 0.01, "expected ~6/h, got {freq}");
298 }
299
300 #[test]
302 fn test_frequency_single_point() {
303 let p = AccessPattern {
304 key: "k".into(),
305 access_times: vec![1000],
306 size_bytes: 64,
307 };
308 assert_eq!(p.frequency_per_hour(), 0.0);
309 }
310
311 #[test]
313 fn test_predict_next_access_uniform() {
314 let p = AccessPattern {
315 key: "k".into(),
316 access_times: vec![1000, 1100, 1200, 1300],
318 size_bytes: 64,
319 };
320 let predicted = p.predict_next_access().expect("should predict");
321 assert!(
323 predicted >= 1380 && predicted <= 1420,
324 "expected ~1400, got {predicted}"
325 );
326 }
327
328 #[test]
330 fn test_predict_next_access_insufficient() {
331 let p = AccessPattern {
332 key: "k".into(),
333 access_times: vec![500],
334 size_bytes: 32,
335 };
336 assert!(p.predict_next_access().is_none());
337 }
338
339 #[test]
341 fn test_periodicity_detected() {
342 let times: Vec<u64> = (0..20).map(|i| i * 600).collect();
344 let p = AccessPattern {
345 key: "k".into(),
346 access_times: times,
347 size_bytes: 64,
348 };
349 let period = p.periodicity_secs();
350 assert!(period.is_some(), "should detect periodicity");
351 let period = period.expect("period present");
352 assert!(
353 (period - 600.0).abs() < 5.0,
354 "expected ~600 s, got {period}"
355 );
356 }
357
358 #[test]
360 fn test_periodicity_too_few_points() {
361 let p = AccessPattern {
362 key: "k".into(),
363 access_times: vec![0, 100, 200],
364 size_bytes: 64,
365 };
366 assert!(p.periodicity_secs().is_none());
369 }
370
371 #[test]
373 fn test_top_hot_keys_order() {
374 let mut warmer = CacheWarmer::new();
375 for t in [0u64, 3600] {
377 warmer.record_access("cold", 64, t);
378 }
379 for i in 0..10u64 {
381 warmer.record_access("hot", 64, i * 360);
382 }
383 let top = warmer.top_hot_keys(2);
384 assert_eq!(top[0].0, "hot");
385 assert_eq!(top[1].0, "cold");
386 }
387
388 #[test]
390 fn test_top_hot_keys_limit() {
391 let mut warmer = CacheWarmer::new();
392 for k in ["a", "b", "c", "d", "e"] {
393 warmer.record_access(k, 64, 0);
394 warmer.record_access(k, 64, 3600);
395 }
396 assert_eq!(warmer.top_hot_keys(3).len(), 3);
397 }
398
399 #[test]
401 fn test_plan_warmup_respects_budget() {
402 let mut warmer = CacheWarmer::new();
403 warmer.look_ahead_secs = 10_000;
404 warmer.min_frequency = 0.1;
405 let now = 10_000u64;
406 for i in 0..5u64 {
408 warmer.record_access("big", 5000, i * 1800);
409 warmer.record_access("small", 100, i * 1800);
410 }
411 let plan = warmer.plan_warmup(now, 200);
413 assert!(plan.estimated_bytes <= 200);
414 assert!(!plan.entries_to_warm.contains(&"big".to_string()));
415 }
416
417 #[test]
419 fn test_plan_warmup_min_frequency_filter() {
420 let mut warmer = CacheWarmer::new();
421 warmer.look_ahead_secs = 100_000;
422 warmer.min_frequency = 100.0; warmer.record_access("rare", 64, 0);
425 warmer.record_access("rare", 64, 3600);
426 let plan = warmer.plan_warmup(7200, usize::MAX);
427 assert!(plan.entries_to_warm.is_empty());
428 }
429
430 #[test]
432 fn test_estimated_hit_improvement_range() {
433 let mut warmer = CacheWarmer::new();
434 warmer.look_ahead_secs = 100_000;
435 warmer.min_frequency = 0.1;
436 for i in 0..5u64 {
437 warmer.record_access("k", 100, i * 600);
438 }
439 let plan = warmer.plan_warmup(3000, usize::MAX);
440 assert!(plan.estimated_hit_improvement >= 0.0);
441 assert!(plan.estimated_hit_improvement <= 1.0);
442 }
443}