1use 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#[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 fn lock_shard(shard: &Mutex<InMemoryCacheShard>) -> MutexGuard<'_, InMemoryCacheShard> {
40 shard
46 .lock()
47 .unwrap_or_else(|_| panic!("pipeline cache shard lock was poisoned"))
48 }
49
50 #[must_use]
52 pub fn new() -> Self {
53 Self::default()
54 }
55
56 #[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 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 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 pub fn is_empty(&self) -> bool {
102 self.shards
103 .iter()
104 .all(|s| Self::lock_shard(s).entries.is_empty())
105 }
106
107 #[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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
218pub enum InMemoryEvictionReason {
219 EntryLimit,
221 ByteLimit,
223 EntryAndByteLimit,
225 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
242pub struct InMemoryEvictionReport {
243 pub entries: u64,
245 pub bytes: u64,
247 pub max_age_ticks: u64,
249 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 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}