1use std::collections::BTreeMap;
7use std::time::Instant;
8
9use crate::forecast::ForecastResult;
10use dashmap::DashMap;
11use metrics::counter;
12use tracing::debug;
13
14#[derive(Debug, Clone)]
16pub struct MaterializedForecast {
17 pub result: ForecastResult,
19 pub measurement: String,
21 pub tags: BTreeMap<String, String>,
23 pub computed_at: Instant,
25 pub horizon: usize,
27}
28
29impl MaterializedForecast {
30 #[must_use]
32 pub fn is_fresh(&self, max_age: std::time::Duration) -> bool {
33 self.computed_at.elapsed() < max_age
34 }
35}
36
37type CacheKey = (String, String);
39
40pub const DEFAULT_MAX_CACHE_ENTRIES: usize = 10_000;
42
43pub struct ForecastCache {
45 cache: DashMap<CacheKey, MaterializedForecast>,
46 default_max_age: std::time::Duration,
47 max_entries: usize,
51}
52
53impl ForecastCache {
54 #[must_use]
56 pub fn new(default_max_age: std::time::Duration) -> Self {
57 Self {
58 cache: DashMap::new(),
59 default_max_age,
60 max_entries: DEFAULT_MAX_CACHE_ENTRIES,
61 }
62 }
63
64 #[must_use]
66 pub fn with_max_entries(mut self, max: usize) -> Self {
67 self.max_entries = max.max(1);
68 self
69 }
70
71 pub fn store(
73 &self,
74 measurement: &str,
75 tags: &BTreeMap<String, String>,
76 result: ForecastResult,
77 horizon: usize,
78 ) {
79 let key = (measurement.to_string(), compute_tags_key(tags));
80 self.cache.insert(
81 key,
82 MaterializedForecast {
83 result,
84 measurement: measurement.to_string(),
85 tags: tags.clone(),
86 computed_at: Instant::now(),
87 horizon,
88 },
89 );
90
91 if self.cache.len() > self.max_entries {
94 self.evict_stale();
95 }
96 if self.cache.len() > self.max_entries {
97 if let Some(oldest_key) = self
99 .cache
100 .iter()
101 .min_by_key(|entry| entry.value().computed_at)
102 .map(|entry| entry.key().clone())
103 {
104 self.cache.remove(&oldest_key);
105 }
106 }
107 counter!("chronix_forecast_materialized_total", "measurement" => measurement.to_string())
108 .increment(1);
109 debug!(measurement = %measurement, horizon, "Forecast materialized");
110 }
111
112 #[must_use]
114 pub fn get(
115 &self,
116 measurement: &str,
117 tags: &BTreeMap<String, String>,
118 ) -> Option<MaterializedForecast> {
119 let key = (measurement.to_string(), compute_tags_key(tags));
120 let entry = self.cache.get(&key)?;
121 if entry.is_fresh(self.default_max_age) {
122 counter!("chronix_forecast_cache_hit_total").increment(1);
123 Some(entry.clone())
124 } else {
125 counter!("chronix_forecast_cache_miss_total").increment(1);
126 None
127 }
128 }
129
130 #[must_use]
132 pub fn get_any(
133 &self,
134 measurement: &str,
135 tags: &BTreeMap<String, String>,
136 ) -> Option<MaterializedForecast> {
137 let key = (measurement.to_string(), compute_tags_key(tags));
138 self.cache.get(&key).map(|r| r.value().clone())
139 }
140
141 #[must_use]
143 pub fn len(&self) -> usize {
144 self.cache.len()
145 }
146
147 #[must_use]
149 pub fn is_empty(&self) -> bool {
150 self.cache.is_empty()
151 }
152
153 pub fn evict_stale(&self) -> usize {
155 let before = self.cache.len();
156 self.cache.retain(|_, v| v.is_fresh(self.default_max_age));
157 let removed = before - self.cache.len();
158 if removed > 0 {
159 debug!(removed, "Evicted stale forecasts");
160 }
161 removed
162 }
163
164 pub fn invalidate(&self, measurement: &str, tags: &BTreeMap<String, String>) {
166 let key = (measurement.to_string(), compute_tags_key(tags));
167 self.cache.remove(&key);
168 }
169
170 pub fn clear(&self) {
172 self.cache.clear();
173 }
174}
175
176impl Default for ForecastCache {
177 fn default() -> Self {
178 Self::new(std::time::Duration::from_secs(3600))
179 }
180}
181
182use crate::util::compute_tags_key;
183
184#[cfg(test)]
185mod tests {
186 use super::*;
187 use std::time::Duration;
188
189 fn make_tags(host: &str) -> BTreeMap<String, String> {
190 let mut tags = BTreeMap::new();
191 tags.insert("host".to_string(), host.to_string());
192 tags
193 }
194
195 fn make_result(n: usize) -> ForecastResult {
196 ForecastResult {
197 values: vec![1.0; n],
198 timestamps: (0..n as i64).collect(),
199 confidence_lower: vec![0.5; n],
200 confidence_upper: vec![1.5; n],
201 confidence_level: 0.95,
202 }
203 }
204
205 #[test]
206 fn store_and_retrieve() {
207 let cache = ForecastCache::new(Duration::from_secs(3600));
208 let tags = make_tags("host1");
209 cache.store("cpu", &tags, make_result(5), 5);
210
211 assert_eq!(cache.len(), 1);
212 let forecast = cache.get("cpu", &tags).unwrap();
213 assert_eq!(forecast.result.values.len(), 5);
214 assert_eq!(forecast.horizon, 5);
215 }
216
217 #[test]
218 fn stale_not_returned_by_get() {
219 let cache = ForecastCache::new(Duration::from_millis(1));
220 let tags = make_tags("host1");
221 cache.store("cpu", &tags, make_result(5), 5);
222
223 std::thread::sleep(Duration::from_millis(5));
225 assert!(cache.get("cpu", &tags).is_none());
226 }
227
228 #[test]
229 fn get_any_ignores_freshness() {
230 let cache = ForecastCache::new(Duration::from_millis(1));
231 let tags = make_tags("host1");
232 cache.store("cpu", &tags, make_result(5), 5);
233
234 std::thread::sleep(Duration::from_millis(5));
235 assert!(cache.get_any("cpu", &tags).is_some());
236 }
237
238 #[test]
239 fn evict_stale() {
240 let cache = ForecastCache::new(Duration::from_millis(1));
241 let tags = make_tags("host1");
242 cache.store("cpu", &tags, make_result(5), 5);
243
244 std::thread::sleep(Duration::from_millis(5));
245 let removed = cache.evict_stale();
246 assert_eq!(removed, 1);
247 assert!(cache.is_empty());
248 }
249
250 #[test]
251 fn invalidate() {
252 let cache = ForecastCache::new(Duration::from_secs(3600));
253 let tags = make_tags("host1");
254 cache.store("cpu", &tags, make_result(5), 5);
255 assert!(!cache.is_empty());
256
257 cache.invalidate("cpu", &tags);
258 assert!(cache.is_empty());
259 }
260
261 #[test]
262 fn multiple_series() {
263 let cache = ForecastCache::new(Duration::from_secs(3600));
264 cache.store("cpu", &make_tags("a"), make_result(5), 5);
265 cache.store("cpu", &make_tags("b"), make_result(10), 10);
266 cache.store("mem", &make_tags("a"), make_result(3), 3);
267
268 assert_eq!(cache.len(), 3);
269 let f = cache.get("cpu", &make_tags("b")).unwrap();
270 assert_eq!(f.horizon, 10);
271 }
272
273 #[test]
274 fn clear() {
275 let cache = ForecastCache::new(Duration::from_secs(3600));
276 cache.store("cpu", &make_tags("a"), make_result(5), 5);
277 cache.store("cpu", &make_tags("b"), make_result(5), 5);
278 cache.clear();
279 assert!(cache.is_empty());
280 }
281}