1use chrono::Utc;
24use somatize_core::action::{ActionCache, ActionResult, BlobStore, ContentHash};
25use somatize_core::cache::{CacheKey, CacheStore, EntryMeta, Origin};
26use somatize_core::codec::{decode_value, encode_and_hash};
27use somatize_core::error::{Result, SomaError};
28use somatize_core::value::Value;
29use std::collections::BTreeMap;
30use std::fs;
31use std::path::{Path, PathBuf};
32
33pub const FORMAT_VERSION: u32 = 2;
37
38pub struct FsActionStore {
41 root: PathBuf,
42}
43
44impl FsActionStore {
45 pub fn new(root: impl Into<PathBuf>) -> Result<Self> {
51 let root = root.into();
52 fs::create_dir_all(&root)?;
53
54 let format_path = root.join("format.json");
55 if format_path.exists() {
56 let raw = fs::read_to_string(&format_path)?;
57 let format: serde_json::Value = serde_json::from_str(&raw)
58 .map_err(|e| SomaError::Cache(format!("unreadable cache format.json: {e}")))?;
59 let version = format.get("version").and_then(|v| v.as_u64()).unwrap_or(0);
60 if version != FORMAT_VERSION as u64 {
61 return Err(SomaError::Cache(format!(
62 "cache dir {} has format version {version}, expected {FORMAT_VERSION}; \
63 run `soma cache purge-v1` or point SOMA_CACHE_DIR elsewhere",
64 root.display()
65 )));
66 }
67 } else {
68 write_atomic(
69 &format_path,
70 serde_json::json!({ "version": FORMAT_VERSION })
71 .to_string()
72 .as_bytes(),
73 )?;
74 }
75 fs::create_dir_all(root.join("actions"))?;
76 fs::create_dir_all(root.join("cas"))?;
77 fs::create_dir_all(root.join("pins"))?;
78 Ok(Self { root })
79 }
80
81 pub fn root(&self) -> &Path {
83 &self.root
84 }
85
86 fn action_path(&self, key: &CacheKey) -> PathBuf {
87 let hex = key.to_hex();
88 self.root
89 .join("actions")
90 .join(&hex[..2])
91 .join(format!("{hex}.json"))
92 }
93
94 fn blob_path(&self, hash: &ContentHash) -> PathBuf {
95 let hex = hash.to_hex();
96 self.root
97 .join("cas")
98 .join(hash.algo.prefix())
99 .join(&hex[..2])
100 .join(format!("{hex}.bin"))
101 }
102
103 pub fn pin(&self, name: &str, key: &CacheKey) -> Result<()> {
105 if name.contains(['/', '\\']) || name.starts_with('.') {
106 return Err(SomaError::Cache(format!("invalid pin name: {name:?}")));
107 }
108 write_atomic(&self.root.join("pins").join(name), key.to_hex().as_bytes())
109 }
110
111 pub fn pinned(&self) -> Result<Vec<CacheKey>> {
113 let mut keys = Vec::new();
114 let pins = self.root.join("pins");
115 if !pins.exists() {
116 return Ok(keys);
117 }
118 for entry in fs::read_dir(&pins)? {
119 let entry = entry?;
120 let hex = fs::read_to_string(entry.path())?;
121 if let Some(key) = key_from_hex(hex.trim()) {
122 keys.push(key);
123 }
124 }
125 Ok(keys)
126 }
127
128 pub fn actions(&self) -> Result<Vec<ActionResult>> {
130 let mut out = Vec::new();
131 let actions = self.root.join("actions");
132 if !actions.exists() {
133 return Ok(out);
134 }
135 for shard in fs::read_dir(&actions)? {
136 let shard = shard?.path();
137 if !shard.is_dir() {
138 continue;
139 }
140 for entry in fs::read_dir(&shard)? {
141 let path = entry?.path();
142 if path.extension().is_some_and(|e| e == "json")
143 && let Ok(raw) = fs::read_to_string(&path)
144 && let Ok(record) = serde_json::from_str::<ActionResult>(&raw)
145 {
146 out.push(record);
147 }
148 }
149 }
150 Ok(out)
151 }
152
153 pub fn evict_blob(&self, hash: &ContentHash) -> Result<()> {
156 let path = self.blob_path(hash);
157 if path.exists() {
158 fs::remove_file(&path)?;
159 }
160 Ok(())
161 }
162
163 pub fn cas_bytes(&self) -> Result<u64> {
165 fn dir_size(dir: &Path) -> u64 {
166 let Ok(entries) = fs::read_dir(dir) else {
167 return 0;
168 };
169 entries
170 .filter_map(|e| e.ok())
171 .map(|e| {
172 let p = e.path();
173 if p.is_dir() {
174 dir_size(&p)
175 } else {
176 e.metadata().map(|m| m.len()).unwrap_or(0)
177 }
178 })
179 .sum()
180 }
181 Ok(dir_size(&self.root.join("cas")))
182 }
183
184 fn store_computed(
185 &self,
186 key: &CacheKey,
187 value: &Value,
188 origin: &Origin,
189 compute: std::time::Duration,
190 deterministic: bool,
191 ) -> Result<()> {
192 let (bytes, hash) = encode_and_hash(value)?;
193 let output_bytes = bytes.len() as u64;
194 self.put_bytes_prehashed(&bytes, &hash)?;
196 let mut outputs = BTreeMap::new();
197 outputs.insert("output".to_string(), hash);
198 self.put_action(&ActionResult {
199 key: key.clone(),
200 outputs,
201 output_bytes,
202 compute_ms: compute.as_millis() as u64,
203 deterministic,
204 origin: origin.clone(),
205 created_at: Utc::now(),
206 last_accessed: Utc::now(),
207 })
208 }
209
210 fn put_bytes_prehashed(&self, bytes: &[u8], hash: &ContentHash) -> Result<()> {
211 let path = self.blob_path(hash);
212 if path.exists() {
213 return Ok(()); }
215 write_atomic(&path, bytes)
216 }
217}
218
219fn key_from_hex(hex: &str) -> Option<CacheKey> {
220 if hex.len() != 64 {
221 return None;
222 }
223 let mut digest = [0u8; 32];
224 for (i, byte) in digest.iter_mut().enumerate() {
225 *byte = u8::from_str_radix(&hex[i * 2..i * 2 + 2], 16).ok()?;
226 }
227 Some(CacheKey(digest))
228}
229
230fn write_atomic(path: &Path, data: &[u8]) -> Result<()> {
233 use std::io::Write;
234 use std::sync::atomic::{AtomicU64, Ordering};
235 static WRITE_SEQ: AtomicU64 = AtomicU64::new(0);
236
237 let parent = path
238 .parent()
239 .ok_or_else(|| SomaError::Cache("store path has no parent".into()))?;
240 fs::create_dir_all(parent)?;
241 let seq = WRITE_SEQ.fetch_add(1, Ordering::Relaxed);
242 let tmp = path.with_extension(format!("tmp-{}-{seq}", std::process::id()));
243 {
244 let mut f = fs::File::create(&tmp)?;
245 f.write_all(data)?;
246 f.sync_all()?;
247 }
248 if let Err(e) = fs::rename(&tmp, path) {
249 let _ = fs::remove_file(&tmp);
250 return Err(e.into());
251 }
252 Ok(())
253}
254
255impl BlobStore for FsActionStore {
256 fn put_bytes(&self, bytes: &[u8]) -> Result<ContentHash> {
257 let hash = ContentHash::blake3(bytes);
258 self.put_bytes_prehashed(bytes, &hash)?;
259 Ok(hash)
260 }
261
262 fn get_bytes(&self, hash: &ContentHash) -> Result<Option<Vec<u8>>> {
263 let path = self.blob_path(hash);
264 if !path.exists() {
265 return Ok(None);
266 }
267 let bytes = fs::read(&path)?;
268 if !hash.verify(&bytes) {
269 tracing::warn!(hash = %hash.to_hex(), "corrupt CAS blob, ignoring");
271 return Ok(None);
272 }
273 Ok(Some(bytes))
274 }
275
276 fn contains(&self, hash: &ContentHash) -> Result<bool> {
277 Ok(self.blob_path(hash).exists())
278 }
279}
280
281impl ActionCache for FsActionStore {
282 fn get_action(&self, key: &CacheKey) -> Result<Option<ActionResult>> {
283 let path = self.action_path(key);
284 if !path.exists() {
285 return Ok(None);
286 }
287 let raw = fs::read_to_string(&path)?;
288 match serde_json::from_str(&raw) {
289 Ok(record) => Ok(Some(record)),
290 Err(e) => {
291 tracing::warn!(key = %key, error = %e, "corrupt action record, ignoring");
292 Ok(None)
293 }
294 }
295 }
296
297 fn put_action(&self, result: &ActionResult) -> Result<()> {
298 let raw = serde_json::to_string(result)
299 .map_err(|e| SomaError::Cache(format!("action record encode: {e}")))?;
300 write_atomic(&self.action_path(&result.key), raw.as_bytes())
301 }
302}
303
304impl CacheStore for FsActionStore {
305 fn get(&self, key: &CacheKey) -> Result<Option<Value>> {
306 let Some(record) = self.get_action(key)? else {
307 return Ok(None);
308 };
309 let Some(hash) = record.outputs.get("output") else {
310 return Ok(None);
311 };
312 let Some(bytes) = self.get_bytes(hash)? else {
313 return Ok(None); };
315 decode_value(&bytes).map(Some)
316 }
317
318 fn put(&self, key: &CacheKey, value: &Value) -> Result<()> {
319 self.store_computed(
320 key,
321 value,
322 &Origin::Ingested {
323 source: "unknown".into(),
324 },
325 std::time::Duration::ZERO,
326 true,
327 )
328 }
329
330 fn put_with_origin(&self, key: &CacheKey, value: &Value, origin: &Origin) -> Result<()> {
331 self.store_computed(key, value, origin, std::time::Duration::ZERO, true)
332 }
333
334 fn put_computed(
335 &self,
336 key: &CacheKey,
337 value: &Value,
338 origin: &Origin,
339 compute: std::time::Duration,
340 deterministic: bool,
341 ) -> Result<()> {
342 self.store_computed(key, value, origin, compute, deterministic)
343 }
344
345 fn exists(&self, key: &CacheKey) -> Result<bool> {
346 let Some(record) = self.get_action(key)? else {
347 return Ok(false);
348 };
349 match record.outputs.get("output") {
350 Some(hash) => self.contains(hash),
351 None => Ok(false),
352 }
353 }
354
355 fn remove(&self, key: &CacheKey) -> Result<()> {
356 let path = self.action_path(key);
359 if path.exists() {
360 fs::remove_file(&path)?;
361 }
362 Ok(())
363 }
364
365 fn metadata(&self, key: &CacheKey) -> Result<Option<EntryMeta>> {
366 Ok(self.get_action(key)?.map(|record| EntryMeta {
367 key: record.key,
368 size_bytes: record.output_bytes,
369 created_at: record.created_at,
370 last_accessed: record.last_accessed,
371 ttl: None,
372 origin: record.origin,
373 }))
374 }
375}
376
377#[cfg(test)]
378mod tests {
379 use super::*;
380 use std::sync::Arc;
381 use std::sync::atomic::{AtomicU64, Ordering};
382
383 static COUNTER: AtomicU64 = AtomicU64::new(0);
384
385 fn temp_root() -> PathBuf {
386 let id = COUNTER.fetch_add(1, Ordering::Relaxed);
387 let dir = std::env::temp_dir().join(format!("soma_fs_store_{}_{id}", std::process::id()));
388 let _ = fs::remove_dir_all(&dir);
389 dir
390 }
391
392 #[test]
393 fn roundtrip_and_dedup() {
394 let root = temp_root();
395 let store = FsActionStore::new(&root).unwrap();
396 let value = Value::tensor(vec![1.0; 1000], vec![1000]);
397
398 let k1 = CacheKey::hash_data(b"action-1");
399 let k2 = CacheKey::hash_data(b"action-2");
400 store.put(&k1, &value).unwrap();
401 store.put(&k2, &value).unwrap(); assert_eq!(store.get(&k1).unwrap().unwrap(), value);
404 assert_eq!(store.get(&k2).unwrap().unwrap(), value);
405
406 let r1 = store.get_action(&k1).unwrap().unwrap();
408 let r2 = store.get_action(&k2).unwrap().unwrap();
409 assert_eq!(r1.outputs["output"], r2.outputs["output"]);
410 let blob_count = walk_count(&root.join("cas"), "bin");
411 assert_eq!(blob_count, 1, "identical outputs must share one blob");
412
413 let _ = fs::remove_dir_all(&root);
414 }
415
416 #[test]
417 fn eviction_keeps_record_and_refills() {
418 let root = temp_root();
419 let store = FsActionStore::new(&root).unwrap();
420 let key = CacheKey::hash_data(b"expensive");
421 let value = Value::tensor(vec![2.0; 64], vec![64]);
422 store
423 .put_computed(
424 &key,
425 &value,
426 &Origin::Computed {
427 node_id: "n".into(),
428 run_id: "r".into(),
429 },
430 std::time::Duration::from_secs(3600),
431 true,
432 )
433 .unwrap();
434
435 let record = store.get_action(&key).unwrap().unwrap();
436 assert_eq!(record.compute_ms, 3_600_000);
437 let hash = record.outputs["output"];
438
439 store.evict_blob(&hash).unwrap();
441 assert!(store.get_action(&key).unwrap().is_some());
442 assert!(store.get(&key).unwrap().is_none());
443 assert!(!store.exists(&key).unwrap());
444
445 store.put(&key, &value).unwrap();
447 assert_eq!(store.get(&key).unwrap().unwrap(), value);
448
449 let _ = fs::remove_dir_all(&root);
450 }
451
452 #[test]
453 fn corrupt_blob_is_a_miss_not_an_error() {
454 let root = temp_root();
455 let store = FsActionStore::new(&root).unwrap();
456 let key = CacheKey::hash_data(b"c");
457 store.put(&key, &Value::tensor(vec![1.0], vec![1])).unwrap();
458
459 let hash = store.get_action(&key).unwrap().unwrap().outputs["output"];
460 fs::write(store.blob_path(&hash), b"garbage").unwrap();
461
462 assert!(store.get(&key).unwrap().is_none());
463 let _ = fs::remove_dir_all(&root);
464 }
465
466 #[test]
467 fn format_version_guard() {
468 let root = temp_root();
469 fs::create_dir_all(&root).unwrap();
470 fs::write(root.join("format.json"), r#"{"version": 99}"#).unwrap();
471 assert!(FsActionStore::new(&root).is_err());
472 let _ = fs::remove_dir_all(&root);
473 }
474
475 #[test]
476 fn pins_roundtrip() {
477 let root = temp_root();
478 let store = FsActionStore::new(&root).unwrap();
479 let key = CacheKey::hash_data(b"best-model");
480 store.pin("best-model", &key).unwrap();
481 assert_eq!(store.pinned().unwrap(), vec![key.clone()]);
482 assert!(store.pin("../escape", &key).is_err());
483 let _ = fs::remove_dir_all(&root);
484 }
485
486 #[test]
487 fn concurrent_writers_same_key() {
488 let root = temp_root();
489 let store = Arc::new(FsActionStore::new(&root).unwrap());
490 let key = CacheKey::hash_data(b"contended");
491 let value = Value::tensor(vec![7.0; 512], vec![512]);
492
493 std::thread::scope(|s| {
494 for _ in 0..8 {
495 let store = store.clone();
496 let key = key.clone();
497 let value = value.clone();
498 s.spawn(move || store.put(&key, &value).unwrap());
499 }
500 });
501 assert_eq!(store.get(&key).unwrap().unwrap(), value);
502 let _ = fs::remove_dir_all(&root);
503 }
504
505 #[test]
506 fn survives_restart() {
507 let root = temp_root();
508 let key = CacheKey::hash_data(b"persist");
509 let value = Value::json(serde_json::json!({"w": [1, 2, 3]}));
510 {
511 let store = FsActionStore::new(&root).unwrap();
512 store.put(&key, &value).unwrap();
513 }
514 {
515 let store = FsActionStore::new(&root).unwrap();
516 assert_eq!(store.get(&key).unwrap().unwrap(), value);
517 }
518 let _ = fs::remove_dir_all(&root);
519 }
520
521 fn walk_count(dir: &Path, ext: &str) -> usize {
522 let Ok(entries) = fs::read_dir(dir) else {
523 return 0;
524 };
525 entries
526 .filter_map(|e| e.ok())
527 .map(|e| {
528 let p = e.path();
529 if p.is_dir() {
530 walk_count(&p, ext)
531 } else if p.extension().is_some_and(|x| x == ext) {
532 1
533 } else {
534 0
535 }
536 })
537 .sum()
538 }
539}