Skip to main content

somatize_runtime/cache/
gc.rs

1//! Cost-aware garbage collection for [`FsActionStore`].
2//!
3//! Evicts **blobs only** — action records are always retained, so an
4//! evicted entry is regenerable: the next run recomputes it and
5//! re-fills the same content address (Nectar: eviction degrades
6//! performance, never correctness).
7//!
8//! Eviction order is by *value density*, ascending:
9//!
10//! ```text
11//! score(blob) = max over records naming it of
12//!               (compute_ms + 1) × recency_weight ÷ size_bytes
13//! ```
14//!
15//! so a 100-byte state that took two days to fit outlives a 10 GB
16//! intermediate that took two minutes, and never the other way around
17//! (plain LRU gets this exactly wrong for research pipelines). Blobs
18//! referenced by pinned actions are GC roots and never evicted.
19
20use 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/// When to evict and how much to keep. Defaults: 20 GiB ceiling,
27/// one-hour minimum age.
28#[derive(Debug, Clone)]
29pub struct GcPolicy {
30    /// Target ceiling for total CAS bytes.
31    pub max_bytes: u64,
32    /// Blobs younger than this are never evicted (avoids racing an
33    /// in-flight run that just wrote them).
34    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, // 20 GiB
41            min_age: std::time::Duration::from_secs(3600),
42        }
43    }
44}
45
46/// What one [`collect`] pass did, for `soma cache gc` output.
47#[derive(Debug, Clone, Default)]
48pub struct GcReport {
49    /// Total CAS bytes before the pass.
50    pub bytes_before: u64,
51    /// Total CAS bytes after the pass.
52    pub bytes_after: u64,
53    /// Blobs deleted by this pass.
54    pub blobs_evicted: usize,
55    /// Blobs that survived (including roots).
56    pub blobs_kept: usize,
57    /// Blobs protected as outputs of pinned actions.
58    pub pinned_blobs: usize,
59}
60
61/// Run one collection pass. Safe to run while writers are active:
62/// blob puts are idempotent, and an evict-then-immediate-reput just
63/// re-creates the file.
64pub 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    // Roots: outputs of pinned actions.
76    let pinned_keys: HashSet<_> = store.pinned()?.into_iter().collect();
77    let mut roots: HashSet<ContentHash> = HashSet::new();
78
79    // Best score per blob across all records naming it.
80    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    // Lowest value density first.
113    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        // Expensive tiny state (2 days of compute, ~100 bytes).
169        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        // Cheap huge intermediate (2 seconds, ~80 KB).
181        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        // Budget forces evicting roughly one blob.
193        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        // Records survive eviction — regenerable, not lost.
205        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}