Skip to main content

dig_store_cache/
cache.rs

1//! The [`Cache`] handle + its internal state machine.
2//!
3//! [`Cache`] is a cheap-to-clone, thread-safe handle (`Arc<Mutex<Inner>>`): every method takes `&self`
4//! and briefly locks the shared state. The state machine enforces the crate's invariants — capacity,
5//! pin exemption, atomic admission, disk-authoritative rebuild — with the filesystem work delegated to
6//! [`crate::layout`] and the eviction decision to the pluggable [`crate::policy::EvictionPolicy`].
7
8use crate::config::{Admission, CacheConfig, CacheStats, PutOptions};
9use crate::error::CacheError;
10use crate::index::{self, Manifest, ManifestEntry};
11use crate::layout;
12use crate::policy::{EvictionContext, EvictionEntry};
13use dig_store::{get_capsule_identity, CapsuleIdentity};
14use std::collections::HashMap;
15use std::path::{Path, PathBuf};
16use std::sync::{Arc, Mutex};
17
18/// A thread-safe handle to an on-disk capsule cache. Cloning shares the same underlying cache.
19#[derive(Clone)]
20pub struct Cache {
21    inner: Arc<Mutex<Inner>>,
22}
23
24impl std::fmt::Debug for Cache {
25    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
26        let inner = self.lock();
27        f.debug_struct("Cache")
28            .field("root", &inner.root)
29            .field("count", &inner.entries.len())
30            .field("bytes_used", &inner.bytes_used)
31            .field("capacity", &inner.config.max_bytes)
32            .finish()
33    }
34}
35
36/// A cached capsule located on disk. Byte access is PATH-BASED — capsules can be ~1 GiB, so a consumer
37/// streams from [`CachedCapsule::path`] rather than reading the whole file into memory.
38#[derive(Debug, Clone)]
39pub struct CachedCapsule {
40    id: CapsuleIdentity,
41    path: PathBuf,
42}
43
44impl CachedCapsule {
45    /// The capsule's identity.
46    pub fn id(&self) -> CapsuleIdentity {
47        self.id
48    }
49
50    /// The path to the capsule's `.dig` file on disk. Stream from here; do not assume it fits in RAM.
51    pub fn path(&self) -> &Path {
52        &self.path
53    }
54}
55
56/// One capsule's in-memory bookkeeping. The identity is the map key; recency + pin live here.
57struct Entry {
58    key_hex: String,
59    size: u64,
60    seq: u64,
61    pinned: bool,
62}
63
64/// The locked cache state.
65struct Inner {
66    root: PathBuf,
67    config: CacheConfig,
68    entries: HashMap<CapsuleIdentity, Entry>,
69    bytes_used: u64,
70    next_seq: u64,
71}
72
73impl Cache {
74    /// Open (or rebuild) a cache rooted at `root` with `config`.
75    ///
76    /// Establishes `root/{capsules,tmp}`, reclaims any leftover `tmp/*.part` from an interrupted
77    /// admission, then rebuilds the in-memory index by SCANNING `capsules/` (disk is authoritative)
78    /// and overlaying `index.json` for recency + pin state. A capsule file with no manifest entry is
79    /// admitted with its identity recovered from the file and mtime-based recency; a manifest entry
80    /// with no file is dropped.
81    pub fn open(root: &Path, config: CacheConfig) -> Result<Cache, CacheError> {
82        layout::ensure_dirs(root)?;
83        layout::clean_tmp(root)?;
84        let scanned = layout::scan_capsules(root)?;
85        let manifest = index::load(root);
86
87        let inner = Inner::rebuild(root.to_path_buf(), config, scanned, manifest)?;
88        // Persist the reconciled view (dropped orphans, admitted disk-only files, fresh recency).
89        inner.save_manifest()?;
90        Ok(Cache {
91            inner: Arc::new(Mutex::new(inner)),
92        })
93    }
94
95    /// Admit the file at `src` under identity `id`. The file is stream-copied (never slurped), so this
96    /// is the path for real (large) capsules. `check_identity` is IGNORED here (the body is not read).
97    pub fn put_file(
98        &self,
99        id: CapsuleIdentity,
100        src: &Path,
101        opts: PutOptions,
102    ) -> Result<Admission, CacheError> {
103        let size = std::fs::metadata(src)
104            .map_err(|e| CacheError::io(src, e))?
105            .len();
106        let mut inner = self.lock();
107        inner.reject_if_too_large(id, size)?;
108        let (staged, _) = layout::stage_file(&inner.root, src)?;
109        inner.admit_staged(id, staged, size, opts.pinned)
110    }
111
112    /// Admit `bytes` under identity `id`. A convenience for small/test capsules — real capsules use
113    /// [`Cache::put_file`]. With `opts.check_identity`, the bytes' declared identity must equal `id`.
114    pub fn put_bytes(
115        &self,
116        id: CapsuleIdentity,
117        bytes: &[u8],
118        opts: PutOptions,
119    ) -> Result<Admission, CacheError> {
120        if opts.check_identity {
121            verify_claimed_identity(id, bytes)?;
122        }
123        let size = bytes.len() as u64;
124        let mut inner = self.lock();
125        inner.reject_if_too_large(id, size)?;
126        let staged = layout::stage_bytes(&inner.root, bytes)?;
127        inner.admit_staged(id, staged, size, opts.pinned)
128    }
129
130    /// Fetch a cached capsule, marking it most-recently-used. `None` if not cached.
131    pub fn get(&self, id: &CapsuleIdentity) -> Option<CachedCapsule> {
132        let mut inner = self.lock();
133        let seq = inner.next_seq;
134        let key_hex = {
135            let entry = inner.entries.get_mut(id)?;
136            entry.seq = seq; // recency bump (in memory only; not persisted until a structural change)
137            entry.key_hex.clone()
138        };
139        inner.next_seq += 1;
140        let path = layout::capsule_path(&inner.root, &key_hex);
141        Some(CachedCapsule { id: *id, path })
142    }
143
144    /// Read a cached capsule's bytes into memory (convenience for small/test capsules; real capsules
145    /// stream from [`CachedCapsule::path`]). Updates recency. `Ok(None)` if not cached.
146    pub fn get_bytes(&self, id: &CapsuleIdentity) -> Result<Option<Vec<u8>>, CacheError> {
147        let Some(cached) = self.get(id) else {
148            return Ok(None);
149        };
150        let bytes = std::fs::read(cached.path()).map_err(|e| CacheError::io(cached.path(), e))?;
151        Ok(Some(bytes))
152    }
153
154    /// Whether `id` is currently cached.
155    pub fn contains(&self, id: &CapsuleIdentity) -> bool {
156        self.lock().entries.contains_key(id)
157    }
158
159    /// Every capsule the cache currently holds — the node's advertisable holdings.
160    pub fn holdings(&self) -> Vec<CapsuleIdentity> {
161        self.lock().entries.keys().copied().collect()
162    }
163
164    /// Remove a capsule (deleting its file + freeing its bytes). Returns whether it was present.
165    pub fn remove(&self, id: &CapsuleIdentity) -> Result<bool, CacheError> {
166        let mut inner = self.lock();
167        if !inner.entries.contains_key(id) {
168            return Ok(false);
169        }
170        inner.drop_entry(id)?;
171        inner.save_manifest()?;
172        Ok(true)
173    }
174
175    /// Pin a capsule so eviction never reclaims it. Returns whether it was present (and is now pinned).
176    pub fn pin(&self, id: &CapsuleIdentity) -> Result<bool, CacheError> {
177        self.set_pinned(id, true)
178    }
179
180    /// Unpin a capsule, making it eligible for eviction again. Returns whether it was present.
181    pub fn unpin(&self, id: &CapsuleIdentity) -> Result<bool, CacheError> {
182        self.set_pinned(id, false)
183    }
184
185    /// A snapshot of occupancy.
186    pub fn stats(&self) -> CacheStats {
187        let inner = self.lock();
188        CacheStats {
189            bytes_used: inner.bytes_used,
190            count: inner.entries.len(),
191            capacity: inner.config.max_bytes,
192        }
193    }
194
195    /// Replace the configuration. Lowering `max_bytes` evicts (per the policy) until the cache fits the
196    /// new capacity and returns the evicted set (the flywheel-retract signal).
197    pub fn set_config(&self, config: CacheConfig) -> Result<Admission, CacheError> {
198        let mut inner = self.lock();
199        inner.config = config;
200        let evicted = inner.evict_to_fit();
201        inner.save_manifest()?;
202        Ok(Admission { evicted })
203    }
204
205    fn set_pinned(&self, id: &CapsuleIdentity, pinned: bool) -> Result<bool, CacheError> {
206        let mut inner = self.lock();
207        match inner.entries.get_mut(id) {
208            Some(entry) => {
209                entry.pinned = pinned;
210                inner.save_manifest()?;
211                Ok(true)
212            }
213            None => Ok(false),
214        }
215    }
216
217    fn lock(&self) -> std::sync::MutexGuard<'_, Inner> {
218        // A poisoned lock means a prior holder panicked mid-mutation; recover the guard rather than
219        // cascading the panic — the next structural write re-persists a consistent manifest.
220        self.inner
221            .lock()
222            .unwrap_or_else(|poisoned| poisoned.into_inner())
223    }
224}
225
226impl Inner {
227    /// Rebuild the in-memory index from a disk scan overlaid with the manifest (see [`Cache::open`]).
228    fn rebuild(
229        root: PathBuf,
230        config: CacheConfig,
231        scanned: Vec<layout::ScannedFile>,
232        manifest: Option<Manifest>,
233    ) -> Result<Inner, CacheError> {
234        let by_key: HashMap<String, ManifestEntry> = manifest
235            .map(|m| {
236                m.entries
237                    .into_iter()
238                    .map(|e| (e.retrieval_key.clone(), e))
239                    .collect()
240            })
241            .unwrap_or_default();
242
243        // Each surviving file becomes a pending entry with a recency sort key: the manifest's seq when
244        // known, else the file mtime (disk-authoritative recency after a manifest loss).
245        struct Pending {
246            id: CapsuleIdentity,
247            key_hex: String,
248            size: u64,
249            pinned: bool,
250            sort_key: u128,
251        }
252
253        let mut pending = Vec::new();
254        for file in scanned {
255            let resolved = by_key.get(&file.key_hex).and_then(|entry| {
256                entry
257                    .identity()
258                    .map(|id| (id, entry.seq as u128, entry.pinned))
259            });
260
261            let (id, sort_key, pinned) = match resolved {
262                Some((id, seq, pinned)) => (id, seq, pinned),
263                None => {
264                    // Orphan file: recover its identity from disk. A file that cannot be read as a
265                    // capsule, or is filed under the wrong key, is foreign/corrupt — leave it on disk
266                    // and do not treat it as a holding.
267                    match layout::recover_identity(&file.path) {
268                        Ok(id) if layout::retrieval_key_hex(&id) == file.key_hex => {
269                            (id, file.mtime_nanos, false)
270                        }
271                        _ => continue,
272                    }
273                }
274            };
275            pending.push(Pending {
276                id,
277                key_hex: file.key_hex,
278                size: file.size,
279                pinned,
280                sort_key,
281            });
282        }
283
284        // Normalize recency into a dense 0..n sequence in time order (mixing manifest-seq and mtime
285        // sources cleanly, since only relative order matters).
286        pending.sort_by_key(|p| p.sort_key);
287        let mut entries = HashMap::new();
288        let mut bytes_used = 0u64;
289        for (seq, p) in pending.into_iter().enumerate() {
290            bytes_used = bytes_used.saturating_add(p.size);
291            entries.insert(
292                p.id,
293                Entry {
294                    key_hex: p.key_hex,
295                    size: p.size,
296                    seq: seq as u64,
297                    pinned: p.pinned,
298                },
299            );
300        }
301        let next_seq = entries.len() as u64;
302
303        Ok(Inner {
304            root,
305            config,
306            entries,
307            bytes_used,
308            next_seq,
309        })
310    }
311
312    /// Reject an admission whose single capsule can never fit (larger than the whole capacity), before
313    /// any eviction or staging happens.
314    fn reject_if_too_large(&self, id: CapsuleIdentity, size: u64) -> Result<(), CacheError> {
315        if size > self.config.max_bytes {
316            return Err(CacheError::EntryTooLarge {
317                id: Box::new(id),
318                size,
319                capacity: self.config.max_bytes,
320            });
321        }
322        Ok(())
323    }
324
325    /// Finish an admission whose bytes are already staged in `tmp/`: evict to make room, atomically
326    /// move the staged file into place, and update the index + manifest.
327    fn admit_staged(
328        &mut self,
329        id: CapsuleIdentity,
330        staged: PathBuf,
331        size: u64,
332        pinned: bool,
333    ) -> Result<Admission, CacheError> {
334        let key_hex = layout::retrieval_key_hex(&id);
335
336        let evicted = self.select_evictions(&id, size);
337        if let Err(e) = self.apply_evictions(&evicted) {
338            layout::discard_staged(&staged);
339            return Err(e);
340        }
341
342        if let Err(e) = layout::finalize(&self.root, &key_hex, &staged) {
343            layout::discard_staged(&staged);
344            return Err(e);
345        }
346
347        if let Some(previous) = self.entries.get(&id) {
348            self.bytes_used = self.bytes_used.saturating_sub(previous.size);
349        }
350        self.bytes_used = self.bytes_used.saturating_add(size);
351        let seq = self.next_seq;
352        self.next_seq += 1;
353        self.entries.insert(
354            id,
355            Entry {
356                key_hex,
357                size,
358                seq,
359                pinned,
360            },
361        );
362
363        self.save_manifest()?;
364        Ok(Admission { evicted })
365    }
366
367    /// Ask the policy which capsules to evict to fit `incoming` bytes, excluding the incoming id
368    /// itself (a re-admission of an already-cached capsule reuses its slot rather than evicting it).
369    fn select_evictions(
370        &self,
371        incoming_id: &CapsuleIdentity,
372        incoming: u64,
373    ) -> Vec<CapsuleIdentity> {
374        let existing = self.entries.get(incoming_id).map(|e| e.size).unwrap_or(0);
375        let current_bytes = self.bytes_used.saturating_sub(existing);
376        let entries: Vec<EvictionEntry> = self
377            .entries
378            .iter()
379            .filter(|(id, _)| *id != incoming_id)
380            .map(|(id, e)| EvictionEntry {
381                id: *id,
382                size: e.size,
383                last_access: e.seq,
384                pinned: e.pinned,
385            })
386            .collect();
387        let ctx = EvictionContext {
388            entries: &entries,
389            current_bytes,
390            capacity: self.config.max_bytes,
391            incoming_size: incoming,
392        };
393        self.config.policy.select_evictions(&ctx)
394    }
395
396    /// Evict everything the policy selects to bring the cache within its (possibly newly-lowered)
397    /// capacity. Returns the evicted ids; a filesystem error during a delete is swallowed so a stuck
398    /// file never wedges reconfiguration (the entry is still dropped from the index).
399    fn evict_to_fit(&mut self) -> Vec<CapsuleIdentity> {
400        let entries: Vec<EvictionEntry> = self
401            .entries
402            .iter()
403            .map(|(id, e)| EvictionEntry {
404                id: *id,
405                size: e.size,
406                last_access: e.seq,
407                pinned: e.pinned,
408            })
409            .collect();
410        let ctx = EvictionContext {
411            entries: &entries,
412            current_bytes: self.bytes_used,
413            capacity: self.config.max_bytes,
414            incoming_size: 0,
415        };
416        let evicted = self.config.policy.select_evictions(&ctx);
417        for id in &evicted {
418            let _ = self.drop_entry(id);
419        }
420        evicted
421    }
422
423    /// Delete each selected capsule's file and drop it from the index.
424    fn apply_evictions(&mut self, evicted: &[CapsuleIdentity]) -> Result<(), CacheError> {
425        for id in evicted {
426            self.drop_entry(id)?;
427        }
428        Ok(())
429    }
430
431    /// Remove a single entry: delete its file, subtract its bytes, drop it from the index. A no-op if
432    /// the id is not present.
433    fn drop_entry(&mut self, id: &CapsuleIdentity) -> Result<(), CacheError> {
434        if let Some(entry) = self.entries.remove(id) {
435            layout::remove_capsule(&self.root, &entry.key_hex)?;
436            self.bytes_used = self.bytes_used.saturating_sub(entry.size);
437        }
438        Ok(())
439    }
440
441    /// Serialize the current index to `index.json`.
442    fn save_manifest(&self) -> Result<(), CacheError> {
443        let entries = self
444            .entries
445            .iter()
446            .map(|(id, e)| ManifestEntry {
447                store_id: index::bytes32_hex(&id.store_id),
448                root_hash: index::bytes32_hex(&id.root_hash),
449                retrieval_key: e.key_hex.clone(),
450                size: e.size,
451                seq: e.seq,
452                pinned: e.pinned,
453            })
454            .collect();
455        let manifest = Manifest {
456            version: index::MANIFEST_VERSION,
457            next_seq: self.next_seq,
458            entries,
459        };
460        index::save(&self.root, &manifest)
461    }
462}
463
464/// Recover the declared identity from `bytes` and assert it equals the caller's claim.
465fn verify_claimed_identity(claimed: CapsuleIdentity, bytes: &[u8]) -> Result<(), CacheError> {
466    let recovered = get_capsule_identity(bytes).map_err(|e| CacheError::CorruptEntry {
467        path: PathBuf::from("<in-memory bytes>"),
468        reason: e.to_string(),
469    })?;
470    if recovered != claimed {
471        return Err(CacheError::IdentityMismatch {
472            claimed: Box::new(claimed),
473            recovered: Box::new(recovered),
474        });
475    }
476    Ok(())
477}