Skip to main content

chronix_analytics/
materialized.rs

1//! Materialized forecast views — pre-computed, cached forecast results.
2//!
3//! Stores the latest forecast for each series so queries can be served
4//! from cache without re-fitting.
5
6use std::collections::BTreeMap;
7use std::time::Instant;
8
9use crate::forecast::ForecastResult;
10use dashmap::DashMap;
11use metrics::counter;
12use tracing::debug;
13
14/// A materialized (cached) forecast for a series.
15#[derive(Debug, Clone)]
16pub struct MaterializedForecast {
17    /// The forecast result.
18    pub result: ForecastResult,
19    /// Measurement name.
20    pub measurement: String,
21    /// Series tags.
22    pub tags: BTreeMap<String, String>,
23    /// When this forecast was computed.
24    pub computed_at: Instant,
25    /// Forecast horizon (number of predicted points).
26    pub horizon: usize,
27}
28
29impl MaterializedForecast {
30    /// Check if the forecast is still fresh.
31    #[must_use]
32    pub fn is_fresh(&self, max_age: std::time::Duration) -> bool {
33        self.computed_at.elapsed() < max_age
34    }
35}
36
37/// Key: `(measurement, canonical_tags_key)`.
38type CacheKey = (String, String);
39
40/// Default maximum number of cached forecast models.
41pub const DEFAULT_MAX_CACHE_ENTRIES: usize = 10_000;
42
43/// Cache of materialized forecasts.
44pub struct ForecastCache {
45    cache: DashMap<CacheKey, MaterializedForecast>,
46    default_max_age: std::time::Duration,
47    /// Maximum number of entries. When exceeded, stale entries are
48    /// evicted on the next store. If the cache is still full after
49    /// eviction, the oldest entry is removed.
50    max_entries: usize,
51}
52
53impl ForecastCache {
54    /// Create a new forecast cache.
55    #[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    /// Create a cache with a custom capacity limit.
65    #[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    /// Store a materialized forecast.
72    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 the cache exceeds capacity, evict stale entries first,
92        // then evict oldest if still over capacity.
93        if self.cache.len() > self.max_entries {
94            self.evict_stale();
95        }
96        if self.cache.len() > self.max_entries {
97            // Remove the oldest entry
98            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    /// Look up a cached forecast, returning it if fresh.
113    #[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    /// Get a cached forecast regardless of freshness.
131    #[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    /// Number of cached forecasts.
142    #[must_use]
143    pub fn len(&self) -> usize {
144        self.cache.len()
145    }
146
147    /// Whether the cache is empty.
148    #[must_use]
149    pub fn is_empty(&self) -> bool {
150        self.cache.is_empty()
151    }
152
153    /// Remove all stale entries.
154    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    /// Invalidate a specific series.
165    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    /// Clear all cached forecasts.
171    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        // Wait for staleness
224        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}