Skip to main content

somatize_runtime/cache/
tiered.rs

1//! [`TieredCache`] — multi-level [`CacheStore`] with promotion on hit.
2
3use somatize_core::cache::{CacheKey, CacheStore, CacheTier, EntryMeta};
4use somatize_core::error::Result;
5use somatize_core::value::Value;
6
7/// Multi-level cache with automatic promotion.
8///
9/// Checks tiers in order: Memory → Local → (Remote, future).
10/// On a hit in a lower tier, promotes the entry to faster tiers.
11pub struct TieredCache {
12    tiers: Vec<(CacheTier, Box<dyn CacheStore>)>,
13}
14
15impl TieredCache {
16    /// Create a tiered cache from a list of (tier, store) pairs.
17    /// Tiers should be ordered fastest-first.
18    pub fn new(tiers: Vec<(CacheTier, Box<dyn CacheStore>)>) -> Self {
19        Self { tiers }
20    }
21
22    /// Create a two-level cache: Memory + Local.
23    pub fn memory_and_local(memory: Box<dyn CacheStore>, local: Box<dyn CacheStore>) -> Self {
24        Self {
25            tiers: vec![(CacheTier::Memory, memory), (CacheTier::Local, local)],
26        }
27    }
28}
29
30impl CacheStore for TieredCache {
31    /// The fastest tier — what a caller reaches first.
32    fn tier(&self) -> CacheTier {
33        self.tiers
34            .first()
35            .map(|(t, _)| *t)
36            .unwrap_or(CacheTier::Memory)
37    }
38
39    fn get(&self, key: &CacheKey) -> Result<Option<Value>> {
40        Ok(self.get_located(key)?.map(|(value, _)| value))
41    }
42
43    /// Which tier answered is the number that says whether the slower
44    /// tiers are earning their keep, so it is reported rather than
45    /// flattened into "the cache was used".
46    fn get_located(&self, key: &CacheKey) -> Result<Option<(Value, CacheTier)>> {
47        for (i, (tier, store)) in self.tiers.iter().enumerate() {
48            if let Some(value) = store.get(key)? {
49                // Promote to faster tiers
50                for (_, faster_store) in &self.tiers[..i] {
51                    let _ = faster_store.put(key, &value);
52                }
53                return Ok(Some((value, *tier)));
54            }
55        }
56        Ok(None)
57    }
58
59    fn put(&self, key: &CacheKey, value: &Value) -> Result<()> {
60        // Write to all tiers
61        for (_, store) in &self.tiers {
62            store.put(key, value)?;
63        }
64        Ok(())
65    }
66
67    fn put_with_origin(
68        &self,
69        key: &CacheKey,
70        value: &Value,
71        origin: &somatize_core::cache::Origin,
72    ) -> Result<()> {
73        for (_, store) in &self.tiers {
74            store.put_with_origin(key, value, origin)?;
75        }
76        Ok(())
77    }
78
79    fn exists(&self, key: &CacheKey) -> Result<bool> {
80        for (_, store) in &self.tiers {
81            if store.exists(key)? {
82                return Ok(true);
83            }
84        }
85        Ok(false)
86    }
87
88    fn remove(&self, key: &CacheKey) -> Result<()> {
89        for (_, store) in &self.tiers {
90            store.remove(key)?;
91        }
92        Ok(())
93    }
94
95    fn metadata(&self, key: &CacheKey) -> Result<Option<EntryMeta>> {
96        for (_, store) in &self.tiers {
97            if let Some(meta) = store.metadata(key)? {
98                return Ok(Some(meta));
99            }
100        }
101        Ok(None)
102    }
103}
104
105#[cfg(test)]
106mod tests {
107    use super::*;
108    use crate::cache::local::LocalCache;
109    use crate::cache::memory::MemoryCache;
110    use std::env;
111    use std::fs;
112    use std::path::PathBuf;
113
114    use std::sync::atomic::{AtomicU64, Ordering};
115    static COUNTER: AtomicU64 = AtomicU64::new(0);
116
117    fn temp_dir() -> PathBuf {
118        let id = COUNTER.fetch_add(1, Ordering::Relaxed);
119        let dir = env::temp_dir().join(format!("soma_tiered_test_{}_{id}", std::process::id()));
120        let _ = fs::remove_dir_all(&dir);
121        dir
122    }
123
124    fn make_tiered() -> (TieredCache, PathBuf) {
125        let dir = temp_dir();
126        let memory = Box::new(MemoryCache::default());
127        let local = Box::new(LocalCache::new(&dir).unwrap());
128        (TieredCache::memory_and_local(memory, local), dir)
129    }
130
131    /// The cache-hit event used to report `Memory` unconditionally, so
132    /// every per-tier statistic said the same thing no matter which tier
133    /// did the work.
134    #[test]
135    fn get_located_names_the_tier_that_answered() {
136        let (cache, dir) = make_tiered();
137        let key = CacheKey::hash_data(b"only-on-disk");
138        let value = Value::tensor(vec![7.0], vec![1]);
139
140        // Write to the local tier alone, bypassing the memory tier.
141        let local = LocalCache::new(&dir).unwrap();
142        local.put(&key, &value).unwrap();
143
144        let (got, tier) = cache.get_located(&key).unwrap().unwrap();
145        assert_eq!(got, value);
146        assert_eq!(tier, CacheTier::Local);
147
148        // Now it has been promoted, so the memory tier answers.
149        let (_, tier) = cache.get_located(&key).unwrap().unwrap();
150        assert_eq!(tier, CacheTier::Memory);
151
152        let _ = fs::remove_dir_all(&dir);
153    }
154
155    #[test]
156    fn put_writes_to_all_tiers() {
157        let (cache, dir) = make_tiered();
158        let key = CacheKey::hash_data(b"test");
159        let value = Value::tensor(vec![1.0, 2.0], vec![2]);
160
161        cache.put(&key, &value).unwrap();
162
163        // Both tiers should have it
164        assert!(cache.tiers[0].1.exists(&key).unwrap()); // memory
165        assert!(cache.tiers[1].1.exists(&key).unwrap()); // local
166
167        let _ = fs::remove_dir_all(&dir);
168    }
169
170    #[test]
171    fn get_from_memory_first() {
172        let (cache, dir) = make_tiered();
173        let key = CacheKey::hash_data(b"test");
174        let value = Value::tensor(vec![1.0], vec![1]);
175
176        cache.put(&key, &value).unwrap();
177        let result = cache.get(&key).unwrap().unwrap();
178        assert_eq!(result, value);
179
180        let _ = fs::remove_dir_all(&dir);
181    }
182
183    #[test]
184    fn promotes_from_local_to_memory() {
185        let dir = temp_dir();
186        let memory = Box::new(MemoryCache::default());
187        let local = Box::new(LocalCache::new(&dir).unwrap());
188
189        // Write only to local
190        let key = CacheKey::hash_data(b"local_only");
191        let value = Value::tensor(vec![42.0], vec![1]);
192        local.put(&key, &value).unwrap();
193
194        let tiered = TieredCache::memory_and_local(memory, local);
195
196        // Memory doesn't have it
197        assert!(!tiered.tiers[0].1.exists(&key).unwrap());
198
199        // Get should find it in local and promote to memory
200        let result = tiered.get(&key).unwrap().unwrap();
201        assert_eq!(result, value);
202
203        // Now memory should have it
204        assert!(tiered.tiers[0].1.exists(&key).unwrap());
205
206        let _ = fs::remove_dir_all(&dir);
207    }
208
209    #[test]
210    fn miss_returns_none() {
211        let (cache, dir) = make_tiered();
212        assert!(cache.get(&CacheKey::hash_data(b"nope")).unwrap().is_none());
213        let _ = fs::remove_dir_all(&dir);
214    }
215
216    #[test]
217    fn remove_from_all_tiers() {
218        let (cache, dir) = make_tiered();
219        let key = CacheKey::hash_data(b"test");
220        cache.put(&key, &Value::Empty).unwrap();
221        cache.remove(&key).unwrap();
222
223        assert!(!cache.tiers[0].1.exists(&key).unwrap());
224        assert!(!cache.tiers[1].1.exists(&key).unwrap());
225
226        let _ = fs::remove_dir_all(&dir);
227    }
228}