Skip to main content

clawft_kernel/
mesh_swarm_cache.rs

1//! Artifact cache with pinned and evictable entries (ADR-099 section 6,
2//! card mesh-placement-25).
3//!
4//! The cache accounts for verified artifacts held by an
5//! [`ArtifactExchange`] against a byte budget. When the unpinned entries
6//! push the total over budget, the **least recently used unpinned** entries
7//! are evicted (their bytes freed, `artifact.evict` chained) until it fits.
8//! A **pinned** entry is never evicted by the policy: that is how a node
9//! keeps the packages and model weights it is running. Pins can push the
10//! cache over budget; [`ArtifactCache::over_budget`] reports it.
11//!
12//! "Used" means admitted, fetched, or served to a peer, so artifacts the
13//! swarm keeps asking this node for stay.
14
15use std::collections::{BTreeMap, HashSet};
16use std::sync::{Arc, Mutex};
17
18use clawft_types::placement::NodeFacts;
19
20use crate::mesh_artifact::ArtifactExchange;
21use crate::mesh_artifact_types::ArtifactId;
22use crate::mesh_swarm_governance::set_held_capabilities;
23
24/// What an entry is, for advertisement.
25#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
26pub enum ArtifactKind {
27    /// Any artifact (package file, manifest).
28    #[default]
29    Generic,
30    /// A model shard: also advertised as `model.present`.
31    Model,
32}
33
34/// Cache budget.
35#[derive(Debug, Clone, Copy, PartialEq, Eq)]
36pub struct CacheConfig {
37    /// Bytes of verified artifacts to keep (pinned entries may exceed it).
38    pub max_bytes: u64,
39}
40
41/// Why the cache refused something.
42#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
43pub enum CacheError {
44    /// Unknown or unverified artifact.
45    #[error("artifact {0} is not held and verified")]
46    NotHeld(String),
47    /// Larger than the whole budget and not pinned: not kept.
48    #[error("artifact {id} ({size} bytes) exceeds the cache budget of {budget} bytes")]
49    TooLarge {
50        /// Artifact.
51        id: String,
52        /// Its size.
53        size: u64,
54        /// The budget.
55        budget: u64,
56    },
57    /// Pinned entries cannot be removed until unpinned.
58    #[error("artifact {0} is pinned")]
59    Pinned(String),
60}
61
62/// One cache entry as reported.
63#[derive(Debug, Clone, PartialEq, Eq)]
64pub struct CacheEntryInfo {
65    /// Artifact.
66    pub id: ArtifactId,
67    /// Whole-content hash.
68    pub content_hash: [u8; 32],
69    /// Size in bytes.
70    pub size: u64,
71    /// Protected from eviction.
72    pub pinned: bool,
73    /// Kind, for advertisement.
74    pub kind: ArtifactKind,
75}
76
77struct Entry {
78    content_hash: [u8; 32],
79    size: u64,
80    pinned: bool,
81    kind: ArtifactKind,
82    last_used: u64,
83}
84
85#[derive(Default)]
86struct Inner {
87    entries: BTreeMap<ArtifactId, Entry>,
88    tick: u64,
89    /// Content hashes to pin the moment they are admitted.
90    pin_on_arrival: HashSet<[u8; 32]>,
91}
92
93impl Inner {
94    fn next_tick(&mut self) -> u64 {
95        self.tick += 1;
96        self.tick
97    }
98    fn used(&self) -> u64 {
99        self.entries.values().map(|e| e.size).sum()
100    }
101}
102
103fn package_hashes(pkg: &crate::mesh_artifact_pkg::ExchangedPackage) -> Vec<[u8; 32]> {
104    use crate::workload_pkg::codec::hex_decode_exact;
105    std::iter::once(pkg.manifest_hash.as_str())
106        .chain(pkg.verified.body.files().map(|f| f.blake3.as_str()))
107        .filter_map(hex_decode_exact::<32>)
108        .collect()
109}
110
111/// Budgeted, pin-aware view of the verified artifacts of one exchange.
112pub struct ArtifactCache {
113    ex: Arc<ArtifactExchange>,
114    cfg: CacheConfig,
115    inner: Mutex<Inner>,
116}
117
118impl ArtifactCache {
119    /// Cache over `ex`. The exchange reports every artifact that becomes
120    /// verified (seeded or fetched) to the cache, and the cache enforces
121    /// the budget as they arrive. Artifacts already verified are admitted.
122    pub fn new(ex: Arc<ArtifactExchange>, cfg: CacheConfig) -> Arc<Self> {
123        let cache = Arc::new(Self {
124            ex: ex.clone(),
125            cfg,
126            inner: Mutex::new(Inner::default()),
127        });
128        ex.swarm.attach_cache(&cache);
129        for d in ex.descriptors.iter().map(|d| d.clone()).collect::<Vec<_>>() {
130            let _ = cache.admit(&d.id());
131        }
132        cache
133    }
134
135    fn lock(&self) -> std::sync::MutexGuard<'_, Inner> {
136        self.inner.lock().unwrap_or_else(|p| p.into_inner())
137    }
138
139    /// The exchange this cache accounts.
140    pub fn exchange(&self) -> &Arc<ArtifactExchange> {
141        &self.ex
142    }
143
144    /// Account for verified artifact `id` and enforce the budget. Returns
145    /// the artifacts evicted to make room. An unpinned artifact larger than
146    /// the whole budget is evicted again and reported as an error.
147    pub fn admit(&self, id: &ArtifactId) -> Result<Vec<ArtifactId>, CacheError> {
148        let d = self
149            .ex
150            .descriptor(id)
151            .ok_or_else(|| CacheError::NotHeld(id.to_string()))?;
152        {
153            let mut g = self.lock();
154            let tick = g.next_tick();
155            let pinned = g.pin_on_arrival.contains(&d.content_hash);
156            g.entries
157                .entry(*id)
158                .and_modify(|e| e.last_used = tick)
159                .or_insert(Entry {
160                    content_hash: d.content_hash,
161                    size: d.total_size,
162                    pinned,
163                    kind: ArtifactKind::Generic,
164                    last_used: tick,
165                });
166        }
167        let too_large = d.total_size > self.cfg.max_bytes && !self.is_pinned(id);
168        if too_large {
169            self.evict(id, "lru");
170            return Err(CacheError::TooLarge {
171                id: id.to_string(),
172                size: d.total_size,
173                budget: self.cfg.max_bytes,
174            });
175        }
176        Ok(self.enforce_keeping(Some(id)))
177    }
178
179    /// Hook from the exchange: an artifact became verified.
180    pub(crate) fn on_verified(&self, id: &ArtifactId) {
181        let _ = self.admit(id);
182    }
183
184    /// Hook from the exchange: a download began. Its bytes count against the
185    /// budget, so room is made now rather than after it lands.
186    pub(crate) fn on_pending(&self) {
187        self.enforce();
188    }
189
190    /// Hook from the exchange: an artifact was forgotten (evicted or revoked).
191    pub(crate) fn on_forgotten(&self, id: &ArtifactId) {
192        self.lock().entries.remove(id);
193    }
194
195    /// Mark `id` as just used.
196    pub fn touch(&self, id: &ArtifactId) {
197        let mut g = self.lock();
198        let tick = g.next_tick();
199        if let Some(e) = g.entries.get_mut(id) {
200            e.last_used = tick;
201        }
202    }
203
204    /// Protect `id` from eviction. False if it is not in the cache.
205    pub fn pin(&self, id: &ArtifactId) -> bool {
206        self.set_pinned(id, true)
207    }
208
209    /// Make `id` evictable again, then enforce the budget (it may go now).
210    pub fn unpin(&self, id: &ArtifactId) -> bool {
211        let known = self.set_pinned(id, false);
212        if known {
213            self.enforce();
214        }
215        known
216    }
217
218    fn set_pinned(&self, id: &ArtifactId, pinned: bool) -> bool {
219        let mut g = self.lock();
220        let tick = g.next_tick();
221        match g.entries.get_mut(id) {
222            Some(e) => {
223                e.pinned = pinned;
224                e.last_used = tick;
225                true
226            }
227            None => false,
228        }
229    }
230
231    /// Pin whatever has this content hash, now or the moment it arrives. A
232    /// fetch admits an artifact as soon as it verifies, so a caller about to
233    /// run a package pins its hashes first, or an over-budget cache could
234    /// evict the artifact before the caller can pin it.
235    pub fn pin_content(&self, content_hash: [u8; 32]) {
236        let mut g = self.lock();
237        g.pin_on_arrival.insert(content_hash);
238        for e in g.entries.values_mut().filter(|e| e.content_hash == content_hash) {
239            e.pinned = true;
240        }
241    }
242
243    /// Undo [`Self::pin_content`] and make matching entries evictable again.
244    pub fn unpin_content(&self, content_hash: &[u8; 32]) {
245        {
246            let mut g = self.lock();
247            g.pin_on_arrival.remove(content_hash);
248            for e in g.entries.values_mut().filter(|e| e.content_hash == *content_hash) {
249                e.pinned = false;
250            }
251        }
252        self.enforce();
253    }
254
255    /// Pin a package's manifest and every file it lists.
256    pub fn pin_package(&self, pkg: &crate::mesh_artifact_pkg::ExchangedPackage) {
257        for h in package_hashes(pkg) {
258            self.pin_content(h);
259        }
260    }
261
262    /// Make a package's manifest and files evictable again (it was removed).
263    pub fn unpin_package(&self, pkg: &crate::mesh_artifact_pkg::ExchangedPackage) {
264        for h in package_hashes(pkg) {
265            self.unpin_content(&h);
266        }
267    }
268
269    /// True if `id` is pinned.
270    pub fn is_pinned(&self, id: &ArtifactId) -> bool {
271        self.lock().entries.get(id).is_some_and(|e| e.pinned)
272    }
273
274    /// Set what `id` is (for `model.present`).
275    pub fn set_kind(&self, id: &ArtifactId, kind: ArtifactKind) -> bool {
276        match self.lock().entries.get_mut(id) {
277            Some(e) => {
278                e.kind = kind;
279                true
280            }
281            None => false,
282        }
283    }
284
285    /// Evict `id` now. Pinned entries are refused.
286    pub fn remove(&self, id: &ArtifactId) -> Result<(), CacheError> {
287        if self.is_pinned(id) {
288            return Err(CacheError::Pinned(id.to_string()));
289        }
290        if !self.lock().entries.contains_key(id) {
291            return Err(CacheError::NotHeld(id.to_string()));
292        }
293        self.evict(id, "manual");
294        Ok(())
295    }
296
297    /// Evict least-recently-used unpinned entries until the total fits the
298    /// budget. Returns the evicted ids, oldest first.
299    pub fn enforce(&self) -> Vec<ArtifactId> {
300        self.enforce_keeping(None)
301    }
302
303    /// As [`Self::enforce`], but `keep` (just admitted) is evicted only as a
304    /// last resort, when nothing else unpinned is left to free.
305    fn enforce_keeping(&self, keep: Option<&ArtifactId>) -> Vec<ArtifactId> {
306        let mut evicted = Vec::new();
307        loop {
308            let victim = {
309                let g = self.lock();
310                if g.used() + self.ex.pending_bytes() <= self.cfg.max_bytes {
311                    break;
312                }
313                let oldest = |skip: Option<&ArtifactId>| {
314                    g.entries
315                        .iter()
316                        .filter(|(id, e)| !e.pinned && Some(*id) != skip)
317                        .min_by_key(|(_, e)| e.last_used)
318                        .map(|(id, _)| *id)
319                };
320                oldest(keep).or_else(|| keep.and_then(|k| oldest(None).filter(|v| v == k)))
321            };
322            let Some(id) = victim else { break };
323            self.evict(&id, "lru");
324            evicted.push(id);
325        }
326        evicted
327    }
328
329    fn evict(&self, id: &ArtifactId, reason: &str) {
330        self.lock().entries.remove(id);
331        self.ex.forget(id, reason);
332    }
333
334    /// Bytes accounted: verified entries plus downloads in progress.
335    pub fn used_bytes(&self) -> u64 {
336        self.lock().used() + self.ex.pending_bytes()
337    }
338
339    /// Bytes over the budget (only pins and downloads in progress can cause this).
340    pub fn over_budget(&self) -> u64 {
341        self.used_bytes().saturating_sub(self.cfg.max_bytes)
342    }
343
344    /// Entries, least recently used first.
345    pub fn entries(&self) -> Vec<CacheEntryInfo> {
346        let g = self.lock();
347        let mut v: Vec<_> = g.entries.iter().collect();
348        v.sort_by_key(|(_, e)| e.last_used);
349        v.into_iter()
350            .map(|(id, e)| CacheEntryInfo {
351                id: *id,
352                content_hash: e.content_hash,
353                size: e.size,
354                pinned: e.pinned,
355                kind: e.kind,
356            })
357            .collect()
358    }
359
360    /// Merge what this node can serve into `facts` as capabilities
361    /// (`store.artifact.*`, `model.present`), replacing any older ones.
362    pub fn advertise(&self, facts: &mut NodeFacts) {
363        let shards: HashSet<[u8; 32]> = self
364            .lock()
365            .entries
366            .values()
367            .filter(|e| e.kind == ArtifactKind::Model)
368            .map(|e| e.content_hash)
369            .collect();
370        set_held_capabilities(facts, self.ex.held_capabilities(&shards));
371    }
372}
373
374#[cfg(test)]
375#[path = "mesh_swarm_cache_tests.rs"]
376mod tests;