1use std::{marker::PhantomData, num::NonZeroUsize, sync::Arc, time::Duration};
4
5use async_trait::async_trait;
6use saluki_common::{hash::FastBuildHasher, sync::shutdown::ShutdownHandle};
7use saluki_error::GenericError;
8use saluki_metrics::{static_metrics, Counter, Gauge, Histogram};
9use tokio::{pin, select, time::sleep};
10use tracing::debug;
11
12use crate::runtime::{self, scope, InitializationError, ScopeGuard, Supervisable, SupervisorFuture};
13
14mod expiry;
15use self::expiry::{Expiration, ExpirationBuilder, ExpiryCapableLifecycle};
16
17pub mod weight;
18use self::weight::{ItemCountWeighter, Weighter, WrappedWeighter};
19
20type RawCache<K, V, W, H> = quick_cache::sync::Cache<K, V, WrappedWeighter<W>, H, ExpiryCapableLifecycle<K>>;
21
22#[static_metrics(prefix = cache, labels(cache_id))]
23#[derive(Clone)]
24struct Telemetry {
25 current_items: Gauge,
26 current_weight: Gauge,
27 weight_limit: Gauge,
28 hits_total: Counter,
29 misses_total: Counter,
30 items_evicted_total: Counter,
31 #[metric(level = debug)]
32 items_inserted_total: Counter,
33 #[metric(level = debug)]
34 items_removed_total: Counter,
35 #[metric(level = debug)]
36 items_expired_total: Counter,
37 #[metric(level = trace)]
38 items_expired_batch_size: Histogram,
39}
40
41struct InnerCache<K, V, W, H> {
42 cache: Arc<RawCache<K, V, W, H>>,
43
44 _drivers: Option<ScopeGuard>,
50}
51
52pub struct CacheBuilder<K, V, W = ItemCountWeighter, H = FastBuildHasher> {
54 identifier: String,
55 capacity: NonZeroUsize,
56 weighter: W,
57 idle_period: Option<Duration>,
58 expiration_interval: Option<Duration>,
59 telemetry_enabled: bool,
60 _key: PhantomData<K>,
61 _value: PhantomData<V>,
62 _hasher: PhantomData<H>,
63}
64
65impl<K, V> CacheBuilder<K, V> {
66 pub fn from_identifier<N: Into<String>>(identifier: N) -> Result<CacheBuilder<K, V>, GenericError> {
76 let identifier = identifier.into();
77 if identifier.is_empty() {
78 return Err(GenericError::msg("cache identifier must not be empty"));
79 }
80
81 Ok(CacheBuilder {
82 identifier,
83 capacity: NonZeroUsize::MAX,
84 weighter: ItemCountWeighter,
85 idle_period: None,
86 expiration_interval: None,
87 telemetry_enabled: true,
88 _key: PhantomData,
89 _value: PhantomData,
90 _hasher: PhantomData,
91 })
92 }
93
94 pub fn for_tests() -> CacheBuilder<K, V> {
105 CacheBuilder::from_identifier("noop")
106 .expect("cache identifier not empty")
107 .with_telemetry(false)
108 }
109}
110
111impl<K, V, W, H> CacheBuilder<K, V, W, H> {
112 pub fn with_capacity(mut self, capacity: NonZeroUsize) -> Self {
121 self.capacity = capacity;
122 self
123 }
124
125 pub fn with_time_to_idle(mut self, idle_period: Option<Duration>) -> Self {
135 self.idle_period = idle_period;
136
137 if self.idle_period.is_some() {
139 self.expiration_interval = self.expiration_interval.or(Some(Duration::from_secs(1)));
140 }
141
142 self
143 }
144
145 pub fn with_expiration_interval(mut self, expiration_interval: Duration) -> Self {
158 self.expiration_interval = Some(expiration_interval);
159 self
160 }
161
162 pub fn with_item_weighter<W2>(self, weighter: W2) -> CacheBuilder<K, V, W2, H> {
175 CacheBuilder {
176 identifier: self.identifier,
177 capacity: self.capacity,
178 weighter,
179 idle_period: self.idle_period,
180 expiration_interval: self.expiration_interval,
181 telemetry_enabled: self.telemetry_enabled,
182 _key: PhantomData,
183 _value: PhantomData,
184 _hasher: PhantomData,
185 }
186 }
187
188 pub fn with_hasher<H2>(self) -> CacheBuilder<K, V, W, H2> {
196 CacheBuilder {
197 identifier: self.identifier,
198 capacity: self.capacity,
199 weighter: self.weighter,
200 idle_period: self.idle_period,
201 expiration_interval: self.expiration_interval,
202 telemetry_enabled: self.telemetry_enabled,
203 _key: PhantomData,
204 _value: PhantomData,
205 _hasher: PhantomData,
206 }
207 }
208
209 pub fn with_telemetry(mut self, enabled: bool) -> Self {
218 self.telemetry_enabled = enabled;
219 self
220 }
221}
222
223impl<K, V, W, H> CacheBuilder<K, V, W, H>
224where
225 K: Eq + std::hash::Hash + Clone + Send + Sync + 'static,
226 V: Clone + Send + Sync + 'static,
227 W: Weighter<K, V> + Clone + Send + Sync + 'static,
228 H: std::hash::BuildHasher + Clone + Default + Send + Sync + 'static,
229{
230 pub fn build(self) -> Cache<K, V, W, H> {
236 let capacity = self.capacity.get();
237
238 let drivers_name = format!("cache_{}", self.identifier);
239 let telemetry = Telemetry::new(self.identifier);
240 telemetry.weight_limit().set(capacity as f64);
241
242 let eviction_counter = if self.telemetry_enabled {
244 telemetry.items_evicted_total().clone()
245 } else {
246 Counter::noop()
247 };
248 let mut expiration_builder = ExpirationBuilder::new(eviction_counter);
249 if let Some(time_to_idle) = self.idle_period {
250 expiration_builder = expiration_builder.with_time_to_idle(time_to_idle);
251 }
252 let (expiration, expiry_lifecycle) = expiration_builder.build();
253
254 let raw_cache = Arc::new(RawCache::with(
256 capacity,
257 capacity as u64,
258 WrappedWeighter::from(self.weighter),
259 H::default(),
260 expiry_lifecycle,
261 ));
262
263 let needs_drivers = self.expiration_interval.is_some() || self.telemetry_enabled;
265 let mut drivers = needs_drivers.then(|| scope::nested_or_detached(drivers_name));
266
267 if let (Some(drivers), Some(interval)) = (drivers.as_mut(), self.expiration_interval) {
268 drivers.spawn(
269 runtime::supervisable(ExpirationDriver {
270 cache: Arc::clone(&raw_cache),
271 telemetry: telemetry.clone(),
272 expiration: expiration.clone(),
273 interval,
274 })
275 .build(),
276 );
277 }
278
279 if let (Some(drivers), true) = (drivers.as_mut(), self.telemetry_enabled) {
280 drivers.spawn(
281 runtime::supervisable(TelemetryDriver {
282 cache: Arc::clone(&raw_cache),
283 telemetry: telemetry.clone(),
284 })
285 .build(),
286 );
287 }
288
289 Cache {
290 inner: Arc::new(InnerCache {
291 cache: raw_cache,
292 _drivers: drivers,
293 }),
294 expiration,
295 telemetry,
296 }
297 }
298}
299
300#[derive(Clone)]
302pub struct Cache<K, V, W = ItemCountWeighter, H = FastBuildHasher> {
303 inner: Arc<InnerCache<K, V, W, H>>,
304 expiration: Expiration<K>,
305 telemetry: Telemetry,
306}
307
308impl<K, V, W, H> Cache<K, V, W, H>
309where
310 K: Eq + std::hash::Hash + Clone,
311 V: Clone,
312 W: Weighter<K, V> + Clone,
313 H: std::hash::BuildHasher + Clone,
314{
315 pub fn is_empty(&self) -> bool {
317 self.inner.cache.is_empty()
318 }
319
320 pub fn len(&self) -> usize {
322 self.inner.cache.len()
323 }
324
325 pub fn weight(&self) -> u64 {
327 self.inner.cache.weight()
328 }
329
330 pub fn insert(&self, key: K, value: V) {
336 self.inner.cache.insert(key.clone(), value);
337 self.expiration.mark_entry_accessed(key);
338 self.telemetry.items_inserted_total().increment(1);
339 }
340
341 pub fn get(&self, key: &K) -> Option<V> {
345 let value = self.inner.cache.get(key);
346 if value.is_some() {
347 self.expiration.mark_entry_accessed(key.clone());
348 self.telemetry.hits_total().increment(1);
349 } else {
350 self.telemetry.misses_total().increment(1);
351 }
352 value
353 }
354
355 pub fn remove(&self, key: &K) {
357 self.inner.cache.remove(key);
358 self.expiration.mark_entry_removed(key.clone());
359 self.telemetry.items_removed_total().increment(1);
360 }
361}
362
363struct ExpirationDriver<K, V, W, H> {
365 cache: Arc<RawCache<K, V, W, H>>,
366 telemetry: Telemetry,
367 expiration: Expiration<K>,
368 interval: Duration,
369}
370
371#[async_trait]
372impl<K, V, W, H> Supervisable for ExpirationDriver<K, V, W, H>
373where
374 K: Eq + std::hash::Hash + Clone + Send + Sync + 'static,
375 V: Clone + Send + Sync + 'static,
376 W: Weighter<K, V> + Clone + Send + Sync + 'static,
377 H: std::hash::BuildHasher + Clone + Send + Sync + 'static,
378{
379 fn name(&self) -> &str {
380 "expiration"
381 }
382
383 async fn initialize(&self, process_shutdown: ShutdownHandle) -> Result<SupervisorFuture, InitializationError> {
384 let cache = Arc::clone(&self.cache);
385 let telemetry = self.telemetry.clone();
386 let expiration = self.expiration.clone();
387 let interval = self.interval;
388
389 Ok(Box::pin(async move {
390 drive_expiration(cache, telemetry, expiration, interval, process_shutdown).await;
391 Ok(())
392 }))
393 }
394}
395
396struct TelemetryDriver<K, V, W, H> {
398 cache: Arc<RawCache<K, V, W, H>>,
399 telemetry: Telemetry,
400}
401
402#[async_trait]
403impl<K, V, W, H> Supervisable for TelemetryDriver<K, V, W, H>
404where
405 K: Eq + std::hash::Hash + Clone + Send + Sync + 'static,
406 V: Clone + Send + Sync + 'static,
407 W: Weighter<K, V> + Clone + Send + Sync + 'static,
408 H: std::hash::BuildHasher + Clone + Send + Sync + 'static,
409{
410 fn name(&self) -> &str {
411 "telemetry"
412 }
413
414 async fn initialize(&self, process_shutdown: ShutdownHandle) -> Result<SupervisorFuture, InitializationError> {
415 let cache = Arc::clone(&self.cache);
416 let telemetry = self.telemetry.clone();
417
418 Ok(Box::pin(async move {
419 drive_telemetry(cache, telemetry, process_shutdown).await;
420 Ok(())
421 }))
422 }
423}
424
425async fn drive_expiration<K, V, W, H>(
426 cache: Arc<RawCache<K, V, W, H>>, telemetry: Telemetry, expiration: Expiration<K>, expiration_interval: Duration,
427 shutdown: ShutdownHandle,
428) where
429 K: Eq + std::hash::Hash + Clone,
430 V: Clone,
431 W: Weighter<K, V> + Clone,
432 H: std::hash::BuildHasher + Clone,
433{
434 let mut expired_item_keys = Vec::new();
435 pin!(shutdown);
436
437 loop {
438 select! {
439 _ = &mut shutdown => break,
440 _ = sleep(expiration_interval) => {}
441 }
442
443 expiration.drain_expired_items(&mut expired_item_keys);
445
446 let num_expired_items = expired_item_keys.len();
447 if num_expired_items != 0 {
448 telemetry.items_expired_total().increment(num_expired_items as u64);
449 telemetry.items_expired_batch_size().record(num_expired_items as f64);
450 }
451
452 debug!(num_expired_items, "Found expired items.");
453
454 for item_key in expired_item_keys.drain(..) {
455 cache.remove(&item_key);
456 telemetry.items_removed_total().increment(1);
457 expiration.mark_entry_removed(item_key);
458 }
459
460 debug!(num_expired_items, "Removed expired items.");
461 }
462}
463
464async fn drive_telemetry<K, V, W, H>(cache: Arc<RawCache<K, V, W, H>>, telemetry: Telemetry, shutdown: ShutdownHandle)
465where
466 K: Eq + std::hash::Hash + Clone,
467 V: Clone,
468 W: Weighter<K, V> + Clone,
469 H: std::hash::BuildHasher + Clone,
470{
471 pin!(shutdown);
472
473 loop {
474 select! {
475 _ = &mut shutdown => break,
476 _ = sleep(Duration::from_secs(1)) => {}
477 }
478
479 telemetry.current_items().set(cache.len() as f64);
480 telemetry.current_weight().set(cache.weight() as f64);
481 }
482}
483
484#[cfg(test)]
485mod tests {
486 use super::*;
487
488 #[derive(Clone)]
489 pub struct ItemValueWeighter;
490
491 impl<K> Weighter<K, usize> for ItemValueWeighter {
492 fn item_weight(&self, _key: &K, value: &usize) -> u64 {
493 *value as u64
494 }
495 }
496
497 #[test]
498 fn empty_cache_identifier() {
499 let result = CacheBuilder::<u64, u64>::from_identifier("");
500 assert!(result.is_err(), "expected error for empty cache identifier");
501 }
502
503 #[test]
504 fn basic() {
505 const CACHE_KEY: usize = 42;
506 const CACHE_VALUE: &str = "value1";
507
508 let cache = CacheBuilder::for_tests().build();
509
510 assert_eq!(cache.len(), 0);
511 assert_eq!(cache.weight(), 0);
512
513 cache.insert(CACHE_KEY, CACHE_VALUE);
514 assert_eq!(cache.len(), 1);
515 assert_eq!(cache.weight(), 1);
516
517 assert_eq!(cache.get(&CACHE_KEY), Some(CACHE_VALUE));
518
519 cache.remove(&CACHE_KEY);
520 assert_eq!(cache.len(), 0);
521 assert_eq!(cache.weight(), 0);
522 }
523
524 #[test]
525 fn evict_at_capacity() {
526 const CAPACITY: usize = 3;
527
528 let cache = CacheBuilder::for_tests()
529 .with_capacity(NonZeroUsize::new(CAPACITY).unwrap())
530 .build();
531
532 for i in 0..CAPACITY {
534 cache.insert(i, "value");
535 }
536
537 assert_eq!(cache.len(), CAPACITY);
538 assert_eq!(cache.weight(), CAPACITY as u64);
539
540 cache.insert(CAPACITY, "new_value");
543 assert_eq!(cache.len(), CAPACITY);
544 assert_eq!(cache.weight(), CAPACITY as u64);
545
546 let mut evicted = false;
547 for i in 0..CAPACITY {
548 if cache.get(&i).is_none() {
549 evicted = true;
550 break;
551 }
552 }
553 assert!(evicted, "expected at least one original item to be evicted");
554 }
555
556 #[test]
557 fn overweight_item() {
558 const CAPACITY: usize = 10;
559
560 let cache = CacheBuilder::for_tests()
562 .with_capacity(NonZeroUsize::new(CAPACITY).unwrap())
563 .with_item_weighter(ItemValueWeighter)
564 .build();
565
566 assert_eq!(cache.len(), 0);
568 assert_eq!(cache.weight(), 0);
569
570 cache.insert(1, CAPACITY + 1);
571 assert_eq!(cache.len(), 0);
572 assert_eq!(cache.weight(), 0);
573 assert_eq!(cache.get(&1), None);
574 }
575
576 #[test]
577 fn evict_on_insert_by_weight() {
578 const CAPACITY: usize = 10;
579
580 let cache = CacheBuilder::for_tests()
582 .with_capacity(NonZeroUsize::new(CAPACITY).unwrap())
583 .with_item_weighter(ItemValueWeighter)
584 .build();
585
586 assert_eq!(cache.len(), 0);
588 assert_eq!(cache.weight(), 0);
589
590 cache.insert(1, 3);
591 cache.insert(2, 4);
592 cache.insert(3, 3);
593 assert_eq!(cache.len(), 3);
594 assert_eq!(cache.weight(), CAPACITY as u64);
595
596 cache.insert(4, CAPACITY - 1);
599 assert_eq!(cache.len(), 1);
600 assert_eq!(cache.weight(), (CAPACITY - 1) as u64);
601
602 assert_eq!(cache.get(&1), None);
603 assert_eq!(cache.get(&2), None);
604 assert_eq!(cache.get(&3), None);
605 assert_eq!(cache.get(&4), Some(CAPACITY - 1));
606 }
607
608 #[tokio::test]
609 async fn tasks_stop_when_cache_dropped() {
610 let cache = CacheBuilder::<u64, u64>::from_identifier("test-drop")
611 .expect("valid identifier")
612 .with_time_to_idle(Some(Duration::from_secs(60)))
613 .with_expiration_interval(Duration::from_millis(50))
614 .build();
615
616 let weak_cache = Arc::downgrade(&cache.inner.cache);
618
619 drop(cache);
620
621 sleep(Duration::from_millis(100)).await;
629
630 assert!(
631 weak_cache.upgrade().is_none(),
632 "raw cache should be released after background tasks exit"
633 );
634 }
635
636 #[tokio::test]
637 async fn tasks_run_under_the_owning_process_and_stop_when_cache_dropped() {
638 use tokio::sync::oneshot;
639
640 use crate::runtime::{NodeSnapshot, Supervisor};
641 use crate::test_support::wait_until;
642
643 fn find<'a>(node: &'a NodeSnapshot, name: &str) -> Option<&'a NodeSnapshot> {
644 if node.name == name {
645 return Some(node);
646 }
647 node.children.iter().find_map(|child| find(child, name))
648 }
649
650 let (weak_tx, weak_rx) = oneshot::channel();
651 let (drop_tx, drop_rx) = oneshot::channel::<()>();
652
653 let mut supervisor = Supervisor::new("cache-test").expect("valid name");
654 let tree = supervisor.tree_handle();
655 supervisor.add_worker(
656 runtime::worker_with_shutdown("owner", move |shutdown| async move {
657 let cache = CacheBuilder::<u64, u64>::from_identifier("test-drop")
658 .expect("valid identifier")
659 .with_time_to_idle(Some(Duration::from_secs(60)))
660 .with_expiration_interval(Duration::from_millis(50))
661 .build();
662 let _ = weak_tx.send(Arc::downgrade(&cache.inner.cache));
663
664 let _ = drop_rx.await;
665 drop(cache);
666 shutdown.await;
667 })
668 .build(),
669 );
670
671 let (shutdown_tx, shutdown_rx) = oneshot::channel::<()>();
672 let run = tokio::spawn(async move { supervisor.run_with_shutdown(shutdown_rx).await });
673 let weak_cache = weak_rx.await.expect("owner should build the cache");
674
675 wait_until("the cache's tasks run beneath the owner", || {
678 let root = tree.snapshot().root;
679 find(&root, "cache_test-drop")
680 .is_some_and(|drivers| find(drivers, "expiration").is_some() && find(drivers, "telemetry").is_some())
681 })
682 .await;
683
684 drop_tx.send(()).expect("owner should be waiting");
685 wait_until("dropping the cache stops its tasks", || {
686 find(&tree.snapshot().root, "cache_test-drop").is_none()
687 })
688 .await;
689 wait_until("the raw cache is released", || weak_cache.upgrade().is_none()).await;
690
691 shutdown_tx.send(()).expect("supervisor should be running");
692 let result = tokio::time::timeout(Duration::from_secs(5), run)
693 .await
694 .expect("supervisor should stop")
695 .expect("supervisor task should not panic");
696 assert!(result.is_ok(), "the cache's tasks stopped on their own: {result:?}");
697 }
698}