somatize_runtime/cache/
gc.rs1use crate::cache::fs_store::FsActionStore;
21use chrono::Utc;
22use somatize_core::action::{BlobStore, ContentHash};
23use somatize_core::error::Result;
24use std::collections::{HashMap, HashSet};
25
26#[derive(Debug, Clone)]
29pub struct GcPolicy {
30 pub max_bytes: u64,
32 pub min_age: std::time::Duration,
35}
36
37impl Default for GcPolicy {
38 fn default() -> Self {
39 Self {
40 max_bytes: 20 * 1024 * 1024 * 1024, min_age: std::time::Duration::from_secs(3600),
42 }
43 }
44}
45
46#[derive(Debug, Clone, Default)]
48pub struct GcReport {
49 pub bytes_before: u64,
51 pub bytes_after: u64,
53 pub blobs_evicted: usize,
55 pub blobs_kept: usize,
57 pub pinned_blobs: usize,
59}
60
61pub fn collect(store: &FsActionStore, policy: &GcPolicy) -> Result<GcReport> {
65 let bytes_before = store.cas_bytes()?;
66 let mut report = GcReport {
67 bytes_before,
68 bytes_after: bytes_before,
69 ..Default::default()
70 };
71 if bytes_before <= policy.max_bytes {
72 return Ok(report);
73 }
74
75 let pinned_keys: HashSet<_> = store.pinned()?.into_iter().collect();
77 let mut roots: HashSet<ContentHash> = HashSet::new();
78
79 let now = Utc::now();
81 let mut scores: HashMap<ContentHash, (f64, u64, chrono::DateTime<Utc>)> = HashMap::new();
82 for record in store.actions()? {
83 let pinned = pinned_keys.contains(&record.key);
84 let age_days = (now - record.last_accessed).num_seconds().max(0) as f64 / 86_400.0;
85 let recency = 1.0 / (1.0 + age_days);
86 let size = record.output_bytes.max(1) as f64;
87 let score = (record.compute_ms as f64 + 1.0) * recency / size;
88 for hash in record.outputs.values() {
89 if pinned {
90 roots.insert(*hash);
91 }
92 let entry =
93 scores
94 .entry(*hash)
95 .or_insert((f64::MIN, record.output_bytes, record.created_at));
96 if score > entry.0 {
97 entry.0 = score;
98 }
99 if record.created_at > entry.2 {
100 entry.2 = record.created_at;
101 }
102 }
103 }
104 report.pinned_blobs = roots.len();
105
106 let min_age = chrono::Duration::from_std(policy.min_age).unwrap_or(chrono::Duration::zero());
107 let mut candidates: Vec<(f64, u64, ContentHash)> = scores
108 .iter()
109 .filter(|(hash, (_, _, created))| !roots.contains(hash) && now - *created >= min_age)
110 .map(|(hash, (score, size, _))| (*score, *size, *hash))
111 .collect();
112 candidates.sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap_or(std::cmp::Ordering::Equal));
114
115 let mut bytes = bytes_before;
116 for (_, size, hash) in candidates {
117 if bytes <= policy.max_bytes {
118 break;
119 }
120 if store.contains(&hash)? {
121 store.evict_blob(&hash)?;
122 bytes = bytes.saturating_sub(size);
123 report.blobs_evicted += 1;
124 }
125 }
126 report.blobs_kept = scores.len() - report.blobs_evicted;
127 report.bytes_after = store.cas_bytes()?;
128 Ok(report)
129}
130
131#[cfg(test)]
132mod tests {
133 use super::*;
134 use somatize_core::cache::{CacheKey, CacheStore, Origin};
135 use somatize_core::value::Value;
136 use std::path::PathBuf;
137 use std::sync::atomic::{AtomicU64, Ordering};
138 use std::time::Duration;
139
140 static COUNTER: AtomicU64 = AtomicU64::new(0);
141
142 fn temp_root() -> PathBuf {
143 let id = COUNTER.fetch_add(1, Ordering::Relaxed);
144 let dir = std::env::temp_dir().join(format!("soma_gc_{}_{id}", std::process::id()));
145 let _ = std::fs::remove_dir_all(&dir);
146 dir
147 }
148
149 fn origin() -> Origin {
150 Origin::Computed {
151 node_id: "n".into(),
152 run_id: "r".into(),
153 }
154 }
155
156 fn policy(max_bytes: u64) -> GcPolicy {
157 GcPolicy {
158 max_bytes,
159 min_age: Duration::ZERO,
160 }
161 }
162
163 #[test]
164 fn evicts_cheap_large_before_expensive_small() {
165 let root = temp_root();
166 let store = FsActionStore::new(&root).unwrap();
167
168 let expensive_key = CacheKey::hash_data(b"expensive");
170 store
171 .put_computed(
172 &expensive_key,
173 &Value::tensor(vec![1.0; 8], vec![8]),
174 &origin(),
175 Duration::from_secs(2 * 86_400),
176 true,
177 )
178 .unwrap();
179
180 let cheap_key = CacheKey::hash_data(b"cheap");
182 store
183 .put_computed(
184 &cheap_key,
185 &Value::tensor(vec![2.0; 10_000], vec![10_000]),
186 &origin(),
187 Duration::from_secs(2),
188 true,
189 )
190 .unwrap();
191
192 let report = collect(&store, &policy(10_000)).unwrap();
194 assert_eq!(report.blobs_evicted, 1);
195
196 assert!(
197 store.get(&expensive_key).unwrap().is_some(),
198 "the expensive-per-byte state must survive"
199 );
200 assert!(
201 store.get(&cheap_key).unwrap().is_none(),
202 "the cheap-per-byte bulk must go first"
203 );
204 use somatize_core::action::ActionCache;
206 assert!(store.get_action(&cheap_key).unwrap().is_some());
207
208 let _ = std::fs::remove_dir_all(&root);
209 }
210
211 #[test]
212 fn pinned_blobs_are_roots() {
213 let root = temp_root();
214 let store = FsActionStore::new(&root).unwrap();
215 let key = CacheKey::hash_data(b"best");
216 store
217 .put_computed(
218 &key,
219 &Value::tensor(vec![3.0; 10_000], vec![10_000]),
220 &origin(),
221 Duration::from_millis(1),
222 true,
223 )
224 .unwrap();
225 store.pin("best-model", &key).unwrap();
226
227 let report = collect(&store, &policy(1)).unwrap();
228 assert_eq!(report.blobs_evicted, 0);
229 assert!(store.get(&key).unwrap().is_some());
230
231 let _ = std::fs::remove_dir_all(&root);
232 }
233
234 #[test]
235 fn under_budget_is_a_noop() {
236 let root = temp_root();
237 let store = FsActionStore::new(&root).unwrap();
238 let key = CacheKey::hash_data(b"small");
239 store.put(&key, &Value::tensor(vec![1.0], vec![1])).unwrap();
240
241 let report = collect(&store, &policy(u64::MAX)).unwrap();
242 assert_eq!(report.blobs_evicted, 0);
243 assert_eq!(report.bytes_before, report.bytes_after);
244
245 let _ = std::fs::remove_dir_all(&root);
246 }
247
248 #[test]
249 fn min_age_protects_fresh_blobs() {
250 let root = temp_root();
251 let store = FsActionStore::new(&root).unwrap();
252 let key = CacheKey::hash_data(b"fresh");
253 store
254 .put_computed(
255 &key,
256 &Value::tensor(vec![1.0; 10_000], vec![10_000]),
257 &origin(),
258 Duration::from_millis(1),
259 true,
260 )
261 .unwrap();
262
263 let fresh_policy = GcPolicy {
264 max_bytes: 1,
265 min_age: Duration::from_secs(3600),
266 };
267 let report = collect(&store, &fresh_policy).unwrap();
268 assert_eq!(
269 report.blobs_evicted, 0,
270 "freshly-written blobs are protected"
271 );
272
273 let _ = std::fs::remove_dir_all(&root);
274 }
275}