Skip to main content

vyre_runtime/pipeline_cache/
in_memory.rs

1//! [`InMemoryPipelineCache`]  -  sharded zero-persistence cache. The hot
2//! path for in-process pipeline reuse.
3
4use std::sync::{Arc, Mutex, MutexGuard};
5
6use rustc_hash::FxHashMap;
7
8use super::fingerprint::PipelineFingerprint;
9use super::metrics::{PipelineCacheCounters, PipelineCacheMetrics};
10use super::store::PipelineCacheStore;
11
12/// In-memory pipeline cache  -  zero-persistence, zero-network, sharded
13/// `FxHashMap`s behind mutexes so concurrent `get`/`put` on different
14/// fingerprints rarely contend (VYRE_RUNTIME / PERF hot-cache audit).
15#[derive(Debug)]
16pub struct InMemoryPipelineCache {
17    shards: [Mutex<InMemoryCacheShard>; Self::SHARD_COUNT],
18    max_entries_per_shard: usize,
19    max_bytes_per_shard: usize,
20    metrics: PipelineCacheCounters,
21}
22
23impl InMemoryPipelineCache {
24    pub(super) const SHARD_COUNT: usize = 256;
25    pub(super) const MAX_ENTRIES_PER_SHARD: usize = 256;
26    pub(super) const MAX_BYTES_PER_SHARD: usize = 16 * 1024 * 1024;
27
28    #[inline]
29    fn shard_index(fp: &PipelineFingerprint) -> usize {
30        usize::from(fp.0[0]) % Self::SHARD_COUNT
31    }
32
33    /// Lock one cache shard, failing closed on a poisoned lock.
34    ///
35    /// # Panics
36    /// Panics when the shard lock is poisoned. A poisoned lock means a panic left the
37    /// shard inconsistent, and serving from it could return a pipeline built for another
38    /// program.
39    fn lock_shard(shard: &Mutex<InMemoryCacheShard>) -> MutexGuard<'_, InMemoryCacheShard> {
40        // Fail closed on poison. `PoisonError::into_inner` would silently hand
41        // back a guard over shard state left half-mutated by a panicking writer
42        //: every subsequent cache read/write would then trust corrupt data with
43        // no signal. A poisoned pipeline-cache shard is unrecoverable; surface it
44        // loudly instead of laundering it.
45        shard
46            .lock()
47            .unwrap_or_else(|_| panic!("pipeline cache shard lock was poisoned"))
48    }
49
50    /// Construct an empty cache.
51    #[must_use]
52    pub fn new() -> Self {
53        Self::default()
54    }
55
56    /// Construct an empty cache with explicit per-shard entry and byte budgets.
57    ///
58    /// A zero entry budget or zero byte budget creates a disabled cache that
59    /// accepts `put` calls but retains no artifacts.
60    #[must_use]
61    pub fn with_limits(max_entries_per_shard: usize, max_bytes_per_shard: usize) -> Self {
62        Self {
63            shards: std::array::from_fn(|_| Mutex::new(InMemoryCacheShard::default())),
64            max_entries_per_shard,
65            max_bytes_per_shard,
66            metrics: PipelineCacheCounters::default(),
67        }
68    }
69
70    /// Current entry count. Thread-safe snapshot.
71    pub fn len(&self) -> usize {
72        self.shards
73            .iter()
74            .map(|s| Self::lock_shard(s).entries.len())
75            .fold(0usize, |acc, value| {
76                cache_usize_add(
77                    acc,
78                    value,
79                    "entry count",
80                    "shard cache metrics before snapshotting",
81                )
82            })
83    }
84
85    /// Current cached artifact bytes. Thread-safe snapshot.
86    pub fn cached_bytes(&self) -> usize {
87        self.shards
88            .iter()
89            .map(|s| Self::lock_shard(s).bytes)
90            .fold(0usize, |acc, value| {
91                cache_usize_add(
92                    acc,
93                    value,
94                    "byte count",
95                    "shard cache metrics before snapshotting",
96                )
97            })
98    }
99
100    /// Whether the cache is empty.
101    pub fn is_empty(&self) -> bool {
102        self.shards
103            .iter()
104            .all(|s| Self::lock_shard(s).entries.is_empty())
105    }
106
107    /// Snapshot the most recent eviction report from every shard that has
108    /// evicted at least one artifact.
109    #[must_use]
110    pub fn eviction_reports(&self) -> Vec<InMemoryEvictionReport> {
111        let mut reports = Vec::new();
112        for shard in &self.shards {
113            if let Some(report) = Self::lock_shard(shard).last_eviction {
114                reports.push(report);
115            }
116        }
117        reports
118    }
119}
120
121impl Default for InMemoryPipelineCache {
122    fn default() -> Self {
123        Self::with_limits(Self::MAX_ENTRIES_PER_SHARD, Self::MAX_BYTES_PER_SHARD)
124    }
125}
126
127fn cache_usize_add(lhs: usize, rhs: usize, _label: &'static str, _fix: &'static str) -> usize {
128    lhs.saturating_add(rhs)
129}
130
131fn cache_usize_sub(lhs: usize, rhs: usize, _label: &'static str, _fix: &'static str) -> usize {
132    lhs.saturating_sub(rhs)
133}
134
135fn cache_u64_add(lhs: u64, rhs: u64, _label: &'static str, _fix: &'static str) -> u64 {
136    lhs.saturating_add(rhs)
137}
138
139fn cache_u64_sub(lhs: u64, rhs: u64, _label: &'static str, _fix: &'static str) -> u64 {
140    lhs.saturating_sub(rhs)
141}
142
143fn cache_usize_to_u64(value: usize, _label: &'static str, _fix: &'static str) -> u64 {
144    match u64::try_from(value) {
145        Ok(value) => value,
146        Err(_) => u64::MAX,
147    }
148}
149
150#[derive(Debug, Default)]
151struct InMemoryCacheShard {
152    entries: FxHashMap<PipelineFingerprint, InMemoryCacheEntry>,
153    bytes: usize,
154    clock: u64,
155    last_eviction: Option<InMemoryEvictionReport>,
156}
157
158impl InMemoryCacheShard {
159    fn next_tick(&mut self) -> u64 {
160        self.clock = cache_u64_add(
161            self.clock,
162            1,
163            "shard clock",
164            "recreate the cache before LRU timestamps wrap",
165        );
166        self.clock
167    }
168
169    fn evict_to_limits(
170        &mut self,
171        max_entries: usize,
172        max_bytes: usize,
173    ) -> Option<InMemoryEvictionReport> {
174        let mut report = None;
175        while self.entries.len() > max_entries || self.bytes > max_bytes {
176            let reason = InMemoryEvictionReason::from_limits(
177                self.entries.len(),
178                self.bytes,
179                max_entries,
180                max_bytes,
181            );
182            let Some(victim) = self
183                .entries
184                .iter()
185                .min_by_key(|(_, entry)| entry.last_used)
186                .map(|(fp, _)| *fp)
187            else {
188                self.bytes = 0;
189                self.last_eviction = report;
190                return report;
191            };
192            if let Some(removed) = self.entries.remove(&victim) {
193                self.bytes = cache_usize_sub(
194                    self.bytes,
195                    removed.bytes,
196                    "byte accounting during eviction",
197                    "rebuild the cache",
198                );
199                record_eviction_report(&mut report, reason, self.clock, &removed);
200            }
201        }
202        if report.is_some() {
203            self.last_eviction = report;
204        }
205        report
206    }
207}
208
209#[derive(Debug)]
210struct InMemoryCacheEntry {
211    artifact: Arc<Vec<u8>>,
212    bytes: usize,
213    last_used: u64,
214}
215
216/// Reason the in-memory pipeline cache evicted retained artifacts.
217#[derive(Debug, Clone, Copy, PartialEq, Eq)]
218pub enum InMemoryEvictionReason {
219    /// Entry-count budget was exceeded.
220    EntryLimit,
221    /// Byte budget was exceeded.
222    ByteLimit,
223    /// Entry-count and byte budgets were both exceeded.
224    EntryAndByteLimit,
225    /// A rejected put removed an existing artifact for the same key.
226    RejectedPut,
227}
228
229impl InMemoryEvictionReason {
230    fn from_limits(entries: usize, bytes: usize, max_entries: usize, max_bytes: usize) -> Self {
231        match (entries > max_entries, bytes > max_bytes) {
232            (true, true) => Self::EntryAndByteLimit,
233            (true, false) => Self::EntryLimit,
234            (false, true) => Self::ByteLimit,
235            (false, false) => Self::EntryLimit,
236        }
237    }
238}
239
240/// Structured eviction report for one in-memory cache shard.
241#[derive(Debug, Clone, Copy, PartialEq, Eq)]
242pub struct InMemoryEvictionReport {
243    /// Number of entries removed by the eviction decision.
244    pub entries: u64,
245    /// Bytes removed by the eviction decision.
246    pub bytes: u64,
247    /// Maximum age of an evicted entry in shard clock ticks.
248    pub max_age_ticks: u64,
249    /// Why eviction happened.
250    pub reason: InMemoryEvictionReason,
251}
252
253fn record_eviction_report(
254    report: &mut Option<InMemoryEvictionReport>,
255    reason: InMemoryEvictionReason,
256    now: u64,
257    removed: &InMemoryCacheEntry,
258) {
259    let removed_bytes = cache_usize_to_u64(
260        removed.bytes,
261        "evicted byte count",
262        "shard cache artifacts before eviction",
263    );
264    let age = cache_u64_sub(
265        now,
266        removed.last_used,
267        "evicted entry age",
268        "rebuild the cache LRU clock",
269    );
270    match report {
271        Some(report) => {
272            report.entries = cache_u64_add(
273                report.entries,
274                1,
275                "eviction count",
276                "shard cache eviction work",
277            );
278            report.bytes = cache_u64_add(
279                report.bytes,
280                removed_bytes,
281                "evicted byte count",
282                "shard cache eviction work",
283            );
284            report.max_age_ticks = report.max_age_ticks.max(age);
285        }
286        None => {
287            *report = Some(InMemoryEvictionReport {
288                entries: 1,
289                bytes: removed_bytes,
290                max_age_ticks: age,
291                reason,
292            });
293        }
294    }
295}
296
297impl PipelineCacheStore for InMemoryPipelineCache {
298    fn get(&self, fp: &PipelineFingerprint) -> Option<Vec<u8>> {
299        self.get_arc(fp).map(|artifact| (*artifact).clone())
300    }
301
302    /// V7-PERF-009: zero-clone hot-path lookup. The cache already stores
303    /// payloads behind `Arc<Vec<u8>>`, so a hit is one refcount bump.
304    fn get_arc(&self, fp: &PipelineFingerprint) -> Option<Arc<Vec<u8>>> {
305        PipelineCacheCounters::increment(&self.metrics.lookups, "lookups");
306        let i = Self::shard_index(fp);
307        let mut shard = Self::lock_shard(&self.shards[i]);
308        let tick = shard.next_tick();
309        let Some(entry) = shard.entries.get_mut(fp) else {
310            PipelineCacheCounters::increment(&self.metrics.misses, "misses");
311            return None;
312        };
313        entry.last_used = tick;
314        PipelineCacheCounters::increment(&self.metrics.hits, "hits");
315        Some(Arc::clone(&entry.artifact))
316    }
317
318    fn put(&self, fp: PipelineFingerprint, artifact: Vec<u8>) {
319        let i = Self::shard_index(&fp);
320        let mut shard = Self::lock_shard(&self.shards[i]);
321        let bytes = artifact.len();
322        if self.max_entries_per_shard == 0
323            || self.max_bytes_per_shard == 0
324            || bytes > self.max_bytes_per_shard
325        {
326            PipelineCacheCounters::increment(&self.metrics.rejected_puts, "rejected puts");
327            if let Some(removed) = shard.entries.remove(&fp) {
328                let tick = shard.next_tick();
329                shard.bytes = cache_usize_sub(
330                    shard.bytes,
331                    removed.bytes,
332                    "byte accounting while rejecting put",
333                    "rebuild the cache",
334                );
335                let mut report = None;
336                record_eviction_report(
337                    &mut report,
338                    InMemoryEvictionReason::RejectedPut,
339                    tick,
340                    &removed,
341                );
342                shard.last_eviction = report;
343                PipelineCacheCounters::increment(&self.metrics.evictions, "evictions");
344                PipelineCacheCounters::add(
345                    &self.metrics.evicted_bytes,
346                    cache_usize_to_u64(
347                        removed.bytes,
348                        "evicted byte count",
349                        "shard cache artifacts before eviction",
350                    ),
351                    "evicted bytes",
352                );
353            }
354            return;
355        }
356
357        if let Some(existing) = shard.entries.remove(&fp) {
358            shard.bytes = cache_usize_sub(
359                shard.bytes,
360                existing.bytes,
361                "byte accounting while replacing entry",
362                "rebuild the cache",
363            );
364        }
365        let tick = shard.next_tick();
366        shard.bytes = cache_usize_add(
367            shard.bytes,
368            bytes,
369            "byte accounting while inserting entry",
370            "lower per-shard cache byte budget",
371        );
372        shard.entries.insert(
373            fp,
374            InMemoryCacheEntry {
375                artifact: Arc::new(artifact),
376                bytes,
377                last_used: tick,
378            },
379        );
380        PipelineCacheCounters::increment(&self.metrics.puts, "puts");
381        if let Some(report) =
382            shard.evict_to_limits(self.max_entries_per_shard, self.max_bytes_per_shard)
383        {
384            PipelineCacheCounters::add(&self.metrics.evictions, report.entries, "evictions");
385            PipelineCacheCounters::add(&self.metrics.evicted_bytes, report.bytes, "evicted bytes");
386        }
387    }
388
389    fn metrics(&self) -> PipelineCacheMetrics {
390        self.metrics.snapshot(
391            cache_usize_to_u64(
392                self.cached_bytes(),
393                "retained byte snapshot",
394                "shard cache metrics before snapshotting",
395            ),
396            cache_usize_to_u64(
397                self.len(),
398                "entry count snapshot",
399                "shard cache metrics before snapshotting",
400            ),
401        )
402    }
403}
404
405#[cfg(test)]
406mod tests {
407    use super::*;
408    use crate::pipeline_cache::test_helpers::tiny_program;
409
410    #[test]
411    fn in_memory_cache_roundtrip() {
412        let cache = InMemoryPipelineCache::new();
413        let fp = PipelineFingerprint::of(&tiny_program());
414        assert!(cache.get(&fp).is_none());
415        cache.put(fp, b"target-bytes".to_vec());
416        assert_eq!(cache.get(&fp).unwrap(), b"target-bytes".to_vec());
417        assert_eq!(cache.len(), 1);
418    }
419
420    #[test]
421    fn in_memory_cache_caps_each_shard() {
422        let cache = InMemoryPipelineCache::new();
423        for i in 0..(InMemoryPipelineCache::MAX_ENTRIES_PER_SHARD + 17) {
424            let mut bytes = [0_u8; 32];
425            bytes[1..9].copy_from_slice(&(i as u64).to_le_bytes());
426            cache.put(PipelineFingerprint(bytes), vec![i as u8]);
427        }
428        assert_eq!(cache.len(), InMemoryPipelineCache::MAX_ENTRIES_PER_SHARD);
429    }
430
431    #[test]
432    fn in_memory_cache_evicts_least_recently_used_entry() {
433        let cache = InMemoryPipelineCache::with_limits(2, 1024);
434        let a = PipelineFingerprint([0; 32]);
435        let mut b_bytes = [0; 32];
436        b_bytes[1] = 1;
437        let b = PipelineFingerprint(b_bytes);
438        let mut c_bytes = [0; 32];
439        c_bytes[1] = 2;
440        let c = PipelineFingerprint(c_bytes);
441
442        cache.put(a, b"a".to_vec());
443        cache.put(b, b"b".to_vec());
444        assert_eq!(cache.get(&a).unwrap(), b"a".to_vec());
445        cache.put(c, b"c".to_vec());
446
447        assert_eq!(cache.get(&a).unwrap(), b"a".to_vec());
448        assert!(cache.get(&b).is_none());
449        assert_eq!(cache.get(&c).unwrap(), b"c".to_vec());
450    }
451
452    #[test]
453    fn in_memory_cache_enforces_byte_budget() {
454        let cache = InMemoryPipelineCache::with_limits(8, 10);
455        let a = PipelineFingerprint([0; 32]);
456        let mut b_bytes = [0; 32];
457        b_bytes[1] = 1;
458        let b = PipelineFingerprint(b_bytes);
459        let mut too_large_bytes = [0; 32];
460        too_large_bytes[1] = 2;
461        let too_large = PipelineFingerprint(too_large_bytes);
462
463        cache.put(a, vec![1; 6]);
464        cache.put(b, vec![2; 6]);
465        assert!(cache.get(&a).is_none());
466        assert_eq!(cache.get(&b).unwrap(), vec![2; 6]);
467        assert_eq!(cache.cached_bytes(), 6);
468
469        cache.put(too_large, vec![3; 11]);
470        assert!(cache.get(&too_large).is_none());
471        assert_eq!(cache.cached_bytes(), 6);
472    }
473
474    #[test]
475    fn in_memory_cache_metrics_track_hits_misses_and_evictions() {
476        let cache = InMemoryPipelineCache::with_limits(1, 8);
477        let a = PipelineFingerprint([0; 32]);
478        let mut b_bytes = [0; 32];
479        b_bytes[1] = 1;
480        let b = PipelineFingerprint(b_bytes);
481
482        assert!(cache.get(&a).is_none());
483        cache.put(a, vec![1; 4]);
484        assert!(cache.get(&a).is_some());
485        cache.put(b, vec![2; 4]);
486
487        let metrics = cache.metrics();
488        assert_eq!(metrics.lookups, 2);
489        assert_eq!(metrics.hits, 1);
490        assert_eq!(metrics.misses, 1);
491        assert_eq!(metrics.puts, 2);
492        assert_eq!(metrics.evictions, 1);
493        assert_eq!(metrics.cached_bytes, 4);
494        assert_eq!(metrics.entries, 1);
495        assert_eq!(metrics.hit_rate_ppm(), 500_000);
496    }
497
498    #[test]
499    fn in_memory_cache_eviction_report_records_reason_entries_bytes_and_age() {
500        let cache = InMemoryPipelineCache::with_limits(1, 8);
501        let a = PipelineFingerprint([0; 32]);
502        let mut b_bytes = [0; 32];
503        b_bytes[1] = 1;
504        let b = PipelineFingerprint(b_bytes);
505
506        cache.put(a, vec![1; 4]);
507        assert!(cache.get(&a).is_some());
508        cache.put(b, vec![2; 4]);
509
510        let reports = cache.eviction_reports();
511        assert_eq!(reports.len(), 1);
512        let report = reports[0];
513        assert_eq!(report.reason, InMemoryEvictionReason::EntryLimit);
514        assert_eq!(report.entries, 1);
515        assert_eq!(report.bytes, 4);
516        assert!(
517            report.max_age_ticks > 0,
518            "Fix: eviction reports must expose LRU age, got {report:?}"
519        );
520    }
521
522    #[test]
523    fn rejected_oversize_put_records_eviction_reason_for_replaced_entry() {
524        let cache = InMemoryPipelineCache::with_limits(8, 8);
525        let fp = PipelineFingerprint([0; 32]);
526
527        cache.put(fp, vec![1; 4]);
528        cache.put(fp, vec![2; 9]);
529
530        assert!(cache.get(&fp).is_none());
531        let reports = cache.eviction_reports();
532        assert_eq!(reports.len(), 1);
533        let report = reports[0];
534        assert_eq!(report.reason, InMemoryEvictionReason::RejectedPut);
535        assert_eq!(report.entries, 1);
536        assert_eq!(report.bytes, 4);
537    }
538
539    #[test]
540    fn poisoned_cache_shard_is_not_silently_recovered() {
541        let cache = Arc::new(InMemoryPipelineCache::new());
542        let poisoned = Arc::clone(&cache);
543        let _ = std::thread::spawn(move || {
544            let _guard = InMemoryPipelineCache::lock_shard(&poisoned.shards[0]);
545            panic!("poison in-memory pipeline cache shard");
546        })
547        .join();
548
549        let panic = std::panic::catch_unwind(|| {
550            let _ = cache.len();
551        })
552        .expect_err("poisoned pipeline cache shard must panic instead of recovering");
553        let message = panic
554            .downcast_ref::<String>()
555            .map(String::as_str)
556            .or_else(|| panic.downcast_ref::<&'static str>().copied())
557            .unwrap_or("<non-string panic>");
558        assert!(
559            message.contains("pipeline cache shard lock was poisoned"),
560            "{message}"
561        );
562    }
563}